Refactor code to run under Python 3.9's LL(1) parser
Marco Ricci

Marco Ricci commited on 2024-12-08 17:44:25
Zeige 5 geänderte Dateien mit 55 Einfügungen und 31 Löschungen.


Though it does not do so by default, Python 3.9 can run with the old
LL(1) parser instead of the PEG-based parser.  Most importantly, the
LL(1) parser does not support parenthesized context manager expressions,
and this is why they have only become officially supported in Python
3.10, once the PEG-based parser became mandatory.  Therefore, for
compatibility, we replace every "proper" parenthesized context manager
expression with equivalent explicit use of a `contextlib.ExitStack`, and
every degenerate such expression by removing the parentheses.

We now also explicitly test `derivepassphrase` with the LL(1) parser,
for as long as Python 3.9 remains supported.

While the use of a `contextlib.ExitStack` comes with additional runtime
cost, by a lucky coincidence, all such proper usage of parenthesized
context manager expressions is confined to the test suite, where we
really don't care too much about slightly higher runtimes if it buys us
more clarity, correctness and compatibility instead.
... ...
@@ -137,6 +137,13 @@ matrix-name-format = '{variable}_{value}'
137 137
 python = ["3.13", "3.12", "3.11", "3.10", "3.9", "pypy3.10", "pypy3.9"]
138 138
 cryptography = ["no", "yes"]
139 139
 hypothesis-profile = ["user-default"]
140
+parser-version = ["PEG"]
141
+
142
+[[tool.hatch.envs.hatch-test.matrix]]
143
+python = ["3.9", "pypy3.9"]
144
+cryptography = ["no", "yes"]
145
+hypothesis-profile = ["user-default"]
146
+parser-version = ["LL1"]
140 147
 
141 148
 [[tool.hatch.envs.hatch-test.matrix]]
142 149
 cryptography = ["yes"]
... ...
@@ -149,6 +156,9 @@ matrix.cryptography.features = [
149 156
 matrix.hypothesis-profile.env-vars = [
150 157
     { key = "HYPOTHESIS_PROFILE", if = ["ci", "default", "dev", "debug"] },
151 158
 ]
159
+matrix.parser-version.env-vars = [
160
+    { key = "PYTHONOLDPARSER", value = "1", if = ["LL1"] },
161
+]
152 162
 
153 163
 [tool.hatch.envs.hatch-test.scripts]
154 164
 run = "pytest --hypothesis-profile={env:HYPOTHESIS_PROFILE:default}{env:HATCH_TEST_ARGS:} {args}"
... ...
@@ -1413,11 +1413,14 @@ def isolated_config(
1413 1413
 ) -> Iterator[None]:
1414 1414
     prog_name = cli.PROG_NAME
1415 1415
     env_name = prog_name.replace(' ', '_').upper() + '_PATH'
1416
-    with (
1417
-        runner.isolated_filesystem(),
1418
-        cli.StandardCLILogging.ensure_standard_logging(),
1419
-        cli.StandardCLILogging.ensure_standard_warnings_logging(),
1420
-    ):
1416
+    # Use parenthesized context manager expressions once Python 3.9
1417
+    # becomes unsupported.
1418
+    with contextlib.ExitStack() as stack:
1419
+        stack.enter_context(runner.isolated_filesystem())
1420
+        stack.enter_context(cli.StandardCLILogging.ensure_standard_logging())
1421
+        stack.enter_context(
1422
+            cli.StandardCLILogging.ensure_standard_warnings_logging()
1423
+        )
1421 1424
         monkeypatch.setenv('HOME', os.getcwd())
1422 1425
         monkeypatch.setenv('USERPROFILE', os.getcwd())
1423 1426
         monkeypatch.delenv(env_name, raising=False)
... ...
@@ -1476,10 +1479,13 @@ def isolated_vault_exporter_config(
1476 1479
                 print(vault_config, file=outfile)
1477 1480
         elif isinstance(vault_config, bytes):
1478 1481
             os.makedirs('.vault', mode=0o700, exist_ok=True)
1479
-            with (
1480
-                chdir('.vault'),
1481
-                tempfile.NamedTemporaryFile(suffix='.zip') as tmpzipfile,
1482
-            ):
1482
+            # Use parenthesized context manager expressions here once
1483
+            # Python 3.9 becomes unsupported.
1484
+            with contextlib.ExitStack() as stack:
1485
+                stack.enter_context(chdir('.vault'))
1486
+                tmpzipfile = stack.enter_context(
1487
+                    tempfile.NamedTemporaryFile(suffix='.zip')
1488
+                )
1483 1489
                 for line in vault_config.splitlines():
1484 1490
                     tmpzipfile.write(base64.standard_b64decode(line))
1485 1491
                 tmpzipfile.flush()
... ...
@@ -1570,12 +1570,17 @@ class TestCLIUtils:
1570 1570
         self, monkeypatch: pytest.MonkeyPatch
1571 1571
     ) -> None:
1572 1572
         runner = click.testing.CliRunner()
1573
-        with (
1573
+        # Use parenthesized context manager expressions here once Python
1574
+        # 3.9 becomes unsupported.
1575
+        with contextlib.ExitStack() as stack:
1576
+            stack.enter_context(
1574 1577
                 tests.isolated_vault_config(
1575 1578
                     monkeypatch=monkeypatch, runner=runner, config={}
1576
-            ),
1577
-            pytest.raises(ValueError, match='Invalid vault config'),
1578
-        ):
1579
+                )
1580
+            )
1581
+            stack.enter_context(
1582
+                pytest.raises(ValueError, match='Invalid vault config')
1583
+            )
1579 1584
             cli._save_config(None)  # type: ignore[arg-type]
1580 1585
 
1581 1586
     def test_111_prompt_for_selection_multiple(self) -> None:
... ...
@@ -5,6 +5,7 @@
5 5
 from __future__ import annotations
6 6
 
7 7
 import base64
8
+import contextlib
8 9
 import json
9 10
 from typing import TYPE_CHECKING
10 11
 
... ...
@@ -265,12 +266,10 @@ class TestStoreroom:
265 266
             'signing_key': bytes(storeroom.KEY_SIZE),
266 267
             'hashing_key': bytes(storeroom.KEY_SIZE),
267 268
         }
268
-        with (
269
-            tests.isolated_vault_exporter_config(
269
+        with tests.isolated_vault_exporter_config(
270 270
             monkeypatch=monkeypatch,
271 271
             runner=runner,
272 272
             vault_config=tests.VAULT_STOREROOM_CONFIG_ZIPPED,
273
-            ),
274 273
         ):
275 274
             with open('.vault/20', 'w', encoding='UTF-8') as outfile:
276 275
                 print(config, file=outfile)
... ...
@@ -292,13 +291,11 @@ class TestStoreroom:
292 291
         err_msg: str,
293 292
     ) -> None:
294 293
         runner = click.testing.CliRunner(mix_stderr=False)
295
-        with (
296
-            tests.isolated_vault_exporter_config(
294
+        with tests.isolated_vault_exporter_config(
297 295
             monkeypatch=monkeypatch,
298 296
             runner=runner,
299 297
             vault_config=tests.VAULT_STOREROOM_CONFIG_ZIPPED,
300 298
             vault_key=tests.VAULT_MASTER_KEY,
301
-            ),
302 299
         ):
303 300
             with open('.vault/.keys', 'w', encoding='UTF-8') as outfile:
304 301
                 print(data, file=outfile)
... ...
@@ -337,15 +334,18 @@ class TestStoreroom:
337 334
         error_text: str,
338 335
     ) -> None:
339 336
         runner = click.testing.CliRunner(mix_stderr=False)
340
-        with (
337
+        # Use parenthesized context manager expressions once Python 3.9
338
+        # becomes unsupported.
339
+        with contextlib.ExitStack() as stack:
340
+            stack.enter_context(
341 341
                 tests.isolated_vault_exporter_config(
342 342
                     monkeypatch=monkeypatch,
343 343
                     runner=runner,
344 344
                     vault_config=zipped_config,
345 345
                     vault_key=tests.VAULT_MASTER_KEY,
346
-            ),
347
-            pytest.raises(RuntimeError, match=error_text),
348
-        ):
346
+                )
347
+            )
348
+            stack.enter_context(pytest.raises(RuntimeError, match=error_text))
349 349
             storeroom.export_storeroom_data()
350 350
 
351 351
     def test_404_decrypt_keys_wrong_data_length(self) -> None:
... ...
@@ -7,6 +7,7 @@
7 7
 from __future__ import annotations
8 8
 
9 9
 import base64
10
+import contextlib
10 11
 import io
11 12
 import socket
12 13
 from typing import TYPE_CHECKING
... ...
@@ -589,10 +590,11 @@ class TestAgentInteraction:
589 590
     ) -> None:
590 591
         del running_ssh_agent
591 592
 
592
-        with (
593
-            pytest.raises(exc_type, match=exc_pattern),
594
-            ssh_agent.SSHAgentClient() as client,
595
-        ):
593
+        # Use parenthesized context manager expressions once Python 3.9
594
+        # becomes unsupported.
595
+        with contextlib.ExitStack() as stack:
596
+            stack.enter_context(pytest.raises(exc_type, match=exc_pattern))
597
+            client = stack.enter_context(ssh_agent.SSHAgentClient())
596 598
             client.request(request_code, b'', response_code=response_code)
597 599
 
598 600
     @pytest.mark.parametrize(
... ...
@@ -650,10 +652,11 @@ class TestAgentInteraction:
650 652
                 assert single_code in response_codes
651 653
             return response_data  # pragma: no cover
652 654
 
653
-        with (
654
-            monkeypatch.context() as monkeypatch2,
655
-            ssh_agent.SSHAgentClient() as client,
656
-        ):
655
+        # Use parenthesized context manager expressions once Python 3.9
656
+        # becomes unsupported.
657
+        with contextlib.ExitStack() as stack:
658
+            monkeypatch2 = stack.enter_context(monkeypatch.context())
659
+            client = stack.enter_context(ssh_agent.SSHAgentClient())
657 660
             monkeypatch2.setattr(client, 'request', request)
658 661
             with pytest.raises(
659 662
                 RuntimeError, match='Malformed response|does not match request'
660 663