Apply new ruff ruleset to code base.
Marco Ricci

Marco Ricci commited on 2024-09-01 13:43:33
Zeige 13 geänderte Dateien mit 351 Einfügungen und 136 Löschungen.


Results in many cosmetical code changes, and many documentation fixes.
(In particular, the docstring for
`derivepassphrase.cli.derivepassphrase` contained typos in the option
descriptions.)  Furthermore, three other changes are made:

  - Ignore `FURB101` and `FURB103` as well, which deal with similar
    functionality to the deselected `PTH` rules.
  - Change the attributes of the `VaultNativeConfigParser` class (and
    its subclasses) to private, instead of public. Change the
    `DummyModule` class in `derivepassphrase.exporter.storeroom` and
    `derivepassphrase.exporter.vault_v03_and_below` to private as well.
  - The modules `derivepassphrase.exporter.storeroom` and
    `derivepassphrase.exporter.vault_v03_and_below` are now no longer
    executable files.
... ...
@@ -189,27 +189,30 @@ quote-style = 'single'
189 189
 [tool.ruff.lint]
190 190
 ignore = [
191 191
     # Suggested ignore by ruff when also using ruff to format.  We *do*
192
-    # check for E501, because this usually only happens when there is a text
193
-    # string that should be manually broken.
194
-    'W191', 'E111', 'E114', 'E117', 'D206', 'D300', 'Q000', 'Q001', 'Q002',
195
-    'Q003', 'COM812', 'COM819', 'ISC001', 'ISC002',
192
+    # check for E501, because this usually only happens when there is
193
+    # a text string that should be manually broken.
194
+    'W191', 'E111', 'E114', 'E117', 'D206', 'D300', 'Q000', 'Q001',
195
+    'Q002', 'Q003', 'COM812', 'COM819', 'ISC001', 'ISC002',
196 196
     # We use `assert` regularly to appease the type checker, and because
197 197
     # it is the right language tool for this job.
198 198
     'S101',
199 199
     # The formatter takes care of trailing commas and docstring code
200 200
     # automatically.
201 201
     'COM812', 'W505',
202
-    # We document transitive exceptions as well (if we feel they would be
203
-    # surprising to the user otherwise).
202
+    # We document transitive exceptions as well (if we feel they would
203
+    # be surprising to the user otherwise).
204 204
     'DOC502',
205
-    # We currently don't have issues for every TODO.  Forcing an issue also
206
-    # goes against the philosophy of TODOs as low-overhead markers for
207
-    # future work; see
205
+    # We currently don't have issues for every TODO.  Forcing an issue
206
+    # also goes against the philosophy of TODOs as low-overhead markers
207
+    # for future work; see
208 208
     # https://gist.github.com/dmnd/ed5d8ef8de2e4cfea174bd5dafcda382 .
209 209
     'TD003',
210
-    # We somewhat regularly use loops where each iteration needs a separate
211
-    # try-except block.
210
+    # We somewhat regularly use loops where each iteration needs
211
+    # a separate try-except block.
212 212
     'PERF203',
213
+    # We do not currently use pathlib.  The PTH rules are unselected,
214
+    # but FURB includes several pathlib-related rules.
215
+    'FURB101', 'FURB103',
213 216
     # We catch type-ignore comments without specific code via the mypy
214 217
     # configuration, not via ruff.
215 218
     'PGH003',
... ...
@@ -226,13 +229,13 @@ select = [
226 229
     'INT', 'ARG',
227 230
     # We currently do not use pathlib. Disable 'PTH'.
228 231
     'TD',
229
-    # We use TODOs and FIXMEs as notes for later, and don't want the linter
230
-    # to nag about every occurrence.  Disable 'FIX'.
232
+    # We use TODOs and FIXMEs as notes for later, and don't want the
233
+    # linter to nag about every occurrence.  Disable 'FIX'.
231 234
     #
232
-    # The "eradicate" rule is prone to a lot of false positives, and it is
233
-    # unclear to me, and probably confusing to read, where to apply a noqa
234
-    # marker.  Instead, disable 'ERA', and if necessary, specify it on the
235
-    # command-line.
235
+    # The "eradicate" rule is prone to a lot of false positives, and it
236
+    # is unclear to me, and probably confusing to read, where to apply
237
+    # a noqa marker.  Instead, disable 'ERA', and if necessary, specify
238
+    # it on the command-line.
236 239
     'PD', 'PGH', 'PL', 'TRY', 'FLY', 'NPY', 'FAST',
237 240
     'AIR', 'PERF', 'FURB', 'DOC', 'RUF',
238 241
 ]
... ...
@@ -251,8 +254,9 @@ select = [
251 254
   'PLC1901',
252 255
   # Suggested by hatch, assumingly because tests may use "magic values".
253 256
   'PLR2004',
254
-  # Suggested by hatch, because tests are typically organized as classes and
255
-  # instance methods but may not really be using the `self` argument.
257
+  # Suggested by hatch, because tests are typically organized as classes
258
+  # and instance methods but may not really be using the `self`
259
+  # argument.
256 260
   'PLR6301',
257 261
   # Suggested by hatch, because these warnings may be precisely what the
258 262
   # tests are supposed to test.
... ...
@@ -274,8 +278,8 @@ select = [
274 278
   # Importing this from the tests directory would then automatically
275 279
   # trigger `PLC2701`.
276 280
   'PLC2701',
277
-  # Too many public methods/arguments/returns/branches/locals doesn't really
278
-  # apply here.
281
+  # Too many public methods/arguments/returns/branches/locals doesn't
282
+  # really apply here.
279 283
   'PLR0904', 'PLR0911', 'PLR0912', 'PLR0913', 'PLR0914', 'PLR0915',
280 284
   'PLR0916', 'PLR0917',
281 285
   # To fully test the `derivepassphrase.cli` module (and a couple other
... ...
@@ -2,7 +2,7 @@
2 2
 #
3 3
 # SPDX-License-Identifier: MIT
4 4
 
5
-"""Work-alike of vault(1) – a deterministic, stateless password manager"""  # noqa: RUF002
5
+"""Work-alike of vault(1) – a deterministic, stateless password manager"""  # noqa: D415,RUF002
6 6
 
7 7
 __author__ = 'Marco Ricci <m@the13thletter.info>'
8 8
 __version__ = '0.1.3'
... ...
@@ -114,7 +114,7 @@ class VaultConfig(TypedDict, _VaultConfig, total=False):
114 114
     services: Required[dict[str, VaultConfigServicesSettings]]
115 115
 
116 116
 
117
-def is_vault_config(obj: Any) -> TypeGuard[VaultConfig]:
117
+def is_vault_config(obj: Any) -> TypeGuard[VaultConfig]:  # noqa: ANN401,C901,PLR0911,PLR0912
118 118
     """Check if `obj` is a valid vault config, according to typing.
119 119
 
120 120
     Args:
... ...
@@ -123,7 +123,7 @@ def _save_config(config: _types.VaultConfig, /) -> None:
123 123
         os.makedirs(filedir, exist_ok=False)
124 124
     except FileExistsError:
125 125
         if not os.path.isdir(filedir):
126
-            raise
126
+            raise  # noqa: DOC501
127 127
     with open(filename, 'w', encoding='UTF-8') as fileobj:
128 128
         json.dump(config, fileobj)
129 129
 
... ...
@@ -186,7 +186,7 @@ def _get_suitable_ssh_keys(
186 186
         case _:  # pragma: no cover
187 187
             assert_never(conn)
188 188
             msg = f'invalid connection hint: {conn!r}'
189
-            raise TypeError(msg)
189
+            raise TypeError(msg)  # noqa: DOC501
190 190
     with client_context:
191 191
         try:
192 192
             all_key_comment_pairs = list(client.list_keys())
... ...
@@ -215,6 +215,8 @@ def _prompt_for_selection(
215 215
     instead, and return the correct index.
216 216
 
217 217
     Args:
218
+        items:
219
+            The list of items to choose from.
218 220
         heading:
219 221
             A heading for the list of items, to print immediately
220 222
             before.  Defaults to a reasonable standard heading.  If
... ...
@@ -408,7 +410,7 @@ class OptionGroupOption(click.Option):
408 410
     option_group_name: str = ''
409 411
     epilog: str = ''
410 412
 
411
-    def __init__(self, *args: Any, **kwargs: Any) -> None:
413
+    def __init__(self, *args: Any, **kwargs: Any) -> None:  # noqa: ANN401
412 414
         if self.__class__ == __class__:  # type: ignore[name-defined]
413 415
             raise NotImplementedError
414 416
         super().__init__(*args, **kwargs)
... ...
@@ -449,10 +451,6 @@ class CommandWithHelpGroups(click.Command):
449 451
             formatter:
450 452
                 The formatter for the `--help` listing.
451 453
 
452
-        Returns:
453
-            Nothing.  Output is generated by calling appropriate methods
454
-            on `formatter` instead.
455
-
456 454
         """
457 455
         help_records: dict[str, list[tuple[str, str]]] = {}
458 456
         epilogs: dict[str, str] = {}
... ...
@@ -519,9 +517,22 @@ class StorageManagementOption(OptionGroupOption):
519 517
 def _validate_occurrence_constraint(
520 518
     ctx: click.Context,
521 519
     param: click.Parameter,
522
-    value: Any,
520
+    value: Any,  # noqa: ANN401
523 521
 ) -> int | None:
524
-    """Check that the occurrence constraint is valid (int, 0 or larger)."""
522
+    """Check that the occurrence constraint is valid (int, 0 or larger).
523
+
524
+    Args:
525
+        ctx: The `click` context.
526
+        param: The current command-line parameter.
527
+        value: The parameter value to be checked.
528
+
529
+    Returns:
530
+        The parsed parameter value.
531
+
532
+    Raises:
533
+        click.BadParameter: The parameter value is invalid.
534
+
535
+    """
525 536
     del ctx  # Unused.
526 537
     del param  # Unused.
527 538
     if value is None:
... ...
@@ -543,9 +554,22 @@ def _validate_occurrence_constraint(
543 554
 def _validate_length(
544 555
     ctx: click.Context,
545 556
     param: click.Parameter,
546
-    value: Any,
557
+    value: Any,  # noqa: ANN401
547 558
 ) -> int | None:
548
-    """Check that the length is valid (int, 1 or larger)."""
559
+    """Check that the length is valid (int, 1 or larger).
560
+
561
+    Args:
562
+        ctx: The `click` context.
563
+        param: The current command-line parameter.
564
+        value: The parameter value to be checked.
565
+
566
+    Returns:
567
+        The parsed parameter value.
568
+
569
+    Raises:
570
+        click.BadParameter: The parameter value is invalid.
571
+
572
+    """
549 573
     del ctx  # Unused.
550 574
     del param  # Unused.
551 575
     if value is None:
... ...
@@ -729,7 +753,7 @@ DEFAULT_NOTES_MARKER = '# - - - - - >8 - - - - -'
729 753
 @click.version_option(version=dpp.__version__, prog_name=PROG_NAME)
730 754
 @click.argument('service', required=False)
731 755
 @click.pass_context
732
-def derivepassphrase(
756
+def derivepassphrase(  # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915
733 757
     ctx: click.Context,
734 758
     /,
735 759
     *,
... ...
@@ -807,13 +831,13 @@ def derivepassphrase(
807 831
             Command-line argument `--number`.  Same as `lower`, but for
808 832
             ASCII digits.
809 833
         space:
810
-            Command-line argument `--number`.  Same as `lower`, but for
834
+            Command-line argument `--space`.  Same as `lower`, but for
811 835
             the space character.
812 836
         dash:
813
-            Command-line argument `--number`.  Same as `lower`, but for
837
+            Command-line argument `--dash`.  Same as `lower`, but for
814 838
             the hyphen-minus and underscore characters.
815 839
         symbol:
816
-            Command-line argument `--number`.  Same as `lower`, but for
840
+            Command-line argument `--symbol`.  Same as `lower`, but for
817 841
             all other ASCII printable characters (except backquote).
818 842
         edit_notes:
819 843
             Command-line argument `-n`/`--notes`.  If given, spawn an
... ...
@@ -843,8 +867,7 @@ def derivepassphrase(
843 867
             a filename to open for reading.  Using `-` for standard
844 868
             input is supported.
845 869
 
846
-    """
847
-
870
+    """  # noqa: D301
848 871
     options_in_group: dict[type[click.Option], list[click.Option]] = {}
849 872
     params_by_str: dict[str, click.Parameter] = {}
850 873
     for param in ctx.command.params:
... ...
@@ -858,7 +881,7 @@ def derivepassphrase(
858 881
                 case StorageManagementOption():
859 882
                     group = StorageManagementOption
860 883
                 case OptionGroupOption():
861
-                    raise AssertionError(  # noqa: TRY003
884
+                    raise AssertionError(  # noqa: DOC501,TRY003
862 885
                         f'Unknown option group for {param!r}'  # noqa: EM102
863 886
                     )
864 887
                 case _:
... ...
@@ -940,7 +963,7 @@ def derivepassphrase(
940 963
         if is_param_set(param) and not service:
941 964
             opt_str = param.opts[0]
942 965
             msg = f'{opt_str} requires a SERVICE'
943
-            raise click.UsageError(msg)
966
+            raise click.UsageError(msg)  # noqa: DOC501
944 967
     for param in [params_by_str['--key'], params_by_str['--phrase']]:
945 968
         if is_param_set(param) and not (
946 969
             service or is_param_set(params_by_str['--config'])
... ...
@@ -967,7 +990,7 @@ def derivepassphrase(
967 990
         ).get('notes', '')
968 991
         notes_value = click.edit(text=text)
969 992
         if notes_value is not None:
970
-            notes_lines = collections.deque(notes_value.splitlines(True))
993
+            notes_lines = collections.deque(notes_value.splitlines(True))  # noqa: FBT003
971 994
             while notes_lines:
972 995
                 line = notes_lines.popleft()
973 996
                 if line.startswith(DEFAULT_NOTES_MARKER):
... ...
@@ -995,7 +1018,8 @@ def derivepassphrase(
995 1018
         put_config({'services': {}})
996 1019
     elif import_settings:
997 1020
         try:
998
-            # TODO: keep track of auto-close; try os.dup if feasible
1021
+            # TODO(the-13th-letter): keep track of auto-close; try
1022
+            # os.dup if feasible
999 1023
             infile = (
1000 1024
                 cast(TextIO, import_settings)
1001 1025
                 if hasattr(import_settings, 'close')
... ...
@@ -1032,7 +1056,8 @@ def derivepassphrase(
1032 1056
     elif export_settings:
1033 1057
         configuration = get_config()
1034 1058
         try:
1035
-            # TODO: keep track of auto-close; try os.dup if feasible
1059
+            # TODO(the-13th-letter): keep track of auto-close; try
1060
+            # os.dup if feasible
1036 1061
             outfile = (
1037 1062
                 cast(TextIO, export_settings)
1038 1063
                 if hasattr(export_settings, 'close')
... ...
@@ -35,7 +35,7 @@ def _load_data(
35 35
     fmt: Literal['v0.2', 'v0.3', 'storeroom'],
36 36
     path: str | bytes | os.PathLike[str],
37 37
     key: bytes,
38
-) -> Any:
38
+) -> Any:  # noqa: ANN401
39 39
     contents: bytes
40 40
     module: types.ModuleType
41 41
     match fmt:
... ...
@@ -1,4 +1,8 @@
1
-#!/usr/bin/python3
1
+# SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: MIT
4
+
5
+"""Exporter for the vault "storeroom" configuration format."""
2 6
 
3 7
 from __future__ import annotations
4 8
 
... ...
@@ -31,18 +35,18 @@ else:
31 35
         from cryptography.hazmat.primitives.kdf import pbkdf2
32 36
     except ModuleNotFoundError as exc:
33 37
 
34
-        class DummyModule:  # pragma: no cover
38
+        class _DummyModule:  # pragma: no cover
35 39
             def __init__(self, exc: type[Exception]) -> None:
36 40
                 self.exc = exc
37 41
 
38
-            def __getattr__(self, name: str) -> Any:
39
-                def func(*args: Any, **kwargs: Any) -> Any:  # noqa: ARG001
42
+            def __getattr__(self, name: str) -> Any:  # noqa: ANN401
43
+                def func(*args: Any, **kwargs: Any) -> Any:  # noqa: ANN401,ARG001
40 44
                     raise self.exc
41 45
 
42 46
                 return func
43 47
 
44
-        ciphers = hashes = hmac = padding = DummyModule(exc)
45
-        algorithms = modes = pbkdf2 = DummyModule(exc)
48
+        ciphers = hashes = hmac = padding = _DummyModule(exc)
49
+        algorithms = modes = pbkdf2 = _DummyModule(exc)
46 50
         STUBBED = True
47 51
     else:
48 52
         STUBBED = False
... ...
@@ -58,11 +62,36 @@ logger = logging.getLogger(__name__)
58 62
 
59 63
 
60 64
 class KeyPair(TypedDict):
65
+    """A pair of AES256 keys, one for encryption and one for signing.
66
+
67
+    Attributes:
68
+        encryption_key:
69
+            AES256 key, used for encryption with AES256-CBC (with PKCS#7
70
+            padding).
71
+        signing_key:
72
+            AES256 key, used for signing with HMAC-SHA256.
73
+
74
+    """
75
+
61 76
     encryption_key: bytes
62 77
     signing_key: bytes
63 78
 
64 79
 
65 80
 class MasterKeys(TypedDict):
81
+    """A triple of AES256 keys, for encryption, signing and hashing.
82
+
83
+    Attributes:
84
+        hashing_key:
85
+            AES256 key, used for hashing with HMAC-SHA256 to derive
86
+            a hash table slot for an item.
87
+        encryption_key:
88
+            AES256 key, used for encryption with AES256-CBC (with PKCS#7
89
+            padding).
90
+        signing_key:
91
+            AES256 key, used for signing with HMAC-SHA256.
92
+
93
+    """
94
+
66 95
     hashing_key: bytes
67 96
     encryption_key: bytes
68 97
     signing_key: bytes
... ...
@@ -94,7 +123,7 @@ def derive_master_keys_keys(password: str | bytes, iterations: int) -> KeyPair:
94 123
     if isinstance(password, str):
95 124
         password = password.encode('ASCII')
96 125
     master_keys_keys_blob = pbkdf2.PBKDF2HMAC(
97
-        algorithm=hashes.SHA1(),  # noqa: S303
126
+        algorithm=hashes.SHA1(),
98 127
         length=2 * KEY_SIZE,
99 128
         salt=STOREROOM_MASTER_KEYS_UUID,
100 129
         iterations=iterations,
... ...
@@ -157,6 +186,15 @@ def decrypt_master_keys_data(data: bytes, keys: KeyPair) -> MasterKeys:
157 186
     Returns:
158 187
         The master encryption, signing and hashing keys.
159 188
 
189
+    Raises:
190
+        cryptography.exceptions.InvalidSignature:
191
+            The data does not contain a valid signature under the given
192
+            key.
193
+        ValueError:
194
+            The format is invalid, in a non-cryptographic way.  (For
195
+            example, it contains an unsupported version marker, or
196
+            unexpected extra contents, or invalid padding.)
197
+
160 198
     """
161 199
     ciphertext, claimed_mac = struct.unpack(
162 200
         f'{len(data) - MAC_SIZE}s {MAC_SIZE}s', data
... ...
@@ -195,7 +233,7 @@ def decrypt_master_keys_data(data: bytes, keys: KeyPair) -> MasterKeys:
195 233
             f'Expecting 3 encrypted keys at {3 * KEY_SIZE} bytes total, '
196 234
             f'but found {len(plaintext)} instead'
197 235
         )
198
-        raise RuntimeError(msg)
236
+        raise ValueError(msg)
199 237
     hashing_key, encryption_key, signing_key = struct.unpack(
200 238
         f'{KEY_SIZE}s {KEY_SIZE}s {KEY_SIZE}s', plaintext
201 239
     )
... ...
@@ -238,8 +276,16 @@ def decrypt_session_keys(data: bytes, master_keys: MasterKeys) -> KeyPair:
238 276
     Returns:
239 277
         The bucket item's encryption and signing keys.
240 278
 
241
-    """
279
+    Raises:
280
+        cryptography.exceptions.InvalidSignature:
281
+            The data does not contain a valid signature under the given
282
+            key.
283
+        ValueError:
284
+            The format is invalid, in a non-cryptographic way.  (For
285
+            example, it contains an unsupported version marker, or
286
+            unexpected extra contents, or invalid padding.)
242 287
 
288
+    """
243 289
     ciphertext, claimed_mac = struct.unpack(
244 290
         f'{len(data) - MAC_SIZE}s {MAC_SIZE}s', data
245 291
     )
... ...
@@ -336,8 +382,16 @@ def decrypt_contents(data: bytes, session_keys: KeyPair) -> bytes:
336 382
     Returns:
337 383
         The bucket item's payload.
338 384
 
339
-    """
385
+    Raises:
386
+        cryptography.exceptions.InvalidSignature:
387
+            The data does not contain a valid signature under the given
388
+            key.
389
+        ValueError:
390
+            The format is invalid, in a non-cryptographic way.  (For
391
+            example, it contains an unsupported version marker, or
392
+            unexpected extra contents, or invalid padding.)
340 393
 
394
+    """
341 395
     ciphertext, claimed_mac = struct.unpack(
342 396
         f'{len(data) - MAC_SIZE}s {MAC_SIZE}s', data
343 397
     )
... ...
@@ -389,6 +443,30 @@ def decrypt_contents(data: bytes, session_keys: KeyPair) -> bytes:
389 443
 
390 444
 
391 445
 def decrypt_bucket_item(bucket_item: bytes, master_keys: MasterKeys) -> bytes:
446
+    """Decrypt a bucket item.
447
+
448
+    Args:
449
+        bucket_item:
450
+            The encrypted bucket item.
451
+        master_keys:
452
+            The master keys.  Presumably these have previously been
453
+            obtained via the
454
+            [`derivepassphrase.exporter.storeroom.decrypt_master_keys_data`][]
455
+            function.
456
+
457
+    Returns:
458
+        The decrypted bucket item.
459
+
460
+    Raises:
461
+        cryptography.exceptions.InvalidSignature:
462
+            The data does not contain a valid signature under the given
463
+            key.
464
+        ValueError:
465
+            The format is invalid, in a non-cryptographic way.  (For
466
+            example, it contains an unsupported version marker, or
467
+            unexpected extra contents, or invalid padding.)
468
+
469
+    """
392 470
     logger.debug(
393 471
         (
394 472
             'decrypt_bucket_item: data = bytes.fromhex(%s), '
... ...
@@ -408,7 +486,7 @@ def decrypt_bucket_item(bucket_item: bytes, master_keys: MasterKeys) -> bytes:
408 486
     )
409 487
     if data_version != 1:
410 488
         msg = f'Cannot handle version {data_version} encrypted data'
411
-        raise RuntimeError(msg)
489
+        raise ValueError(msg)
412 490
     session_keys = decrypt_session_keys(encrypted_session_keys, master_keys)
413 491
     return decrypt_contents(data_contents, session_keys)
414 492
 
... ...
@@ -419,6 +497,34 @@ def decrypt_bucket_file(
419 497
     *,
420 498
     root_dir: str | bytes | os.PathLike = '.',
421 499
 ) -> Iterator[bytes]:
500
+    """Decrypt a bucket item.
501
+
502
+    Args:
503
+        filename:
504
+            The bucket file's filename.
505
+        master_keys:
506
+            The master keys.  Presumably these have previously been
507
+            obtained via the
508
+            [`derivepassphrase.exporter.storeroom.decrypt_master_keys_data`][]
509
+            function.
510
+        root_dir:
511
+            The root directory of the data store.  The filename is
512
+            interpreted relatively to this directory.
513
+
514
+    Yields:
515
+        :
516
+            A decrypted bucket item.
517
+
518
+    Raises:
519
+        cryptography.exceptions.InvalidSignature:
520
+            The data does not contain a valid signature under the given
521
+            key.
522
+        ValueError:
523
+            The format is invalid, in a non-cryptographic way.  (For
524
+            example, it contains an unsupported version marker, or
525
+            unexpected extra contents, or invalid padding.)
526
+
527
+    """
422 528
     with open(
423 529
         os.path.join(os.fsdecode(root_dir), filename), 'rb'
424 530
     ) as bucket_file:
... ...
@@ -427,10 +533,10 @@ def decrypt_bucket_file(
427 533
             header = json.loads(header_line)
428 534
         except ValueError as exc:
429 535
             msg = f'Invalid bucket file: {filename}'
430
-            raise RuntimeError(msg) from exc
536
+            raise ValueError(msg) from exc
431 537
         if header != {'version': 1}:
432 538
             msg = f'Invalid bucket file: {filename}'
433
-            raise RuntimeError(msg) from None
539
+            raise ValueError(msg) from None
434 540
         for line in bucket_file:
435 541
             yield decrypt_bucket_item(
436 542
                 base64.standard_b64decode(line), master_keys
... ...
@@ -467,7 +573,7 @@ def store(config: dict[str, Any], path: str, json_contents: bytes) -> None:
467 573
         config[path_parts[-1]] = contents
468 574
 
469 575
 
470
-def export_storeroom_data(
576
+def export_storeroom_data(  # noqa: C901,PLR0912,PLR0914,PLR0915
471 577
     storeroom_path: str | bytes | os.PathLike | None = None,
472 578
     master_keys_key: str | bytes | None = None,
473 579
 ) -> dict[str, Any]:
... ...
@@ -1,4 +1,8 @@
1
-#!/usr/bin/python3
1
+# SPDX-FileCopyrightText: 2024 Marco Ricci <m@the13thletter.info>
2
+#
3
+# SPDX-License-Identifier: MIT
4
+
5
+"""Exporter for the vault native configuration format (v0.2 or v0.3)."""
2 6
 
3 7
 from __future__ import annotations
4 8
 
... ...
@@ -36,19 +40,19 @@ else:
36 40
         from cryptography.hazmat.primitives.kdf import pbkdf2
37 41
     except ModuleNotFoundError as exc:
38 42
 
39
-        class DummyModule:  # pragma: no cover
43
+        class _DummyModule:  # pragma: no cover
40 44
             def __init__(self, exc: type[Exception]) -> None:
41 45
                 self.exc = exc
42 46
 
43
-            def __getattr__(self, name: str) -> Any:
44
-                def func(*args: Any, **kwargs: Any) -> Any:  # noqa: ARG001
47
+            def __getattr__(self, name: str) -> Any:  # noqa: ANN401
48
+                def func(*args: Any, **kwargs: Any) -> Any:  # noqa: ANN401,ARG001
45 49
                     raise self.exc
46 50
 
47 51
                 return func
48 52
 
49
-        crypt_exceptions = crypt_utils = DummyModule(exc)
50
-        ciphers = hashes = hmac = padding = DummyModule(exc)
51
-        algorithms = modes = pbkdf2 = DummyModule(exc)
53
+        crypt_exceptions = crypt_utils = _DummyModule(exc)
54
+        ciphers = hashes = hmac = padding = _DummyModule(exc)
55
+        algorithms = modes = pbkdf2 = _DummyModule(exc)
52 56
         STUBBED = True
53 57
     else:
54 58
         STUBBED = False
... ...
@@ -61,26 +65,66 @@ def _h(bs: bytes | bytearray) -> str:
61 65
 
62 66
 
63 67
 class VaultNativeConfigParser(abc.ABC):
68
+    """A base parser for vault's native configuration format.
69
+
70
+    Certain details are specific to the respective vault versions, and
71
+    are abstracted out.  This class by itself is not instantiable
72
+    because of this.
73
+
74
+    """
75
+
64 76
     def __init__(self, contents: Buffer, password: str | Buffer) -> None:
77
+        """Initialize the parser.
78
+
79
+        Args:
80
+            contents:
81
+                The binary contents of the encrypted configuration file.
82
+
83
+                Note: On disk, these are usually stored in
84
+                base64-encoded form, not in the "raw" form as needed
85
+                here.
86
+
87
+            password:
88
+                The vault master key/master passphrase the file is
89
+                encrypted with.  Must be non-empty.  See
90
+                [`derivepassphrase.exporter.get_vault_key`][] for
91
+                details.
92
+
93
+                If this is a text string, then the UTF-8 encoding of the
94
+                string is used as the binary password.
95
+
96
+        """
65 97
         if not password:
66 98
             msg = 'Password must not be empty'
67
-            raise ValueError(msg)
99
+            raise ValueError(msg)  # noqa: DOC501
68 100
         self._contents = bytes(contents)
69
-        self.iv_size = 0
70
-        self.mac_size = 0
71
-        self.encryption_key = b''
72
-        self.encryption_key_size = 0
73
-        self.signing_key = b''
74
-        self.signing_key_size = 0
75
-        self.message = b''
76
-        self.message_tag = b''
77
-        self.iv = b''
78
-        self.payload = b''
101
+        self._iv_size = 0
102
+        self._mac_size = 0
103
+        self._encryption_key = b''
104
+        self._encryption_key_size = 0
105
+        self._signing_key = b''
106
+        self._signing_key_size = 0
107
+        self._message = b''
108
+        self._message_tag = b''
109
+        self._iv = b''
110
+        self._payload = b''
79 111
         self._password = password
80 112
         self._sentinel: object = object()
81 113
         self._data: Any = self._sentinel
82 114
 
83
-    def __call__(self) -> Any:
115
+    def __call__(self) -> Any:  # noqa: ANN401
116
+        """Return the decrypted and parsed vault configuration.
117
+
118
+        Raises:
119
+            cryptography.exceptions.InvalidSignature:
120
+                The encrypted configuration does not contain a valid
121
+                signature.
122
+            ValueError:
123
+                The format is invalid, in a non-cryptographic way.  (For
124
+                example, it contains an unsupported version marker, or
125
+                unexpected extra contents, or invalid padding.)
126
+
127
+        """
84 128
         if self._data is self._sentinel:
85 129
             self._parse_contents()
86 130
             self._derive_keys()
... ...
@@ -89,13 +133,13 @@ class VaultNativeConfigParser(abc.ABC):
89 133
         return self._data
90 134
 
91 135
     @staticmethod
92
-    def pbkdf2(
136
+    def _pbkdf2(
93 137
         password: str | Buffer, key_size: int, iterations: int
94 138
     ) -> bytes:
95 139
         if isinstance(password, str):
96 140
             password = password.encode('utf-8')
97 141
         raw_key = pbkdf2.PBKDF2HMAC(
98
-            algorithm=hashes.SHA1(),  # noqa: S303
142
+            algorithm=hashes.SHA1(),
99 143
             length=key_size // 2,
100 144
             salt=vault.Vault._UUID,  # noqa: SLF001
101 145
             iterations=iterations,
... ...
@@ -115,35 +159,35 @@ class VaultNativeConfigParser(abc.ABC):
115 159
     def _parse_contents(self) -> None:
116 160
         logger.info('Parsing IV, payload and signature from the file contents')
117 161
 
118
-        if len(self._contents) < self.iv_size + 16 + self.mac_size:
162
+        if len(self._contents) < self._iv_size + 16 + self._mac_size:
119 163
             msg = 'Invalid vault configuration file: file is truncated'
120 164
             raise ValueError(msg)
121 165
 
122 166
         def cut(buffer: bytes, cutpoint: int) -> tuple[bytes, bytes]:
123 167
             return buffer[:cutpoint], buffer[cutpoint:]
124 168
 
125
-        cutpos1 = len(self._contents) - self.mac_size
126
-        cutpos2 = self.iv_size
169
+        cutpos1 = len(self._contents) - self._mac_size
170
+        cutpos2 = self._iv_size
127 171
 
128
-        self.message, self.message_tag = cut(self._contents, cutpos1)
129
-        self.iv, self.payload = cut(self.message, cutpos2)
172
+        self._message, self._message_tag = cut(self._contents, cutpos1)
173
+        self._iv, self._payload = cut(self._message, cutpos2)
130 174
 
131 175
         logger.debug(
132 176
             'buffer %s = [[%s, %s], %s]',
133 177
             _h(self._contents),
134
-            _h(self.iv),
135
-            _h(self.payload),
136
-            _h(self.message_tag),
178
+            _h(self._iv),
179
+            _h(self._payload),
180
+            _h(self._message_tag),
137 181
         )
138 182
 
139 183
     def _derive_keys(self) -> None:
140 184
         logger.info('Deriving an encryption and signing key')
141 185
         self._generate_keys()
142 186
         assert (
143
-            len(self.encryption_key) == self.encryption_key_size
187
+            len(self._encryption_key) == self._encryption_key_size
144 188
         ), 'Derived encryption key is invalid'
145 189
         assert (
146
-            len(self.signing_key) == self.signing_key_size
190
+            len(self._signing_key) == self._signing_key_size
147 191
         ), 'Derived signing key is invalid'
148 192
 
149 193
     @abc.abstractmethod
... ...
@@ -152,16 +196,16 @@ class VaultNativeConfigParser(abc.ABC):
152 196
 
153 197
     def _check_signature(self) -> None:
154 198
         logger.info('Checking HMAC signature')
155
-        mac = hmac.HMAC(self.signing_key, hashes.SHA256())
199
+        mac = hmac.HMAC(self._signing_key, hashes.SHA256())
156 200
         mac_input = self._hmac_input()
157 201
         logger.debug(
158 202
             'mac_input = %s, expected_tag = %s',
159 203
             _h(mac_input),
160
-            _h(self.message_tag),
204
+            _h(self._message_tag),
161 205
         )
162 206
         mac.update(mac_input)
163 207
         try:
164
-            mac.verify(self.message_tag)
208
+            mac.verify(self._message_tag)
165 209
         except crypt_exceptions.InvalidSignature:
166 210
             msg = 'File does not contain a valid signature'
167 211
             raise ValueError(msg) from None
... ...
@@ -170,13 +214,13 @@ class VaultNativeConfigParser(abc.ABC):
170 214
     def _hmac_input(self) -> bytes:
171 215
         raise AssertionError
172 216
 
173
-    def _decrypt_payload(self) -> Any:
217
+    def _decrypt_payload(self) -> Any:  # noqa: ANN401
174 218
         decryptor = self._make_decryptor()
175 219
         padded_plaintext = bytearray()
176
-        padded_plaintext.extend(decryptor.update(self.payload))
220
+        padded_plaintext.extend(decryptor.update(self._payload))
177 221
         padded_plaintext.extend(decryptor.finalize())
178 222
         logger.debug('padded plaintext = %s', _h(padded_plaintext))
179
-        unpadder = padding.PKCS7(self.iv_size * 8).unpadder()
223
+        unpadder = padding.PKCS7(self._iv_size * 8).unpadder()
180 224
         plaintext = bytearray()
181 225
         plaintext.extend(unpadder.update(padded_plaintext))
182 226
         plaintext.extend(unpadder.finalize())
... ...
@@ -189,40 +233,52 @@ class VaultNativeConfigParser(abc.ABC):
189 233
 
190 234
 
191 235
 class VaultNativeV03ConfigParser(VaultNativeConfigParser):
236
+    """A parser for vault's native configuration format (v0.3).
237
+
238
+    This is the modern, pre-storeroom configuration format.
239
+
240
+    """
241
+
192 242
     KEY_SIZE = 32
193 243
 
194
-    def __init__(self, *args: Any, **kwargs: Any) -> None:
244
+    def __init__(self, *args: Any, **kwargs: Any) -> None:  # noqa: ANN401,D107
195 245
         super().__init__(*args, **kwargs)
196
-        self.iv_size = 16
197
-        self.mac_size = 32
246
+        self._iv_size = 16
247
+        self._mac_size = 32
198 248
 
199
-    def __call__(self) -> Any:
249
+    def __call__(self) -> Any:  # noqa: ANN401,D102
200 250
         if self._data is self._sentinel:
201 251
             logger.info('Attempting to parse as v0.3 configuration')
202 252
             return super().__call__()
203 253
         return self._data
204 254
 
205 255
     def _generate_keys(self) -> None:
206
-        self.encryption_key = self.pbkdf2(self._password, self.KEY_SIZE, 100)
207
-        self.signing_key = self.pbkdf2(self._password, self.KEY_SIZE, 200)
208
-        self.encryption_key_size = self.signing_key_size = self.KEY_SIZE
256
+        self._encryption_key = self._pbkdf2(self._password, self.KEY_SIZE, 100)
257
+        self._signing_key = self._pbkdf2(self._password, self.KEY_SIZE, 200)
258
+        self._encryption_key_size = self._signing_key_size = self.KEY_SIZE
209 259
 
210 260
     def _hmac_input(self) -> bytes:
211
-        return self.message.hex().lower().encode('ASCII')
261
+        return self._message.hex().lower().encode('ASCII')
212 262
 
213 263
     def _make_decryptor(self) -> ciphers.CipherContext:
214 264
         return ciphers.Cipher(
215
-            algorithms.AES256(self.encryption_key), modes.CBC(self.iv)
265
+            algorithms.AES256(self._encryption_key), modes.CBC(self._iv)
216 266
         ).decryptor()
217 267
 
218 268
 
219 269
 class VaultNativeV02ConfigParser(VaultNativeConfigParser):
220
-    def __init__(self, *args: Any, **kwargs: Any) -> None:
270
+    """A parser for vault's native configuration format (v0.3).
271
+
272
+    This is the modern, pre-storeroom configuration format.
273
+
274
+    """
275
+
276
+    def __init__(self, *args: Any, **kwargs: Any) -> None:  # noqa: ANN401,D107
221 277
         super().__init__(*args, **kwargs)
222
-        self.iv_size = 16
223
-        self.mac_size = 64
278
+        self._iv_size = 16
279
+        self._mac_size = 64
224 280
 
225
-    def __call__(self) -> Any:
281
+    def __call__(self) -> Any:  # noqa: ANN401,D102
226 282
         if self._data is self._sentinel:
227 283
             logger.info('Attempting to parse as v0.2 configuration')
228 284
             return super().__call__()
... ...
@@ -231,17 +287,17 @@ class VaultNativeV02ConfigParser(VaultNativeConfigParser):
231 287
     def _parse_contents(self) -> None:
232 288
         super()._parse_contents()
233 289
         logger.debug('Decoding payload (base64) and message tag (hex)')
234
-        self.payload = base64.standard_b64decode(self.payload)
235
-        self.message_tag = bytes.fromhex(self.message_tag.decode('ASCII'))
290
+        self._payload = base64.standard_b64decode(self._payload)
291
+        self._message_tag = bytes.fromhex(self._message_tag.decode('ASCII'))
236 292
 
237 293
     def _generate_keys(self) -> None:
238
-        self.encryption_key = self.pbkdf2(self._password, 8, 16)
239
-        self.signing_key = self.pbkdf2(self._password, 16, 16)
240
-        self.encryption_key_size = 8
241
-        self.signing_key_size = 16
294
+        self._encryption_key = self._pbkdf2(self._password, 8, 16)
295
+        self._signing_key = self._pbkdf2(self._password, 16, 16)
296
+        self._encryption_key_size = 8
297
+        self._signing_key_size = 16
242 298
 
243 299
     def _hmac_input(self) -> bytes:
244
-        return base64.standard_b64encode(self.message)
300
+        return base64.standard_b64encode(self._message)
245 301
 
246 302
     def _make_decryptor(self) -> ciphers.CipherContext:
247 303
         def evp_bytestokey_md5_one_iteration_no_salt(
... ...
@@ -268,7 +324,7 @@ class VaultNativeV02ConfigParser(VaultNativeConfigParser):
268 324
                     warnings.simplefilter(
269 325
                         'ignore', crypt_utils.CryptographyDeprecationWarning
270 326
                     )
271
-                    block = hashes.Hash(hashes.MD5())  # noqa: S303
327
+                    block = hashes.Hash(hashes.MD5())
272 328
                 block.update(last_block)
273 329
                 block.update(data)
274 330
                 block.update(salt)
... ...
@@ -284,7 +340,7 @@ class VaultNativeV02ConfigParser(VaultNativeConfigParser):
284 340
             )
285 341
             return bytes(buffer[:key_size]), bytes(buffer[key_size:total_size])
286 342
 
287
-        data = base64.standard_b64encode(self.iv + self.encryption_key)
343
+        data = base64.standard_b64encode(self._iv + self._encryption_key)
288 344
         encryption_key, iv = evp_bytestokey_md5_one_iteration_no_salt(
289 345
             data, key_size=32, iv_size=16
290 346
         )
... ...
@@ -64,7 +64,7 @@ class Sequin:
64 64
         /,
65 65
         *,
66 66
         is_bitstring: bool = False,
67
-    ):
67
+    ) -> None:
68 68
         """Initialize the Sequin.
69 69
 
70 70
         Args:
... ...
@@ -176,6 +176,9 @@ class Sequin:
176 176
             digits: A sequence of integers to evaluate.
177 177
             base: The number base to evaluate those integers in.
178 178
 
179
+        Returns:
180
+            The number value of the integer sequence.
181
+
179 182
         Raises:
180 183
             ValueError: `base` is an invalid base.
181 184
             ValueError: Not all integers are valid base `base` digits.
... ...
@@ -204,7 +207,7 @@ class Sequin:
204 207
             x = digits[i]
205 208
             if not isinstance(x, int):
206 209
                 msg = f'not an integer: {x!r}'
207
-                raise TypeError(msg)
210
+                raise TypeError(msg)  # noqa: DOC501
208 211
             if x not in allowed_range:
209 212
                 msg = f'invalid base {base!r} digit: {x!r}'
210 213
                 raise ValueError(msg)
... ...
@@ -387,5 +390,5 @@ class SequinExhaustedError(Exception):
387 390
 
388 391
     """
389 392
 
390
-    def __init__(self) -> None:
393
+    def __init__(self) -> None:  # noqa: D107
391 394
         super().__init__('Sequin is exhausted')
... ...
@@ -115,7 +115,12 @@ class SSHAgentClient:
115 115
             self._connection.connect(ssh_auth_sock)
116 116
 
117 117
     def __enter__(self) -> Self:
118
-        """Close socket connection upon context manager completion."""
118
+        """Close socket connection upon context manager completion.
119
+
120
+        Returns:
121
+            Self.
122
+
123
+        """
119 124
         self._connection.__enter__()
120 125
         return self
121 126
 
... ...
@@ -125,7 +130,18 @@ class SSHAgentClient:
125 130
         exc_val: BaseException | None,
126 131
         exc_tb: TracebackType | None,
127 132
     ) -> bool:
128
-        """Close socket connection upon context manager completion."""
133
+        """Close socket connection upon context manager completion.
134
+
135
+        Args:
136
+            exc_type: An optional exception type.
137
+            exc_val: An optional exception value.
138
+            exc_tb: An optional exception traceback.
139
+
140
+        Returns:
141
+            True if the exception was handled, false if it should
142
+            propagate.
143
+
144
+        """
129 145
         return bool(
130 146
             self._connection.__exit__(exc_type, exc_val, exc_tb)  # type: ignore[func-returns-value]
131 147
         )
... ...
@@ -173,7 +189,7 @@ class SSHAgentClient:
173 189
             ret.extend(payload)
174 190
         except Exception as e:
175 191
             msg = 'invalid payload type'
176
-            raise TypeError(msg) from e
192
+            raise TypeError(msg) from e  # noqa: DOC501
177 193
         return ret
178 194
 
179 195
     @classmethod
... ...
@@ -2,7 +2,7 @@
2 2
 #
3 3
 # SPDX-License-Identifier: MIT
4 4
 
5
-"""Python port of the vault(1) password generation scheme"""
5
+"""Python port of the vault(1) password generation scheme."""
6 6
 
7 7
 from __future__ import annotations
8 8
 
... ...
@@ -71,7 +71,7 @@ class Vault:
71 71
 
72 72
     """
73 73
 
74
-    def __init__(
74
+    def __init__(  # noqa: PLR0913
75 75
         self,
76 76
         *,
77 77
         phrase: bytes | bytearray | str = b'',
... ...
@@ -113,6 +113,11 @@ class Vault:
113 113
                 Same as `lower`, but for all other hitherto unlisted
114 114
                 ASCII printable characters (except backquote).
115 115
 
116
+        Raises:
117
+            ValueError:
118
+                Conflicting passphrase constraints.  Permit more
119
+                characters, or increase the desired passphrase length.
120
+
116 121
         """
117 122
         self._phrase = self._get_binary_string(phrase)
118 123
         self._length = length
... ...
@@ -204,10 +209,10 @@ class Vault:
204 209
             safety_factor = float(safety_factor)
205 210
         except TypeError as e:
206 211
             msg = f'invalid safety factor: not a float: {safety_factor!r}'
207
-            raise TypeError(msg) from e
212
+            raise TypeError(msg) from e  # noqa: DOC501
208 213
         if not math.isfinite(safety_factor) or safety_factor < 1.0:
209 214
             msg = f'invalid safety factor {safety_factor!r}'
210
-            raise ValueError(msg)
215
+            raise ValueError(msg)  # noqa: DOC501
211 216
         # Ensure the bound is strictly positive.
212 217
         entropy_bound = max(1, self._entropy())
213 218
         return int(math.ceil(safety_factor * entropy_bound / 8))
... ...
@@ -255,4 +255,4 @@ class TestVault:
255 255
         with pytest.raises(
256 256
             TypeError, match='invalid safety factor: not a float'
257 257
         ):
258
-            assert v._estimate_sufficient_hash_length(None)  # type: ignore
258
+            assert v._estimate_sufficient_hash_length(None)  # type: ignore[arg-type]
... ...
@@ -392,7 +392,7 @@ class TestCLI:
392 392
         key_index: int,
393 393
     ) -> None:
394 394
         def sign(
395
-            _, key: bytes | bytearray, message: bytes | bytearray
395
+            _: Any, key: bytes | bytearray, message: bytes | bytearray
396 396
         ) -> bytes:
397 397
             del message  # Unused.
398 398
             for value in tests.SUPPORTED_KEYS.values():
... ...
@@ -1299,7 +1299,7 @@ contents go here
1299 1299
                     'services': {
1300 1300
                         DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(),
1301 1301
                         'weird entry name': {'phrase': 'D\u00fcsseldorf'},
1302
-                    }
1302
+                    },
1303 1303
                 }),
1304 1304
                 (
1305 1305
                     "the services.'weird entry name' passphrase "
... ...
@@ -1342,7 +1342,7 @@ class TestCLIUtils:
1342 1342
             ),
1343 1343
             pytest.raises(ValueError, match='Invalid vault config'),
1344 1344
         ):
1345
-            cli._save_config(None)  # type: ignore
1345
+            cli._save_config(None)  # type: ignore[arg-type]
1346 1346
 
1347 1347
     def test_101_prompt_for_selection_multiple(self) -> None:
1348 1348
         @click.command()
... ...
@@ -235,7 +235,7 @@ class TestStoreroom:
235 235
             'signing_key': bytes(storeroom.KEY_SIZE),
236 236
             'hashing_key': bytes(storeroom.KEY_SIZE),
237 237
         }
238
-        with pytest.raises(RuntimeError, match='Cannot handle version 255'):
238
+        with pytest.raises(ValueError, match='Cannot handle version 255'):
239 239
             storeroom.decrypt_bucket_item(bucket_item, master_keys)
240 240
 
241 241
     @pytest.mark.parametrize('config', ['xxx', 'null', '{"version": 255}'])
... ...
@@ -259,7 +259,7 @@ class TestStoreroom:
259 259
         ):
260 260
             with open('.vault/20', 'w', encoding='UTF-8') as outfile:
261 261
                 print(config, file=outfile)
262
-            with pytest.raises(RuntimeError, match='Invalid bucket file: '):
262
+            with pytest.raises(ValueError, match='Invalid bucket file: '):
263 263
                 list(storeroom.decrypt_bucket_file('.vault/20', master_keys))
264 264
 
265 265
     @pytest.mark.parametrize(
... ...
@@ -317,7 +317,7 @@ class TestVaultNativeConfig:
317 317
     )
318 318
     def test_200_pbkdf2_manually(self, iterations: int, result: bytes) -> None:
319 319
         assert (
320
-            vault_v03_and_below.VaultNativeConfigParser.pbkdf2(
320
+            vault_v03_and_below.VaultNativeConfigParser._pbkdf2(
321 321
                 tests.VAULT_MASTER_KEY.encode('utf-8'), 32, iterations
322 322
             )
323 323
             == result
324 324