diff --git a/CHANGES.md b/CHANGES.md index 6875a50be..536740249 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -29,6 +29,9 @@ Unreleased - An empty `envvar`, such as `[]` or `""`, shows the same help and error message as no `envvar` when `show_envvar=True`. The help screen used to show `[env var: ]`. {pr}`3884` +- {meth}`CliRunner.invoke` accepts a new `context_settings` argument, to allow + passing settings to the {class}`Context` of the command which cannot be given as extra + keyword arguments like `color`. {issue}`2110` {pr}`3896` ## Version 8.5.0 diff --git a/docs/testing.md b/docs/testing.md index f4ee0cbc0..c3cd1850d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -117,10 +117,41 @@ def test_sync(): runner = CliRunner() result = runner.invoke(cli, ['sync'], terminal_width=60) assert result.exit_code == 0 - assert 'Debug mode is on' in result.output assert 'Syncing' in result.output ``` +A setting that shares its name with a parameter of {meth}`CliRunner.invoke` cannot be +passed this way. This is the case of `color`: `invoke(color=True)` tells that the +terminal supports colors, and leaves the `color` of the context unset. Pass such a +setting in `context_settings` instead: + +```{code-block} python +:caption: greet.py + +import click + +@click.command() +def greet(): + click.secho('Hello World!', fg='green') +``` + +```{code-block} python +:caption: test_greet.py + +import click +from click.testing import CliRunner +from greet import greet + +def test_greet_color(): + runner = CliRunner() + result = runner.invoke(greet, context_settings={'color': True}) + assert result.exit_code == 0 + assert result.output == click.style('Hello World!', fg='green') + '\n' +``` + +The settings in `context_settings` take precedence over the ones the command declares, +for that invocation only. + ## File System Isolation To test a command that reads or writes files, give each test its own diff --git a/src/click/testing.py b/src/click/testing.py index cc123d4d1..9a38337ad 100644 --- a/src/click/testing.py +++ b/src/click/testing.py @@ -601,6 +601,7 @@ def invoke( env: cabc.Mapping[str, str | None] | None = None, catch_exceptions: bool | None = None, color: bool = False, + context_settings: cabc.Mapping[str, t.Any] | None = None, **extra: t.Any, ) -> Result: """Invokes a command in an isolated environment. The arguments are @@ -623,6 +624,14 @@ def invoke( :param extra: the keyword arguments to pass to :meth:`main`. :param color: whether the output should contain color codes. The application can still override this explicitly. + :param context_settings: settings to pass to the + :class:`~click.Context` of the command. They take precedence + over the ``context_settings`` of the command. Unlike ``extra``, + they can hold a setting named like a parameter of this method, + such as ``color``. + + .. versionchanged:: 8.5.1 + Added the ``context_settings`` parameter. .. versionadded:: 8.2 The result object has the ``output_bytes`` attribute with @@ -684,7 +693,12 @@ def invoke( prog_name = self.get_default_prog_name(cli) try: - return_value = cli.main(args=args or (), prog_name=prog_name, **extra) + return_value = cli.main( + args=args or (), + prog_name=prog_name, + **extra, + **(context_settings or {}), + ) except SystemExit as e: exc_info = sys.exc_info() e_code = t.cast("int | t.Any | None", e.code) diff --git a/tests/test_termui.py b/tests/test_termui.py index 1825b1987..95dfa0eb1 100644 --- a/tests/test_termui.py +++ b/tests/test_termui.py @@ -1221,13 +1221,24 @@ def test_tempfile_pager_accepts_text(monkeypatch, capfd, page): @pytest.mark.skipif(shutil.which("cat") is None, reason="cat not available") @pytest.mark.parametrize( - ("color", "expected"), + ("color", "context_settings", "expected"), [ - pytest.param(False, "hello\n", id="strip ansi"), - pytest.param(True, click.style("hello", fg="red") + "\n", id="preserve ansi"), + pytest.param(False, None, "hello\n", id="strip ansi"), + pytest.param( + True, None, click.style("hello", fg="red") + "\n", id="preserve ansi" + ), + # Without a ``color`` argument, the pager follows the context. + pytest.param( + None, + {"color": True}, + click.style("hello", fg="red") + "\n", + id="context preserves ansi", + ), ], ) -def test_tempfile_pager_handles_ansi(monkeypatch, capfd, color, expected): +def test_tempfile_pager_handles_ansi( + monkeypatch, capfd, runner, color, context_settings, expected +): """The temp file backend honors ``color`` like the pipe backend does. Regression: a binary temp file got no ANSI-stripping wrapper, so ``color`` @@ -1235,7 +1246,12 @@ def test_tempfile_pager_handles_ansi(monkeypatch, capfd, color, expected): """ _force_tempfile_pager(monkeypatch) - click.echo_via_pager(click.style("hello", fg="red"), color=color) + @click.command() + def cli(): + click.echo_via_pager(click.style("hello", fg="red"), color=color) + + result = runner.invoke(cli, context_settings=context_settings) + assert not result.exception out, err = capfd.readouterr() assert err == "" @@ -1395,40 +1411,25 @@ def cli(password): @pytest.mark.parametrize( - ("call", "user_input", "color", "expect"), + ("call", "user_input", "label", "end"), [ - pytest.param( - lambda: click.confirm(click.style("Hello World!", fg="green")), - "y", - False, - "Hello World! [y/N]: y\n", - id="confirm-no-color", - ), - pytest.param( - lambda: click.confirm(click.style("Hello World!", fg="green")), - "y", - True, - "\x1b[32mHello World!\x1b[0m [y/N]: y\n", - id="confirm-color", - ), - pytest.param( - lambda: click.prompt(click.style("Name", fg="green")), - "Bob", - False, - "Name: Bob\n", - id="prompt-no-color", - ), - pytest.param( - lambda: click.prompt(click.style("Name", fg="green")), - "Bob", - True, - "\x1b[32mName\x1b[0m: Bob\n", - id="prompt-color", - ), + pytest.param(click.confirm, "y", "Hello World!", " [y/N]: y\n", id="confirm"), + pytest.param(click.prompt, "Bob", "Name", ": Bob\n", id="prompt"), + ], +) +@pytest.mark.parametrize( + ("color", "context_settings", "styled"), + [ + # Without a context color, the color of the runner decides. + pytest.param(False, None, False, id="no-color"), + pytest.param(True, None, True, id="color"), + # A context color overrides the color of the runner, as for ``echo``. + pytest.param(False, {"color": True}, True, id="context-color"), + pytest.param(True, {"color": False}, False, id="context-no-color"), ], ) def test_prompt_and_confirm_ansi_respects_color( - runner, call, user_input, color, expect + runner, call, user_input, label, end, color, context_settings, styled ): """``confirm`` and ``prompt`` strip ANSI color and style codes from the prompt when color is disabled and keep them when it is enabled, like @@ -1438,10 +1439,13 @@ def test_prompt_and_confirm_ansi_respects_color( @click.command() def cli(): - call() + call(click.style(label, fg="green")) - result = runner.invoke(cli, input=user_input, color=color) - assert result.output == expect + result = runner.invoke( + cli, input=user_input, color=color, context_settings=context_settings + ) + prompt = click.style(label, fg="green") if styled else label + assert result.output == f"{prompt}{end}" def test_false_show_default_cause_no_default_display_in_prompt(runner): diff --git a/tests/test_testing.py b/tests/test_testing.py index 870d95c97..ce69b695f 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -429,6 +429,92 @@ def cli(): assert result.exit_code == 0 +@pytest.mark.parametrize( + ("runner_color", "context_settings", "context_color", "styled"), + [ + # Without a ``color`` setting, the context has no color of its own, and + # the ``color`` of the runner decides. + (False, None, None, False), + (True, None, None, True), + (False, {}, None, False), + (True, {}, None, True), + (False, {"color": None}, None, False), + (True, {"color": None}, None, True), + # A ``color`` setting reaches the context, where it overrides the + # ``color`` of the runner. + (False, {"color": True}, True, True), + (True, {"color": True}, True, True), + (False, {"color": False}, False, False), + (True, {"color": False}, False, False), + ], +) +def test_context_settings_color(runner_color, context_settings, context_color, styled): + """``color`` names a parameter of ``invoke()`` and a context setting. + ``context_settings`` carries the second one, which ``extra`` cannot. + """ + + @click.command() + @click.pass_context + def cli(ctx): + click.secho("hello world", fg="blue") + return ctx.color + + runner = CliRunner() + result = runner.invoke( + cli, + color=runner_color, + context_settings=context_settings, + standalone_mode=False, + ) + assert not result.exception + assert result.return_value is context_color + expected = click.style("hello world", fg="blue") if styled else "hello world" + assert result.output == f"{expected}\n" + + +@pytest.mark.parametrize("terminal_width", [100, None]) +def test_context_settings_override_command(terminal_width): + """Settings given to ``invoke()`` take precedence over the ones the command + declares, for that invocation only. + """ + + @click.command(context_settings={"terminal_width": 60, "max_content_width": 70}) + @click.pass_context + def cli(ctx): + return ctx.terminal_width, ctx.max_content_width + + runner = CliRunner() + + result = runner.invoke( + cli, context_settings={"terminal_width": terminal_width}, standalone_mode=False + ) + assert result.return_value == (terminal_width, 70) + + result = runner.invoke(cli, standalone_mode=False) + assert result.return_value == (60, 70) + assert cli.context_settings == {"terminal_width": 60, "max_content_width": 70} + + +def test_context_settings_duplicates_extra(): + """A setting given in both ``extra`` and ``context_settings`` fails like a + keyword argument given twice. + """ + + @click.command() + def cli(): + pass + + runner = CliRunner() + + with pytest.raises(TypeError, match="multiple values for keyword argument 'obj'"): + runner.invoke( + cli, + obj="extra", + context_settings={"obj": "settings"}, + catch_exceptions=False, + ) + + def test_file_stdin_attrs(runner): @click.command() @click.argument("f", type=click.File())