Mitigate testing discrepancies from click 8.2.0 and below
Marco Ricci

Marco Ricci commited on 2025-05-29 23:02:50
Zeige 2 geänderte Dateien mit 95 Einfügungen und 4 Löschungen.


A discrepancy exists between the documentation of `click.prompt` and the
actual behavior of `click.prompt` when mocked with
`click.testing.CliRunner` on click 8.2.0 and below
([`pallets/click#2934`][BUG2934]): when prompting for input and if at
end-of-file, the `CliRunner` may return an empty string instead of
raising `click.Abort`. This usually translates to extra line breaks in
the "mixed" and "echoed" runner output at the last prompt(s).

We mitigate this discrepancy from both sides. On the code side, we wrap
each call to `click.prompt` to treat aborts and empty responses the
same, which is appropriate behavior for the types of prompts we issue.
On the test side, we amend our existing tests to use empty-line input
instead of no input, and explicitly test the "no input" scenario
separately, accepting both the 8.2.0-or-lower or the 8.2.1-or-higher
output. Because the behavior depends on the `click` version, which is
beyond our control, we also adjust coverage measurement.

[BUG2934]: https://github.com/pallets/click/issues/2934
... ...
@@ -657,6 +657,7 @@ def prompt_for_selection(
657 657
         click.echo(x, err=True, color=color)
658 658
     if n > 1:
659 659
         choices = click.Choice([''] + [str(i) for i in range(1, n + 1)])
660
+        try:
660 661
             choice = click.prompt(
661 662
                 f'Your selection? (1-{n}, leave empty to abort)',
662 663
                 err=True,
... ...
@@ -665,6 +666,15 @@ def prompt_for_selection(
665 666
                 show_default=False,
666 667
                 default='',
667 668
             )
669
+        except click.Abort:  # pragma: no cover
670
+            # This branch will not be triggered during testing on
671
+            # `click` versions < 8.2.1, due to (non-monkeypatch-able)
672
+            # deficiencies in `click.testing.CliRunner`. Therefore, as
673
+            # an external source of nondeterminism, exclude it from
674
+            # coverage.
675
+            #
676
+            # https://github.com/pallets/click/issues/2934
677
+            choice = ''
668 678
         if not choice:
669 679
             raise IndexError(EMPTY_SELECTION)
670 680
         return int(choice) - 1
... ...
@@ -763,6 +773,7 @@ def prompt_for_passphrase() -> str:
763 773
         The user input.
764 774
 
765 775
     """
776
+    try:
766 777
         return cast(
767 778
             'str',
768 779
             click.prompt(
... ...
@@ -773,6 +784,14 @@ def prompt_for_passphrase() -> str:
773 784
                 err=True,
774 785
             ),
775 786
         )
787
+    except click.Abort:  # pragma: no cover
788
+        # This branch will not be triggered during testing on `click`
789
+        # versions < 8.2.1, due to (non-monkeypatch-able) deficiencies
790
+        # in `click.testing.CliRunner`. Therefore, as an external source
791
+        # of nondeterminism, exclude it from coverage.
792
+        #
793
+        # https://github.com/pallets/click/issues/2934
794
+        return ''
776 795
 
777 796
 
778 797
 def toml_key(*parts: str) -> str:
... ...
@@ -666,16 +666,28 @@ class Parametrize(types.SimpleNamespace):
666 666
             ),
667 667
             pytest.param(
668 668
                 ['--phrase', '--', 'sv'],
669
-                '',
669
+                '\n',
670 670
                 'No passphrase was given',
671 671
                 id='phrase-sv',
672 672
             ),
673 673
             pytest.param(
674
-                ['--key'],
674
+                ['--phrase', '--', 'sv'],
675 675
                 '',
676
+                'No passphrase was given',
677
+                id='phrase-sv-eof',
678
+            ),
679
+            pytest.param(
680
+                ['--key'],
681
+                '\n',
676 682
                 'No SSH key was selected',
677 683
                 id='key-sv',
678 684
             ),
685
+            pytest.param(
686
+                ['--key'],
687
+                '',
688
+                'No SSH key was selected',
689
+                id='key-sv-eof',
690
+            ),
679 691
         ],
680 692
     )
681 693
     CONFIG_EDITING_VIA_CONFIG_FLAG = pytest.mark.parametrize(
... ...
@@ -4406,7 +4418,7 @@ A fine choice: Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam
4406 4418
 """
4407 4419
         ), 'expected clean exit'
4408 4420
         result = runner.invoke(
4409
-            driver, ['--heading='], input='', catch_exceptions=True
4421
+            driver, ['--heading='], input='\n', catch_exceptions=True
4410 4422
         )
4411 4423
         assert result.error_exit(error=IndexError), (
4412 4424
             'expected error exit and known error type'
... ...
@@ -4427,6 +4439,43 @@ A fine choice: Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam
4427 4439
 Your selection? (1-10, leave empty to abort):\x20
4428 4440
 """
4429 4441
         ), 'expected known output'
4442
+        # click.testing.CliRunner on click < 8.2.1 incorrectly mocks the
4443
+        # click prompting machinery, meaning that the mixed output will
4444
+        # incorrectly contain a line break, contrary to what the
4445
+        # documentation for click.prompt prescribes.
4446
+        result = runner.invoke(
4447
+            driver, ['--heading='], input='', catch_exceptions=True
4448
+        )
4449
+        assert result.error_exit(error=IndexError), (
4450
+            'expected error exit and known error type'
4451
+        )
4452
+        assert result.stdout in {
4453
+            """\
4454
+[1] Egg and bacon
4455
+[2] Egg, sausage and bacon
4456
+[3] Egg and spam
4457
+[4] Egg, bacon and spam
4458
+[5] Egg, bacon, sausage and spam
4459
+[6] Spam, bacon, sausage and spam
4460
+[7] Spam, egg, spam, spam, bacon and spam
4461
+[8] Spam, spam, spam, egg and spam
4462
+[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
4463
+[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
4464
+Your selection? (1-10, leave empty to abort):\x20
4465
+""",
4466
+            """\
4467
+[1] Egg and bacon
4468
+[2] Egg, sausage and bacon
4469
+[3] Egg and spam
4470
+[4] Egg, bacon and spam
4471
+[5] Egg, bacon, sausage and spam
4472
+[6] Spam, bacon, sausage and spam
4473
+[7] Spam, egg, spam, spam, bacon and spam
4474
+[8] Spam, spam, spam, egg and spam
4475
+[9] Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
4476
+[10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
4477
+Your selection? (1-10, leave empty to abort): """,
4478
+        }, 'expected known output'
4430 4479
 
4431 4480
     def test_112_prompt_for_selection_single(self) -> None:
4432 4481
         """[`cli_helpers.prompt_for_selection`][] works in the "single" case."""
... ...
@@ -4459,7 +4508,7 @@ Great!
4459 4508
         result = runner.invoke(
4460 4509
             driver,
4461 4510
             ['Will replace with spam, okay? (Please say "y" or "n".)'],
4462
-            input='',
4511
+            input='\n',
4463 4512
         )
4464 4513
         assert result.error_exit(error=IndexError), (
4465 4514
             'expected error exit and known error type'
... ...
@@ -4472,6 +4521,29 @@ Will replace with spam, okay? (Please say "y" or "n".):\x20
4472 4521
 Boo.
4473 4522
 """
4474 4523
         ), 'expected known output'
4524
+        # click.testing.CliRunner on click < 8.2.1 incorrectly mocks the
4525
+        # click prompting machinery, meaning that the mixed output will
4526
+        # incorrectly contain a line break, contrary to what the
4527
+        # documentation for click.prompt prescribes.
4528
+        result = runner.invoke(
4529
+            driver,
4530
+            ['Will replace with spam, okay? (Please say "y" or "n".)'],
4531
+            input='',
4532
+        )
4533
+        assert result.error_exit(error=IndexError), (
4534
+            'expected error exit and known error type'
4535
+        )
4536
+        assert result.stdout in {
4537
+            """\
4538
+[1] baked beans
4539
+Will replace with spam, okay? (Please say "y" or "n".):\x20
4540
+Boo.
4541
+""",
4542
+            """\
4543
+[1] baked beans
4544
+Will replace with spam, okay? (Please say "y" or "n".): Boo.
4545
+""",
4546
+        }, 'expected known output'
4475 4547
 
4476 4548
     def test_113_prompt_for_passphrase(
4477 4549
         self,
4478 4550