Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 32 additions & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion src/click/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
80 changes: 42 additions & 38 deletions tests/test_termui.py
Original file line number Diff line number Diff line change
Expand Up @@ -1221,21 +1221,37 @@ 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``
was silently ignored on this path.
"""
_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 == ""
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down
86 changes: 86 additions & 0 deletions tests/test_testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Loading