Update tooling (mypy/pyrefly, mkdocstrings overrides)
Marco Ricci

Marco Ricci commited on 2026-06-24 18:35:06
Zeige 19 geänderte Dateien mit 95 Einfügungen und 74 Löschungen.


Add a pyrefly configuration, which newer versions of hatch and VS Code
prefer over mypy.  Fix violations that pyrefly complains about, but mypy
doesn't, e.g., `del (list_of_variables)`.  Remove unnecessary casts --
pyrefly warns on those, but mypy doesn't -- and document those necessary
casts explicitly, even if they are a tool deficiency (usually mypy).
Conversely, add conversions or tighter type bounds where necessary,
usually when interacting with third-party libraries that use too lax
typing because mypy doesn't complain (cryptography, click).  To be fair,
I have caused my fair share of those as well.

Finally, fix the "devsetup" mkdocs configuration that should have been
showing private members in the API documentation (if document), but
wasn't.
... ...
@@ -83,3 +83,10 @@ nav:
83 83
     - Release checklist: _release_checklist.md
84 84
   - Wishlist:
85 85
     - wishlist/index.md
86
+
87
+plugins:
88
+  mkdocstrings:
89
+    handlers:
90
+      python:
91
+        options:
92
+          filters: []
... ...
@@ -434,6 +434,23 @@ implicit_reexport = false
434 434
 sqlite_cache = true
435 435
 enable_error_code = ['ignore-without-code']
436 436
 
437
+[tool.pyrefly]
438
+project-includes = [
439
+    "src/**/*.py",
440
+    "tests/**/*.py",
441
+]
442
+search-path = [
443
+    "src",
444
+    "other-stubs",
445
+    ".",
446
+]
447
+preset = "legacy"
448
+infer-return-types = "never"
449
+permissive-ignores = true
450
+
451
+[tool.pyrefly.errors]
452
+unnecessary-type-conversion = "ignore"
453
+
437 454
 [tool.pytest.ini_options]
438 455
 addopts = '--doctest-modules --dist=loadgroup --import-mode=importlib'
439 456
 pythonpath = ['src']
... ...
@@ -136,7 +136,7 @@ def shell_complete_service(
136 136
     ctx: click.Context,
137 137
     parameter: click.Parameter,
138 138
     value: str,
139
-) -> list[str | click.shell_completion.CompletionItem]:
139
+) -> list[str]:
140 140
     """Return known vault service names as completion items.
141 141
 
142 142
     Service names are looked up in the vault configuration file.  All
... ...
@@ -137,7 +137,7 @@ class DebugTranslations(gettext.NullTranslations):
137 137
         cache = _debug_translation_message_cache
138 138
         for enum_class in MSG_TEMPLATE_CLASSES:
139 139
             for member in enum_class.__members__.values():
140
-                value = cast("TranslatableString", member.value)
140
+                value = member.value
141 141
                 queue: list[tuple[TranslatableString, frozenset[str]]] = [
142 142
                     (value, frozenset())
143 143
                 ]
... ...
@@ -170,7 +170,7 @@ class DebugTranslations(gettext.NullTranslations):
170 170
             return message if not message_plural or n == 1 else message_plural
171 171
         return cls._format_enum_name_maybe_with_fields(
172 172
             enum_name=str(enum_value),
173
-            ts=cast("TranslatableString", enum_value.value),
173
+            ts=enum_value.value,
174 174
             trimmed=trimmed,
175 175
         )
176 176
 
... ...
@@ -194,7 +194,6 @@ class DebugTranslations(gettext.NullTranslations):
194 194
     def gettext(
195 195
         self,
196 196
         message: str,
197
-        /,
198 197
     ) -> str:
199 198
         return self._locate_message(message)
200 199
 
... ...
@@ -204,7 +203,6 @@ class DebugTranslations(gettext.NullTranslations):
204 203
         msgid1: str,
205 204
         msgid2: str,
206 205
         n: int,
207
-        /,
208 206
     ) -> str:  # pragma: no cover [unused]
209 207
         """"""  # noqa: D419
210 208
         return self._locate_message(msgid1, message_plural=msgid2, n=n)
... ...
@@ -214,7 +212,6 @@ class DebugTranslations(gettext.NullTranslations):
214 212
         self,
215 213
         context: str,
216 214
         message: str,
217
-        /,
218 215
     ) -> str:
219 216
         return self._locate_message(message, context=context)
220 217
 
... ...
@@ -225,7 +222,6 @@ class DebugTranslations(gettext.NullTranslations):
225 222
         msgid1: str,
226 223
         msgid2: str,
227 224
         n: int,
228
-        /,
229 225
     ) -> str:  # pragma: no cover [unused]
230 226
         """"""  # noqa: D419
231 227
         return self._locate_message(
... ...
@@ -501,9 +497,18 @@ class TranslatedString:
501 497
                 More keyword arguments to be passed to [`str.format`][].
502 498
 
503 499
         """
504
-        if isinstance(template, MSG_TEMPLATE_CLASSES):
505
-            template = cast("TranslatableString", template.value)
506
-        self.template = template
500
+        # The cast is still needed for mypy.  For this use case --
501
+        # multiple related enums, all values of which share the same
502
+        # type, even across enums -- mypy's type inference is
503
+        # frustratingly surface-level.
504
+        #
505
+        # pyrefly: ignore [redundant-cast]
506
+        self.template = cast(
507
+            "str | TranslatableString",
508
+            template.value
509
+            if isinstance(template, MSG_TEMPLATE_CLASSES)
510
+            else template,
511
+        )
507 512
         self.kwargs = {**args_dict, **kwargs}
508 513
         self._rendered: str | None = None
509 514
 
... ...
@@ -2470,7 +2475,7 @@ def _write_po_file(  # noqa: C901,PLR0912
2470 2475
     entries: dict[str, dict[str, MsgTemplate]] = {}
2471 2476
     for enum_class in MSG_TEMPLATE_CLASSES:
2472 2477
         for member in enum_class.__members__.values():
2473
-            value = cast("TranslatableString", member.value)
2478
+            value = member.value
2474 2479
             ctx = value.l10n_context
2475 2480
             msg = value.singular
2476 2481
             if (
... ...
@@ -2569,7 +2574,7 @@ def _write_po_file(  # noqa: C901,PLR0912
2569 2574
         for _msg, enum_value in sorted(
2570 2575
             subdict.items(), key=lambda kv: str(kv[1])
2571 2576
         ):
2572
-            value = cast("TranslatableString", enum_value.value)
2577
+            value = enum_value.value
2573 2578
             value2 = value.maybe_without_filename()
2574 2579
             fileobj.writelines(
2575 2580
                 _format_po_entry(
... ...
@@ -2627,7 +2632,7 @@ def _format_po_entry(
2627 2632
 ) -> tuple[str, ...]:  # pragma: no cover [internal]
2628 2633
     """"""  # noqa: D419
2629 2634
     ret: list[str] = ["\n"]
2630
-    ts = transformed_string or cast("TranslatableString", enum_value.value)
2635
+    ts = transformed_string or enum_value.value
2631 2636
     if ts.translator_comments:
2632 2637
         comments = ts.translator_comments.splitlines(False)  # noqa: FBT003
2633 2638
         comments.extend(["", f"Message-ID: {enum_value}"])
... ...
@@ -1283,7 +1283,7 @@ class _VaultContext:  # noqa: PLR0904
1283 1283
             notes_legacy_instructions = _msg.TranslatedString(
1284 1284
                 _msg.Label.DERIVEPASSPHRASE_VAULT_NOTES_LEGACY_INSTRUCTION_TEXT
1285 1285
             )
1286
-            old_notes_value = subtree.get("notes", "")
1286
+            old_notes_value = cast("str", subtree.get("notes", ""))
1287 1287
             if modern_editor_interface:
1288 1288
                 text = "\n".join([
1289 1289
                     str(notes_instructions),
... ...
@@ -1292,8 +1292,9 @@ class _VaultContext:  # noqa: PLR0904
1292 1292
                 ])
1293 1293
             else:
1294 1294
                 text = old_notes_value or str(notes_legacy_instructions)
1295
+            # pyrefly: ignore [bad-argument-type]
1295 1296
             notes_value = click.edit(text=text, require_save=False)
1296
-            assert notes_value is not None
1297
+            assert isinstance(notes_value, str)
1297 1298
             if (
1298 1299
                 not modern_editor_interface
1299 1300
                 and notes_value.strip() != old_notes_value.strip()
... ...
@@ -1428,7 +1429,7 @@ class _VaultContext:  # noqa: PLR0904
1428 1429
             "symbol",
1429 1430
         }
1430 1431
         kwargs = {
1431
-            cast("str", k): cast("int", settings[k])
1432
+            k: cast("int", settings[k])
1432 1433
             for k in vault_service_keys
1433 1434
             if k in settings
1434 1435
         }
... ...
@@ -391,7 +391,7 @@ def _decrypt_master_keys_data(
391 391
         padded_plaintext.extend(decryptor.finalize())
392 392
         unpadder = padding.PKCS7(IV_SIZE * 8).unpadder()
393 393
         plaintext = bytearray()
394
-        plaintext.extend(unpadder.update(padded_plaintext))
394
+        plaintext.extend(unpadder.update(bytes(padded_plaintext)))
395 395
         plaintext.extend(unpadder.finalize())
396 396
         hashing_key, encryption_key, signing_key = struct.unpack(
397 397
             f"{KEY_SIZE}s {KEY_SIZE}s {KEY_SIZE}s", plaintext
... ...
@@ -484,7 +484,7 @@ def _decrypt_session_keys(
484 484
         padded_plaintext.extend(decryptor.finalize())
485 485
         unpadder = padding.PKCS7(IV_SIZE * 8).unpadder()
486 486
         plaintext = bytearray()
487
-        plaintext.extend(unpadder.update(padded_plaintext))
487
+        plaintext.extend(unpadder.update(bytes(padded_plaintext)))
488 488
         plaintext.extend(unpadder.finalize())
489 489
         session_encryption_key, session_signing_key = struct.unpack(
490 490
             f"{KEY_SIZE}s {KEY_SIZE}s", plaintext
... ...
@@ -590,7 +590,7 @@ def _decrypt_contents(
590 590
     padded_plaintext.extend(decryptor.finalize())
591 591
     unpadder = padding.PKCS7(IV_SIZE * 8).unpadder()
592 592
     plaintext = bytearray()
593
-    plaintext.extend(unpadder.update(padded_plaintext))
593
+    plaintext.extend(unpadder.update(bytes(padded_plaintext)))
594 594
     plaintext.extend(unpadder.finalize())
595 595
 
596 596
     logger.debug(
... ...
@@ -429,7 +429,7 @@ class VaultNativeConfigParser(abc.ABC):
429 429
         )
430 430
         unpadder = padding.PKCS7(iv_size * 8).unpadder()
431 431
         plaintext = bytearray()
432
-        plaintext.extend(unpadder.update(padded_plaintext))
432
+        plaintext.extend(unpadder.update(bytes(padded_plaintext)))
433 433
         plaintext.extend(unpadder.finalize())
434 434
         logger.debug(
435 435
             _msg.TranslatedString(
... ...
@@ -258,15 +258,13 @@ except (
258 258
         dwFlagsAndAttributes: DWORD,  # noqa: N803
259 259
         hTemplateFile: HANDLE | None,  # noqa: N803
260 260
     ) -> HANDLE:  # pragma: no cover [external]
261
-        del (
262
-            lpFilename,
263
-            dwDesiredAccess,
264
-            dwShareMode,
265
-            lpSecurityAttributes,
266
-            dwCreationDisposition,
267
-            dwFlagsAndAttributes,
268
-            hTemplateFile,
269
-        )
261
+        del lpFilename
262
+        del dwDesiredAccess
263
+        del dwShareMode
264
+        del lpSecurityAttributes
265
+        del dwCreationDisposition
266
+        del dwFlagsAndAttributes
267
+        del hTemplateFile
270 268
         msg = "This Python version does not support Windows named pipes."
271 269
         raise WindowsNamedPipesNotAvailableError(msg)
272 270
 
... ...
@@ -277,13 +275,11 @@ except (
277 275
         lpNumberOfBytesRead: LPDWORD,  # noqa: N803
278 276
         lpOverlapped: LPOVERLAPPED,  # noqa: N803
279 277
     ) -> BOOL:  # pragma: no cover [external]
280
-        del (
281
-            hFile,
282
-            lpBuffer,
283
-            nNumberOfBytesToRead,
284
-            lpNumberOfBytesRead,
285
-            lpOverlapped,
286
-        )
278
+        del hFile
279
+        del lpBuffer
280
+        del nNumberOfBytesToRead
281
+        del lpNumberOfBytesRead
282
+        del lpOverlapped
287 283
         raise OSError(errno.ENOTSUP, os.strerror(errno.ENOTSUP))
288 284
 
289 285
     def WriteFile(  # noqa: N802
... ...
@@ -293,13 +289,11 @@ except (
293 289
         lpNumberOfBytesWritten: LPDWORD,  # noqa: N803
294 290
         lpOverlapped: LPOVERLAPPED,  # noqa: N803
295 291
     ) -> BOOL:  # pragma: no cover [external]
296
-        del (
297
-            hFile,
298
-            lpBuffer,
299
-            nNumberOfBytesToWrite,
300
-            lpNumberOfBytesWritten,
301
-            lpOverlapped,
302
-        )
292
+        del hFile
293
+        del lpBuffer
294
+        del nNumberOfBytesToWrite
295
+        del lpNumberOfBytesWritten
296
+        del lpOverlapped
303 297
         raise OSError(errno.ENOTSUP, os.strerror(errno.ENOTSUP))
304 298
 
305 299
     def CloseHandle(  # noqa: N802
... ...
@@ -162,7 +162,7 @@ class Vault:
162 162
         self._required: list[bytes] = []
163 163
 
164 164
         def subtract_or_require(
165
-            count: int | None, characters: bytes | bytearray
165
+            count: int | None, characters: bytes
166 166
         ) -> None:
167 167
             if not isinstance(count, int):
168 168
                 return
... ...
@@ -479,15 +479,14 @@ class Vault:
479 479
         """
480 480
         key = bytes(key)
481 481
         TestFunc: TypeAlias = "Callable[[bytes | bytearray], bool]"
482
-        deterministic_signature_types: dict[str, TestFunc]
483
-        deterministic_signature_types = {
482
+        deterministic_signature_types: dict[str, TestFunc] = {
484 483
             "ssh-ed25519": lambda k: k.startswith(
485 484
                 b"\x00\x00\x00\x0bssh-ed25519"
486 485
             ),
487 486
             "ssh-ed448": lambda k: k.startswith(b"\x00\x00\x00\x09ssh-ed448"),
488 487
             "ssh-rsa": lambda k: k.startswith(b"\x00\x00\x00\x07ssh-rsa"),
489 488
         }
490
-        dsa_signature_types = {
489
+        dsa_signature_types: dict[str, TestFunc] = {
491 490
             "ssh-dss": lambda k: k.startswith(b"\x00\x00\x00\x07ssh-dss"),
492 491
             "ecdsa-sha2-nistp256": lambda k: k.startswith(
493 492
                 b"\x00\x00\x00\x13ecdsa-sha2-nistp256"
... ...
@@ -499,7 +498,7 @@ class Vault:
499 498
                 b"\x00\x00\x00\x13ecdsa-sha2-nistp521"
500 499
             ),
501 500
         }
502
-        criteria = [
501
+        criteria: list[Callable[[], bool]] = [
503 502
             lambda: any(
504 503
                 v(key) for v in deterministic_signature_types.values()
505 504
             ),
... ...
@@ -292,7 +292,6 @@ class CliRunner:
292 292
             or "",
293 293
             stderr or "",
294 294
         )
295
-        return ReadableResult.parse(raw_result)
296 295
 
297 296
     def isolated_filesystem(
298 297
         self,
... ...
@@ -24,6 +24,7 @@ import sys
24 24
 from typing import TYPE_CHECKING
25 25
 
26 26
 import hypothesis
27
+import hypothesis.errors
27 28
 from hypothesis import strategies
28 29
 
29 30
 from derivepassphrase import _types
... ...
@@ -19,6 +19,7 @@ from __future__ import annotations
19 19
 
20 20
 import base64
21 21
 import contextlib
22
+import importlib.metadata
22 23
 import importlib.util
23 24
 import json
24 25
 import os
... ...
@@ -128,7 +129,7 @@ def xfail_on_the_annoying_os(
128 129
         empty, will follow.
129 130
 
130 131
     """
131
-    import hypothesis  # noqa: PLC0415
132
+    import hypothesis.errors  # noqa: PLC0415
132 133
 
133 134
     base_reason = "The Annoying OS behaves differently."
134 135
     full_reason = base_reason if not reason else f"{base_reason}  {reason}"
... ...
@@ -154,7 +154,7 @@ def minimize_openssh_keyfile_padding(
154 154
 
155 155
     new_private_block.extend(padding)
156 156
     result.extend(string(new_private_block))
157
-    return result
157
+    return bytes(result)
158 158
 
159 159
 
160 160
 class Parametrize:
... ...
@@ -124,7 +124,7 @@ class Strategies:
124 124
     @strategies.composite
125 125
     @staticmethod
126 126
     def service_name_and_data_strategy(
127
-        draw: hypothesis.strategies.DrawFn,
127
+        draw: strategies.DrawFn,
128 128
         num_entries: int,
129 129
     ) -> tuple[tuple[str, _types.VaultConfigServicesSettings], ...]:
130 130
         """Return a strategy for tuples of service names and settings.
... ...
@@ -366,10 +366,14 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
366 366
         """
367 367
         config = copy.deepcopy(config)
368 368
         setting = copy.deepcopy(setting)
369
-        config_global = config.get("global", {})
369
+        config_global = config.get(
370
+            "global", cast("_types.VaultConfigGlobalSettings", {})
371
+        )
370 372
         maybe_unset = set(maybe_unset) - setting.keys()
371 373
         if overwrite:
372
-            config["global"] = config_global = {}
374
+            config["global"] = config_global = cast(
375
+                "_types.VaultConfigGlobalSettings", {}
376
+            )
373 377
         elif maybe_unset:
374 378
             for key in maybe_unset:
375 379
                 config_global.pop(key, None)  # type: ignore[misc]
... ...
@@ -437,7 +441,7 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
437 441
         self,
438 442
         service: str,
439 443
         config: _types.VaultConfig,
440
-        setting: _types.VaultConfigGlobalSettings,
444
+        setting: _types.VaultConfigServicesSettings,
441 445
         maybe_unset: AbstractSet[str],
442 446
         overwrite: bool,
443 447
     ) -> tuple[_types.VaultConfig, AbstractSet[str]]:
... ...
@@ -471,7 +475,7 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine):
471 475
         config_service = config["services"].get(service, {})
472 476
         maybe_unset = set(maybe_unset) - setting.keys()
473 477
         if overwrite:
474
-            config["services"][service] = config_service = {}
478
+            config["services"][service] = config_service = cast("_types.VaultConfigServicesSettings", {})
475 479
         elif maybe_unset:
476 480
             for key in maybe_unset:
477 481
                 config_service.pop(key, None)  # type: ignore[misc]
... ...
@@ -11,7 +11,7 @@ import json
11 11
 import types
12 12
 from typing import TYPE_CHECKING
13 13
 
14
-import click.testing
14
+import click.shell_completion
15 15
 import pytest
16 16
 from typing_extensions import Any
17 17
 
... ...
@@ -513,11 +513,11 @@ class TestStoreroom:
513 513
         encryptor = ciphers.Cipher(
514 514
             algorithms.AES256(key), modes.CBC(iv)
515 515
         ).encryptor()
516
-        ciphertext = bytearray(encryptor.update(plaintext))
516
+        ciphertext = bytearray(encryptor.update(bytes(plaintext)))
517 517
         ciphertext.extend(encryptor.finalize())
518 518
         mac_obj = hmac.HMAC(key, hashes.SHA256())
519 519
         mac_obj.update(iv)
520
-        mac_obj.update(ciphertext)
520
+        mac_obj.update(bytes(ciphertext))
521 521
         data = iv + bytes(ciphertext) + mac_obj.finalize()
522 522
         with pytest.raises(
523 523
             ValueError,
... ...
@@ -320,9 +320,7 @@ class TestCLIUtilities:
320 320
         """
321 321
         with self._setup_environment() as monkeypatch:
322 322
             if path:
323
-                monkeypatch.setenv(
324
-                    "VAULT_PATH", os.fspath(path) if path is not None else None
325
-                )
323
+                monkeypatch.setenv("VAULT_PATH", os.fspath(path))
326 324
             assert (
327 325
                 exporter.get_vault_path().resolve()
328 326
                 == expected.expanduser().resolve()
... ...
@@ -1322,7 +1322,7 @@ class TestAgentErrorResponses:
1322 1322
         with self._setup_agent_and_request_handler({
1323 1323
             data.AgentProtocolRequest(
1324 1324
                 _types.SSH_AGENTC.REQUEST_IDENTITIES, b""
1325
-            ): [data.AgentProtocolResponse(response_code, response)],
1325
+            ): [data.AgentProtocolResponse(response_code, bytes(response))],
1326 1326
         }) as contexts:
1327 1327
             _, client = contexts
1328 1328
             with pytest.raises(exc_type, match=exc_pattern):
... ...
@@ -1361,7 +1361,7 @@ class TestAgentErrorResponses:
1361 1361
         with self._setup_agent_and_request_handler({
1362 1362
             data.AgentProtocolRequest(
1363 1363
                 _types.SSH_AGENTC.SIGN_REQUEST, sign_payload
1364
-            ): [data.AgentProtocolResponse(response_code, response)],
1364
+            ): [data.AgentProtocolResponse(response_code, bytes(response))],
1365 1365
         }) as contexts:
1366 1366
             monkeypatch, client = contexts
1367 1367
             monkeypatch.setattr(client, "list_keys", lambda: loaded_keys)
... ...
@@ -12,7 +12,7 @@ import gettext
12 12
 import os
13 13
 import re
14 14
 import types
15
-from typing import TYPE_CHECKING, TypeVar, cast
15
+from typing import TYPE_CHECKING, TypeVar
16 16
 
17 17
 import hypothesis
18 18
 import pytest
... ...
@@ -57,16 +57,13 @@ class Strategies:
57 57
         ]
58 58
     ] = {}
59 59
     for enum_class in msg.MSG_TEMPLATE_CLASSES:
60
-        all_translatable_strings_dict.update({
61
-            cast("msg.TranslatableString", v.value): v for v in enum_class
62
-        })
60
+        all_translatable_strings_dict.update({v.value: v for v in enum_class})
63 61
 
64 62
     all_translatable_strings_enum_values = tuple(
65 63
         sorted(all_translatable_strings_dict.values(), key=str)
66 64
     )
67 65
     all_translatable_strings = tuple(
68
-        cast("msg.TranslatableString", v.value)
69
-        for v in all_translatable_strings_enum_values
66
+        v.value for v in all_translatable_strings_enum_values
70 67
     )
71 68
 
72 69
     error_codes = tuple(
... ...
@@ -132,7 +129,7 @@ class Strategies:
132 129
     @classmethod
133 130
     def translatable_strings(
134 131
         cls,
135
-    ) -> strategies.SearchStrategy[msg.MsgTemplate]:
132
+    ) -> strategies.SearchStrategy[msg.TranslatableString]:
136 133
         """Return a strategy for translatable messages."""
137 134
         return strategies.sampled_from(cls.all_translatable_strings)
138 135
 
... ...
@@ -201,9 +198,7 @@ class TestDebugTranslations:
201 198
         value: msg.MsgTemplate,
202 199
     ) -> None:
203 200
         """Translating a MsgTemplate operates on the enum value."""
204
-        self._test_interpolated(
205
-            str(value), cast("msg.TranslatableString", value.value)
206
-        )
201
+        self._test_interpolated(str(value), value.value)
207 202
 
208 203
 
209 204
 @pytest.mark.usefixtures("use_debug_translations")
210 205