pytest-devtools 1.2.0__tar.gz → 1.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pytest-devtools
3
- Version: 1.2.0
4
- Summary: Pytest plugin providing debug fixtures, ANSI-stripped capsys, whitespace-visible assertions, and terminal column management.
3
+ Version: 1.3.0
4
+ Summary: Pytest plugin providing debug fixtures, ANSI-stripped capsys, whitespace-visible assertions, terminal column management, and click/typer CLI runner fixtures.
5
5
  Author: Nathaniel Landau
6
6
  Author-email: Nathaniel Landau <github@natelandau.com>
7
7
  License: MIT License
@@ -23,7 +23,11 @@ Classifier: Programming Language :: Python :: 3.14
23
23
  Classifier: Topic :: Software Development :: Testing
24
24
  Requires-Dist: pytest>=7
25
25
  Requires-Dist: rich>=15.0.0
26
+ Requires-Dist: click>=8.2 ; extra == 'click'
27
+ Requires-Dist: typer>=0.25 ; extra == 'typer'
26
28
  Requires-Python: >=3.10
29
+ Provides-Extra: click
30
+ Provides-Extra: typer
27
31
  Description-Content-Type: text/markdown
28
32
 
29
33
  # pytest-devtools
@@ -39,6 +43,7 @@ A pytest plugin that smooths over a few common annoyances when writing and debug
39
43
  - **Stripped `capsys` output**: removes ANSI escape codes (and optionally the `tmp_path` prefix) from captured stdout/stderr so assertions stay readable.
40
44
  - **Visible whitespace in diffs**: replaces tabs, trailing spaces, carriage returns, and newlines with Unicode symbols when an assertion fails.
41
45
  - **Terminal column width control**: sets `COLUMNS` for every test so libraries that auto-wrap (Rich, Click, etc.) produce stable output.
46
+ - **CLI runner output post-processing**: `cli_runner` and `typer_runner` fixtures strip ANSI codes, `tmp_path` prefixes, and trailing whitespace from `click`/`typer` `CliRunner` results, the same way `capsys` does for regular captured output.
42
47
 
43
48
  ## Installation
44
49
 
@@ -236,6 +241,116 @@ strip_ansi = true # default: true
236
241
  capsys_strip_tmp_path = false # default: false
237
242
  ```
238
243
 
244
+ ## CLI Runner Output
245
+
246
+ `click.testing.CliRunner` and `typer.testing.CliRunner` replace `sys.stdout` and `sys.stderr` with their own in-memory buffers while a command runs. Nothing a CLI writes inside `invoke()` reaches pytest's capture machinery, so the `capsys` override above cannot see it and `result.output` arrives unprocessed.
247
+
248
+ The `cli_runner` and `typer_runner` fixtures are drop-in replacements that post-process the runner's own `Result`.
249
+
250
+ ```bash
251
+ # uv
252
+ uv add --dev "pytest-devtools[click]"
253
+ uv add --dev "pytest-devtools[typer]"
254
+
255
+ # pip
256
+ pip install "pytest-devtools[click]"
257
+ pip install "pytest-devtools[typer]"
258
+ ```
259
+
260
+ Neither extra is strictly required. A project testing a CLI already depends on click or typer directly, and the fixtures activate whenever the library is importable. The extras exist to record the supported versions:
261
+
262
+ | Fixture | Requires |
263
+ | -------------- | ------------- |
264
+ | `cli_runner` | `click>=8.2` |
265
+ | `typer_runner` | `typer>=0.25` |
266
+
267
+ Those floors are where the runner exposes stdout and stderr as separate strings. Below them, `result.stderr` raises `ValueError: stderr not separately captured` and `result.output` silently returns the two streams mixed together. Both fixtures check the installed version at setup and fail with an explicit message rather than letting that surface later.
268
+
269
+ ### click
270
+
271
+ ```python
272
+ from myapp.cli import main
273
+
274
+
275
+ def test_greeting(cli_runner):
276
+ result = cli_runner.invoke(main, ["--name", "world"])
277
+
278
+ assert result.exit_code == 0
279
+ assert result.output == "Hello world\n"
280
+ ```
281
+
282
+ ### typer
283
+
284
+ ```python
285
+ from myapp.cli import app
286
+
287
+
288
+ def test_greeting(typer_runner):
289
+ result = typer_runner.invoke(app, ["--name", "world"])
290
+
291
+ assert result.exit_code == 0
292
+ assert result.output == "Hello world\n"
293
+ ```
294
+
295
+ Both fixtures are real subclasses of their framework's runner, so `isolated_filesystem()` and every other runner method work as usual. Only `invoke()` changes.
296
+
297
+ When a transform is active, `invoke()` returns a transparent proxy over the framework's `Result`: `isinstance(result, click.testing.Result)` and a `-> Result` annotation both hold, but `type(result)` reports the proxy, not the framework's class.
298
+
299
+ ### ANSI Escape Stripping
300
+
301
+ On by default, and controlled by the same settings as `capsys`: `--no-strip-ansi`, `strip_ansi = false`, and `@pytest.mark.keep_ansi` for a single test.
302
+
303
+ This matters most for CLIs that print through Rich. Click strips its own `echo()` styling inside the runner already, but a `Console(force_terminal=True)` writes escape codes straight past that.
304
+
305
+ ### `tmp_path` Stripping
306
+
307
+ Off by default. Enable with `--cli-runner-strip-tmp-path` or `cli_runner_strip_tmp_path = true` to collapse paths under `tmp_path` to their relative portion:
308
+
309
+ ```python
310
+ def test_writes_file(cli_runner, tmp_path):
311
+ result = cli_runner.invoke(main, ["--out", str(tmp_path / "sub" / "report.txt")])
312
+
313
+ assert "wrote sub/report.txt" in result.output
314
+ ```
315
+
316
+ ### Trailing Whitespace Stripping
317
+
318
+ Off by default. Enable with `--cli-runner-strip-trailing-whitespace` or `cli_runner_strip_trailing_whitespace = true`.
319
+
320
+ Typer renders help through a Rich panel, which pads every line out to the full terminal width. At a wide `--columns` setting that means lines of mostly blanks, which makes asserting on `--help` output impractical:
321
+
322
+ ```python
323
+ def test_help(typer_runner):
324
+ result = typer_runner.invoke(app, ["--help"])
325
+
326
+ assert "Usage" in result.output
327
+ assert not any(line.endswith(" ") for line in result.output.split("\n"))
328
+ ```
329
+
330
+ ### Terminal Width
331
+
332
+ Click's help formatter caps at 80 columns and ignores the `COLUMNS` environment variable, so the `--columns` setting alone never reached help text. When the column width feature is enabled, both fixtures pass it to `invoke()` as the `terminal_width` context setting. An explicit `terminal_width` argument always wins:
333
+
334
+ ```python
335
+ def test_narrow_help(cli_runner):
336
+ result = cli_runner.invoke(main, ["--help"], terminal_width=40)
337
+ ```
338
+
339
+ ### Runners You Construct Yourself
340
+
341
+ If runners come from your own conftest fixture and you would rather not switch to `cli_runner`, opt into patching instead. With `cli_runner_patch_result = true` or `--cli-runner-patch-result`, the same post-processing applies to every `Result` for the duration of each test, whoever built the runner:
342
+
343
+ ```toml
344
+ [tool.pytest.ini_options]
345
+ cli_runner_patch_result = true
346
+ ```
347
+
348
+ Three caveats:
349
+
350
+ - Terminal width injection does not reach externally built runners. Only the `cli_runner` and `typer_runner` fixtures' own `invoke()` passes `terminal_width` through; a runner you construct yourself keeps whatever width it would normally use.
351
+ - Enabling patch mode changes what `cli_runner.invoke()` and `typer_runner.invoke()` return too: once the `Result` classes are patched, the fixtures' own `invoke()` skips its wrapping and returns the framework's `Result` directly instead of the proxy.
352
+ - The patch is undone by `monkeypatch`, whose finalizer runs after every function-scoped fixture's teardown but before any session- or module-scoped one. A session- or module-scoped fixture that reads a `Result` during its own teardown therefore sees the raw, unprocessed output.
353
+
239
354
  ## Visible Whitespace in Assertions
240
355
 
241
356
  When two strings differ only in whitespace, pytest's default diff is hard to read. This plugin replaces invisible characters with visible Unicode symbols in the assertion failure message.
@@ -317,6 +432,10 @@ Every feature is configurable through CLI flags and `pyproject.toml` INI options
317
432
  | `tmp_path` in `capsys` | Off | `--capsys-strip-tmp-path` or `capsys_strip_tmp_path = true` |
318
433
  | Visible whitespace | On | `--no-show-whitespace` or `show_whitespace = false` |
319
434
  | Column width | Off | `--columns=N` or `set_columns = true` |
435
+ | CLI runner ANSI | On | Shares `--no-strip-ansi` / `strip_ansi = false` |
436
+ | `tmp_path` in runner | Off | `--cli-runner-strip-tmp-path` or `cli_runner_strip_tmp_path = true` |
437
+ | Runner trailing space | Off | `--cli-runner-strip-trailing-whitespace` or `cli_runner_strip_trailing_whitespace = true` |
438
+ | Patch external runners | Off | `--cli-runner-patch-result` or `cli_runner_patch_result = true` |
320
439
 
321
440
  ## AI Policy
322
441
 
@@ -11,6 +11,7 @@ A pytest plugin that smooths over a few common annoyances when writing and debug
11
11
  - **Stripped `capsys` output**: removes ANSI escape codes (and optionally the `tmp_path` prefix) from captured stdout/stderr so assertions stay readable.
12
12
  - **Visible whitespace in diffs**: replaces tabs, trailing spaces, carriage returns, and newlines with Unicode symbols when an assertion fails.
13
13
  - **Terminal column width control**: sets `COLUMNS` for every test so libraries that auto-wrap (Rich, Click, etc.) produce stable output.
14
+ - **CLI runner output post-processing**: `cli_runner` and `typer_runner` fixtures strip ANSI codes, `tmp_path` prefixes, and trailing whitespace from `click`/`typer` `CliRunner` results, the same way `capsys` does for regular captured output.
14
15
 
15
16
  ## Installation
16
17
 
@@ -208,6 +209,116 @@ strip_ansi = true # default: true
208
209
  capsys_strip_tmp_path = false # default: false
209
210
  ```
210
211
 
212
+ ## CLI Runner Output
213
+
214
+ `click.testing.CliRunner` and `typer.testing.CliRunner` replace `sys.stdout` and `sys.stderr` with their own in-memory buffers while a command runs. Nothing a CLI writes inside `invoke()` reaches pytest's capture machinery, so the `capsys` override above cannot see it and `result.output` arrives unprocessed.
215
+
216
+ The `cli_runner` and `typer_runner` fixtures are drop-in replacements that post-process the runner's own `Result`.
217
+
218
+ ```bash
219
+ # uv
220
+ uv add --dev "pytest-devtools[click]"
221
+ uv add --dev "pytest-devtools[typer]"
222
+
223
+ # pip
224
+ pip install "pytest-devtools[click]"
225
+ pip install "pytest-devtools[typer]"
226
+ ```
227
+
228
+ Neither extra is strictly required. A project testing a CLI already depends on click or typer directly, and the fixtures activate whenever the library is importable. The extras exist to record the supported versions:
229
+
230
+ | Fixture | Requires |
231
+ | -------------- | ------------- |
232
+ | `cli_runner` | `click>=8.2` |
233
+ | `typer_runner` | `typer>=0.25` |
234
+
235
+ Those floors are where the runner exposes stdout and stderr as separate strings. Below them, `result.stderr` raises `ValueError: stderr not separately captured` and `result.output` silently returns the two streams mixed together. Both fixtures check the installed version at setup and fail with an explicit message rather than letting that surface later.
236
+
237
+ ### click
238
+
239
+ ```python
240
+ from myapp.cli import main
241
+
242
+
243
+ def test_greeting(cli_runner):
244
+ result = cli_runner.invoke(main, ["--name", "world"])
245
+
246
+ assert result.exit_code == 0
247
+ assert result.output == "Hello world\n"
248
+ ```
249
+
250
+ ### typer
251
+
252
+ ```python
253
+ from myapp.cli import app
254
+
255
+
256
+ def test_greeting(typer_runner):
257
+ result = typer_runner.invoke(app, ["--name", "world"])
258
+
259
+ assert result.exit_code == 0
260
+ assert result.output == "Hello world\n"
261
+ ```
262
+
263
+ Both fixtures are real subclasses of their framework's runner, so `isolated_filesystem()` and every other runner method work as usual. Only `invoke()` changes.
264
+
265
+ When a transform is active, `invoke()` returns a transparent proxy over the framework's `Result`: `isinstance(result, click.testing.Result)` and a `-> Result` annotation both hold, but `type(result)` reports the proxy, not the framework's class.
266
+
267
+ ### ANSI Escape Stripping
268
+
269
+ On by default, and controlled by the same settings as `capsys`: `--no-strip-ansi`, `strip_ansi = false`, and `@pytest.mark.keep_ansi` for a single test.
270
+
271
+ This matters most for CLIs that print through Rich. Click strips its own `echo()` styling inside the runner already, but a `Console(force_terminal=True)` writes escape codes straight past that.
272
+
273
+ ### `tmp_path` Stripping
274
+
275
+ Off by default. Enable with `--cli-runner-strip-tmp-path` or `cli_runner_strip_tmp_path = true` to collapse paths under `tmp_path` to their relative portion:
276
+
277
+ ```python
278
+ def test_writes_file(cli_runner, tmp_path):
279
+ result = cli_runner.invoke(main, ["--out", str(tmp_path / "sub" / "report.txt")])
280
+
281
+ assert "wrote sub/report.txt" in result.output
282
+ ```
283
+
284
+ ### Trailing Whitespace Stripping
285
+
286
+ Off by default. Enable with `--cli-runner-strip-trailing-whitespace` or `cli_runner_strip_trailing_whitespace = true`.
287
+
288
+ Typer renders help through a Rich panel, which pads every line out to the full terminal width. At a wide `--columns` setting that means lines of mostly blanks, which makes asserting on `--help` output impractical:
289
+
290
+ ```python
291
+ def test_help(typer_runner):
292
+ result = typer_runner.invoke(app, ["--help"])
293
+
294
+ assert "Usage" in result.output
295
+ assert not any(line.endswith(" ") for line in result.output.split("\n"))
296
+ ```
297
+
298
+ ### Terminal Width
299
+
300
+ Click's help formatter caps at 80 columns and ignores the `COLUMNS` environment variable, so the `--columns` setting alone never reached help text. When the column width feature is enabled, both fixtures pass it to `invoke()` as the `terminal_width` context setting. An explicit `terminal_width` argument always wins:
301
+
302
+ ```python
303
+ def test_narrow_help(cli_runner):
304
+ result = cli_runner.invoke(main, ["--help"], terminal_width=40)
305
+ ```
306
+
307
+ ### Runners You Construct Yourself
308
+
309
+ If runners come from your own conftest fixture and you would rather not switch to `cli_runner`, opt into patching instead. With `cli_runner_patch_result = true` or `--cli-runner-patch-result`, the same post-processing applies to every `Result` for the duration of each test, whoever built the runner:
310
+
311
+ ```toml
312
+ [tool.pytest.ini_options]
313
+ cli_runner_patch_result = true
314
+ ```
315
+
316
+ Three caveats:
317
+
318
+ - Terminal width injection does not reach externally built runners. Only the `cli_runner` and `typer_runner` fixtures' own `invoke()` passes `terminal_width` through; a runner you construct yourself keeps whatever width it would normally use.
319
+ - Enabling patch mode changes what `cli_runner.invoke()` and `typer_runner.invoke()` return too: once the `Result` classes are patched, the fixtures' own `invoke()` skips its wrapping and returns the framework's `Result` directly instead of the proxy.
320
+ - The patch is undone by `monkeypatch`, whose finalizer runs after every function-scoped fixture's teardown but before any session- or module-scoped one. A session- or module-scoped fixture that reads a `Result` during its own teardown therefore sees the raw, unprocessed output.
321
+
211
322
  ## Visible Whitespace in Assertions
212
323
 
213
324
  When two strings differ only in whitespace, pytest's default diff is hard to read. This plugin replaces invisible characters with visible Unicode symbols in the assertion failure message.
@@ -289,6 +400,10 @@ Every feature is configurable through CLI flags and `pyproject.toml` INI options
289
400
  | `tmp_path` in `capsys` | Off | `--capsys-strip-tmp-path` or `capsys_strip_tmp_path = true` |
290
401
  | Visible whitespace | On | `--no-show-whitespace` or `show_whitespace = false` |
291
402
  | Column width | Off | `--columns=N` or `set_columns = true` |
403
+ | CLI runner ANSI | On | Shares `--no-strip-ansi` / `strip_ansi = false` |
404
+ | `tmp_path` in runner | Off | `--cli-runner-strip-tmp-path` or `cli_runner_strip_tmp_path = true` |
405
+ | Runner trailing space | Off | `--cli-runner-strip-trailing-whitespace` or `cli_runner_strip_trailing_whitespace = true` |
406
+ | Patch external runners | Off | `--cli-runner-patch-result` or `cli_runner_patch_result = true` |
292
407
 
293
408
  ## AI Policy
294
409
 
@@ -11,16 +11,20 @@
11
11
  "Topic :: Software Development :: Testing",
12
12
  ]
13
13
  dependencies = ["pytest>=7", "rich>=15.0.0"]
14
- description = "Pytest plugin providing debug fixtures, ANSI-stripped capsys, whitespace-visible assertions, and terminal column management."
14
+ description = "Pytest plugin providing debug fixtures, ANSI-stripped capsys, whitespace-visible assertions, terminal column management, and click/typer CLI runner fixtures."
15
15
  license = { file = "LICENSE" }
16
16
  name = "pytest-devtools"
17
17
  readme = "README.md"
18
18
  requires-python = ">=3.10"
19
- version = "1.2.0"
19
+ version = "1.3.0"
20
20
 
21
21
  [project.entry-points.pytest11]
22
22
  pytest-devtools = "devtools.plugin"
23
23
 
24
+ [project.optional-dependencies]
25
+ click = ["click>=8.2"]
26
+ typer = ["typer>=0.25"]
27
+
24
28
  [build-system]
25
29
  build-backend = "uv_build"
26
30
  requires = ["uv_build>=0.11.8,<0.12.0"]
@@ -30,19 +34,21 @@
30
34
 
31
35
  [dependency-groups]
32
36
  dev = [
33
- "commitizen>=4.15.0",
34
- "coverage>=7.13.5",
37
+ "click>=8.2",
38
+ "commitizen>=4.16.3",
39
+ "coverage>=7.14.1",
35
40
  "duty>=1.9.0",
36
- "prek>=0.3.11",
41
+ "prek>=0.4.4",
37
42
  "pytest-clarity>=1.0.1",
38
43
  "pytest-cov>=7.1.0",
39
44
  "pytest-mock>=3.15.1",
40
45
  "pytest-repeat>=0.9.4",
41
- "ruff>=0.15.12",
42
- "ty>=0.0.34",
43
- "typos>=1.46.0",
46
+ "ruff>=0.15.16",
47
+ "ty>=0.0.44",
48
+ "typer>=0.25",
49
+ "typos>=1.47.2",
44
50
  "yamllint>=1.38.0",
45
- ]
51
+ ]
46
52
 
47
53
  [tool.commitizen]
48
54
  bump_message = "bump(release): v$current_version → v$new_version"
@@ -36,3 +36,24 @@ def resolve_option(
36
36
  return cli_value
37
37
 
38
38
  return request.config.getini(name)
39
+
40
+
41
+ def should_strip_ansi(request: pytest.FixtureRequest) -> bool:
42
+ """Determine whether ANSI stripping applies to this test.
43
+
44
+ Shared by the capsys override and the CLI runner fixtures so a single set of
45
+ controls governs both.
46
+
47
+ Args:
48
+ request: The pytest fixture request object.
49
+
50
+ Returns:
51
+ True if ANSI codes should be stripped, False otherwise.
52
+ """
53
+ if request.config.getoption("no_strip_ansi", default=False):
54
+ return False
55
+
56
+ if not request.config.getini("strip_ansi"):
57
+ return False
58
+
59
+ return not request.node.get_closest_marker("keep_ansi")
@@ -0,0 +1,66 @@
1
+ """Pure text transforms shared by the capsys override and the CLI runner fixtures.
2
+
3
+ Keep this module free of pytest imports so the transforms stay directly testable
4
+ and reusable from any context.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from pathlib import Path
14
+
15
+ ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
16
+
17
+
18
+ def strip_ansi(text: str) -> str:
19
+ """Remove ANSI escape sequences from a string.
20
+
21
+ Args:
22
+ text: The string potentially containing ANSI escape sequences.
23
+
24
+ Returns:
25
+ The string with all ANSI SGR sequences removed.
26
+ """
27
+ return ANSI_PATTERN.sub("", text)
28
+
29
+
30
+ def strip_tmp_path(text: str, tmp_path: Path) -> str:
31
+ r"""Remove tmp_path prefix occurrences from a string.
32
+
33
+ Strip the prefix together with its trailing separator, then any bare
34
+ remainder, so that paths nested under ``tmp_path`` collapse to their
35
+ relative portion. Both separators are tried because a Windows
36
+ ``str(tmp_path)`` uses ``\`` while the bare prefix alone would leave a
37
+ leading separator behind.
38
+
39
+ Args:
40
+ text: The captured output string.
41
+ tmp_path: The pytest ``tmp_path`` fixture value to strip.
42
+
43
+ Returns:
44
+ The string with tmp_path prefixes removed.
45
+ """
46
+ tmp_str = str(tmp_path)
47
+ for separator in ("/", "\\"):
48
+ text = text.replace(f"{tmp_str}{separator}", "")
49
+ return text.replace(tmp_str, "")
50
+
51
+
52
+ def strip_trailing_whitespace(text: str) -> str:
53
+ r"""Remove trailing whitespace from every line of a string.
54
+
55
+ Rich renders panels and tables by padding every line out to the full
56
+ terminal width, which makes exact-match assertions against CLI help output
57
+ impractical. Splitting on ``"\n"`` rather than using ``splitlines()``
58
+ preserves the presence or absence of a trailing newline.
59
+
60
+ Args:
61
+ text: The string to right-trim line by line.
62
+
63
+ Returns:
64
+ The string with trailing whitespace removed from each line.
65
+ """
66
+ return "\n".join(line.rstrip() for line in text.split("\n"))
@@ -8,51 +8,20 @@ output simpler and more reliable.
8
8
  from __future__ import annotations
9
9
 
10
10
  import contextlib
11
- import re
12
11
  from typing import TYPE_CHECKING, Any
13
12
 
14
13
  import pytest
15
14
  from _pytest.capture import CaptureFixture, CaptureResult
16
15
 
17
- from devtools._options import resolve_option
16
+ from devtools._options import resolve_option, should_strip_ansi
17
+ from devtools._text import strip_ansi as strip_ansi # noqa: PLC0414
18
+ from devtools._text import strip_tmp_path as strip_tmp_path # noqa: PLC0414
18
19
 
19
20
  if TYPE_CHECKING:
20
21
  from pathlib import Path
21
22
 
22
23
  from _pytest.config import Config
23
24
 
24
- ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
25
-
26
-
27
- def strip_ansi(text: str) -> str:
28
- """Remove ANSI escape sequences from a string.
29
-
30
- Args:
31
- text: The string potentially containing ANSI escape sequences.
32
-
33
- Returns:
34
- The string with all ANSI SGR sequences removed.
35
- """
36
- return ANSI_PATTERN.sub("", text)
37
-
38
-
39
- def strip_tmp_path(text: str, tmp_path: Path) -> str:
40
- """Remove tmp_path prefix occurrences from a string.
41
-
42
- Strip both ``str(tmp_path) + "/"`` and bare ``str(tmp_path)`` so that paths
43
- nested under ``tmp_path`` collapse to their relative portion (matching the
44
- behavior of the ``debug`` fixture's tmp_path stripping).
45
-
46
- Args:
47
- text: The captured output string.
48
- tmp_path: The pytest ``tmp_path`` fixture value to strip.
49
-
50
- Returns:
51
- The string with tmp_path prefixes removed.
52
- """
53
- tmp_str = str(tmp_path)
54
- return text.replace(f"{tmp_str}/", "").replace(tmp_str, "")
55
-
56
25
 
57
26
  def add_options(parser: pytest.Parser) -> None:
58
27
  """Register CLI options for ANSI stripping.
@@ -102,28 +71,10 @@ def configure(config: Config) -> None:
102
71
  """
103
72
  config.addinivalue_line(
104
73
  "markers",
105
- "keep_ansi: disable ANSI stripping from capsys for this test",
74
+ "keep_ansi: disable ANSI stripping for this test",
106
75
  )
107
76
 
108
77
 
109
- def _should_strip_ansi(request: pytest.FixtureRequest) -> bool:
110
- """Determine whether ANSI stripping should be applied for this test.
111
-
112
- Args:
113
- request: The pytest fixture request object.
114
-
115
- Returns:
116
- True if ANSI codes should be stripped, False otherwise.
117
- """
118
- if request.config.getoption("no_strip_ansi", default=False):
119
- return False
120
-
121
- if not request.config.getini("strip_ansi"):
122
- return False
123
-
124
- return not request.node.get_closest_marker("keep_ansi")
125
-
126
-
127
78
  class StrippedCaptureFixture:
128
79
  """Wrapper around CaptureFixture that strips noise from readouterr() results.
129
80
 
@@ -187,7 +138,7 @@ def capsys(
187
138
  Returns:
188
139
  Either the original capsys or a wrapped version that post-processes output.
189
140
  """
190
- ansi = _should_strip_ansi(request)
141
+ ansi = should_strip_ansi(request)
191
142
  tmp_path = bool(resolve_option(request, "capsys_strip_tmp_path"))
192
143
 
193
144
  if not ansi and not tmp_path:
@@ -0,0 +1,538 @@
1
+ """CliRunner fixtures for click and typer with output post-processing.
2
+
3
+ ``CliRunner.invoke()`` replaces ``sys.stdout`` and ``sys.stderr`` with its own
4
+ in-memory buffers, so output produced inside it never reaches pytest's capture
5
+ machinery and the ``capsys`` override cannot see it. These fixtures apply the
6
+ same post-processing to the runner's own ``Result`` instead.
7
+
8
+ Neither click nor typer is imported at module scope; both are optional extras.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import contextlib
14
+ import importlib.util
15
+ from dataclasses import dataclass
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ import pytest
19
+
20
+ if TYPE_CHECKING:
21
+ from pathlib import Path
22
+
23
+ from devtools._options import resolve_option, should_strip_ansi
24
+ from devtools._text import strip_ansi, strip_tmp_path, strip_trailing_whitespace
25
+ from devtools.columns import get_columns_value
26
+ from devtools.debug_fixture import phase_report_key
27
+
28
+ MIN_CLICK_VERSION = "8.2"
29
+ MIN_TYPER_VERSION = "0.25"
30
+
31
+ # Remembers a tmp_path materialized on demand, which never lands in item.funcargs.
32
+ _tmp_path_key: pytest.StashKey[Path] = pytest.StashKey()
33
+
34
+
35
+ def _has_click() -> bool:
36
+ """Report whether click is importable.
37
+
38
+ Exists as a named seam so tests can simulate the library's absence without
39
+ uninstalling it.
40
+ """
41
+ return importlib.util.find_spec("click") is not None
42
+
43
+
44
+ def _has_typer() -> bool:
45
+ """Report whether typer is importable.
46
+
47
+ Exists as a named seam so tests can simulate the library's absence without
48
+ uninstalling it.
49
+ """
50
+ return importlib.util.find_spec("typer") is not None
51
+
52
+
53
+ def _installed_version(dist: str) -> str:
54
+ """Return the installed version string for a distribution.
55
+
56
+ Exists as a named seam so tests can simulate an outdated install without
57
+ downgrading anything.
58
+
59
+ Args:
60
+ dist: The distribution name to look up.
61
+
62
+ Returns:
63
+ The installed version string.
64
+ """
65
+ from importlib.metadata import version # noqa: PLC0415
66
+
67
+ return version(dist)
68
+
69
+
70
+ def _check_min_version(dist: str, minimum: str) -> None:
71
+ """Fail the test if an installed framework predates the supported floor.
72
+
73
+ The optional extras cannot enforce this: the fixtures activate on a lazy
74
+ import probe, so a project that already depends on click or typer gets them
75
+ at whatever version it happens to pin. Below the floor click's Result mixes
76
+ stderr into stdout and raises on ``.stderr``, which surfaces far from the
77
+ real cause.
78
+
79
+ Args:
80
+ dist: The distribution name to check.
81
+ minimum: The minimum supported version.
82
+ """
83
+ from packaging.version import Version # noqa: PLC0415
84
+
85
+ installed = _installed_version(dist)
86
+ if Version(installed) < Version(minimum):
87
+ pytest.fail(
88
+ f"pytest-devtools CLI runner fixtures require {dist}>={minimum}, "
89
+ f"but {dist} {installed} is installed.",
90
+ pytrace=False,
91
+ )
92
+
93
+
94
+ def add_options(parser: pytest.Parser) -> None:
95
+ """Register CLI options for CLI runner output processing.
96
+
97
+ Args:
98
+ parser: The pytest option parser to add options to.
99
+ """
100
+ group = parser.getgroup("devtools-cli-runner", "CLI runner output processing")
101
+ group.addoption(
102
+ "--cli-runner-strip-tmp-path",
103
+ action="store_true",
104
+ default=None,
105
+ dest="cli_runner_strip_tmp_path",
106
+ help="Strip tmp_path prefix from CLI runner output (default: false)",
107
+ )
108
+ group.addoption(
109
+ "--no-cli-runner-strip-tmp-path",
110
+ action="store_false",
111
+ dest="cli_runner_strip_tmp_path",
112
+ help="Don't strip tmp_path prefix from CLI runner output",
113
+ )
114
+ group.addoption(
115
+ "--cli-runner-strip-trailing-whitespace",
116
+ action="store_true",
117
+ default=None,
118
+ dest="cli_runner_strip_trailing_whitespace",
119
+ help="Right-trim every line of CLI runner output (default: false)",
120
+ )
121
+ group.addoption(
122
+ "--no-cli-runner-strip-trailing-whitespace",
123
+ action="store_false",
124
+ dest="cli_runner_strip_trailing_whitespace",
125
+ help="Don't right-trim lines of CLI runner output",
126
+ )
127
+ group.addoption(
128
+ "--cli-runner-patch-result",
129
+ action="store_true",
130
+ default=None,
131
+ dest="cli_runner_patch_result",
132
+ help="Post-process Result output for runners the plugin did not create",
133
+ )
134
+ group.addoption(
135
+ "--no-cli-runner-patch-result",
136
+ action="store_false",
137
+ dest="cli_runner_patch_result",
138
+ help="Don't patch Result for externally created runners",
139
+ )
140
+ parser.addini(
141
+ "cli_runner_strip_tmp_path",
142
+ type="bool",
143
+ default=False,
144
+ help="Strip tmp_path prefix from CLI runner output (default: false)",
145
+ )
146
+ parser.addini(
147
+ "cli_runner_strip_trailing_whitespace",
148
+ type="bool",
149
+ default=False,
150
+ help="Right-trim every line of CLI runner output (default: false)",
151
+ )
152
+ parser.addini(
153
+ "cli_runner_patch_result",
154
+ type="bool",
155
+ default=False,
156
+ help="Post-process Result output for externally created runners (default: false)",
157
+ )
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class _Transforms:
162
+ """Resolved set of post-processing steps to apply to runner output."""
163
+
164
+ ansi: bool
165
+ tmp_path: Path | None
166
+ trailing_whitespace: bool
167
+
168
+ @property
169
+ def active(self) -> bool:
170
+ """Report whether any transform would change the output."""
171
+ return self.ansi or self.tmp_path is not None or self.trailing_whitespace
172
+
173
+ def apply(self, text: str) -> str:
174
+ """Apply every enabled transform to a single output string.
175
+
176
+ Strip ANSI first so tmp_path matching never has to contend with escape
177
+ codes embedded in a path, and right-trim last so it also removes
178
+ whitespace exposed by the earlier steps.
179
+
180
+ Every transform is idempotent: applying it twice yields the same
181
+ result as applying it once. That invariant is what lets patch mode
182
+ and the fixtures' own wrapping coexist safely under any interleaving
183
+ of runners and tests; a future non-idempotent transform would break
184
+ that guarantee.
185
+
186
+ Args:
187
+ text: The raw output string from the runner Result.
188
+
189
+ Returns:
190
+ The post-processed string.
191
+ """
192
+ if self.ansi:
193
+ text = strip_ansi(text)
194
+ if self.tmp_path is not None:
195
+ text = strip_tmp_path(text, self.tmp_path)
196
+ if self.trailing_whitespace:
197
+ text = strip_trailing_whitespace(text)
198
+ return text
199
+
200
+
201
+ def _in_teardown(request: pytest.FixtureRequest) -> bool:
202
+ """Report whether the current test item is in its teardown phase.
203
+
204
+ ``request.getfixturevalue()`` for a fixture nothing requested during setup
205
+ is only legal while the call phase is still running: pytest's internal
206
+ setup/teardown stack drops the item before running its finalizers, so
207
+ resolving a fixture for the first time from within another fixture's
208
+ teardown code raises an assertion deep in pytest's runner. The call
209
+ phase's report lands in ``item.stash`` only once that phase finishes, and
210
+ a failed setup skips the call phase entirely and goes straight to
211
+ teardown, so either signal reliably means teardown is underway.
212
+
213
+ Args:
214
+ request: The pytest fixture request object.
215
+
216
+ Returns:
217
+ True if the item is past setup and call, running teardown.
218
+ """
219
+ reports = request.node.stash.get(phase_report_key, {})
220
+ if "call" in reports:
221
+ return True
222
+ setup = reports.get("setup")
223
+ return setup is not None and not setup.passed
224
+
225
+
226
+ def _resolve_transforms(request: pytest.FixtureRequest) -> _Transforms:
227
+ """Resolve which transforms apply to the current test.
228
+
229
+ Prefers the already-resolved ``tmp_path`` value from ``item.funcargs``
230
+ when the test's fixture closure requested it. That dict is filled once
231
+ during setup and never cleared, so it holds the correct value through the
232
+ whole test regardless of where ``tmp_path`` sits in the test's own
233
+ argument order, and regardless of whether tmp_path's own finalizer has
234
+ already run.
235
+
236
+ Falls back to ``request.getfixturevalue()`` only during the call phase,
237
+ for a test whose fixture closure never asked for ``tmp_path`` at all;
238
+ that materializes it on demand so a Result read directly in the test body
239
+ still gets stripped. ``getfixturevalue()`` does not record into
240
+ ``funcargs``, so the value is stashed on the item: without that, the same
241
+ Result would strip in the test body and come back unstripped from a
242
+ teardown read, where the fallback is illegal and cannot run again.
243
+
244
+ Args:
245
+ request: The pytest fixture request object.
246
+
247
+ Returns:
248
+ The resolved transform set.
249
+ """
250
+ tmp_path: Path | None = None
251
+ if resolve_option(request, "cli_runner_strip_tmp_path"):
252
+ funcargs = request.node.funcargs
253
+ stash = request.node.stash
254
+ if "tmp_path" in funcargs:
255
+ tmp_path = funcargs["tmp_path"]
256
+ elif (stashed := stash.get(_tmp_path_key, None)) is not None:
257
+ tmp_path = stashed
258
+ elif not _in_teardown(request):
259
+ with contextlib.suppress(pytest.FixtureLookupError):
260
+ tmp_path = request.getfixturevalue("tmp_path")
261
+ stash[_tmp_path_key] = tmp_path
262
+
263
+ return _Transforms(
264
+ ansi=should_strip_ansi(request),
265
+ tmp_path=tmp_path,
266
+ trailing_whitespace=bool(resolve_option(request, "cli_runner_strip_trailing_whitespace")),
267
+ )
268
+
269
+
270
+ def _transforms_configured(request: pytest.FixtureRequest) -> bool:
271
+ """Report whether any transform is enabled, without resolving tmp_path.
272
+
273
+ Reads the tmp_path option flag directly rather than resolving the
274
+ tmp_path fixture, so this check is safe to call from an autouse fixture
275
+ before deciding whether to install the patch at all.
276
+
277
+ Args:
278
+ request: The pytest fixture request object.
279
+
280
+ Returns:
281
+ True if any transform would apply to this test.
282
+ """
283
+ return (
284
+ should_strip_ansi(request)
285
+ or bool(resolve_option(request, "cli_runner_strip_tmp_path"))
286
+ or bool(resolve_option(request, "cli_runner_strip_trailing_whitespace"))
287
+ )
288
+
289
+
290
+ def _patch_active(request: pytest.FixtureRequest) -> bool:
291
+ """Report whether global Result patching is enabled for this test.
292
+
293
+ Args:
294
+ request: The pytest fixture request object.
295
+
296
+ Returns:
297
+ True if the Result classes should be patched.
298
+ """
299
+ return bool(resolve_option(request, "cli_runner_patch_result"))
300
+
301
+
302
+ class StrippedResult:
303
+ """Wrapper around a runner Result that post-processes its output strings.
304
+
305
+ ``Result.output``, ``.stdout`` and ``.stderr`` are read-only properties and
306
+ cannot be reassigned per instance, so wrap the Result and forward everything
307
+ else through ``__getattr__``.
308
+ """
309
+
310
+ def __init__(self, original: Any, transforms: _Transforms) -> None:
311
+ self._original = original
312
+ self._transforms = transforms
313
+
314
+ @property
315
+ def __class__(self) -> type:
316
+ """Report the wrapped Result's class so isinstance() checks pass.
317
+
318
+ CPython's isinstance() consults __class__ before falling back to the
319
+ real type, so a user's isinstance(result, click.testing.Result) or
320
+ -> Result annotation holds regardless of which transforms are active.
321
+ type(result) still reports StrippedResult, since type() reads the
322
+ real object header rather than this property.
323
+ """
324
+ return type(self._original)
325
+
326
+ @property
327
+ def output(self) -> str:
328
+ """Return the interleaved stdout and stderr, post-processed."""
329
+ return self._transforms.apply(self._original.output)
330
+
331
+ @property
332
+ def stdout(self) -> str:
333
+ """Return the standard output, post-processed."""
334
+ return self._transforms.apply(self._original.stdout)
335
+
336
+ @property
337
+ def stderr(self) -> str:
338
+ """Return the standard error, post-processed."""
339
+ return self._transforms.apply(self._original.stderr)
340
+
341
+ def __repr__(self) -> str:
342
+ """Delegate to the wrapped Result's repr."""
343
+ return repr(self._original)
344
+
345
+ def __getattr__(self, name: str) -> Any:
346
+ """Delegate attribute access to the wrapped Result."""
347
+ return getattr(self._original, name)
348
+
349
+
350
+ class _ResultPostProcessMixin:
351
+ """Supply an ``invoke()`` that post-processes the returned Result.
352
+
353
+ Mixed in ahead of the framework's own ``CliRunner`` so ``super().invoke()``
354
+ resolves to the real implementation.
355
+ """
356
+
357
+ def invoke(self, *args: Any, **kwargs: Any) -> Any:
358
+ """Invoke the CLI and post-process the resulting output.
359
+
360
+ Args:
361
+ *args: Positional arguments forwarded to the framework's invoke().
362
+ **kwargs: Keyword arguments forwarded to the framework's invoke().
363
+
364
+ Returns:
365
+ A StrippedResult when any transform is enabled, else the original
366
+ Result.
367
+ """
368
+ request: pytest.FixtureRequest | None = getattr(self, "_devtools_request", None)
369
+
370
+ if request is None:
371
+ return super().invoke(*args, **kwargs) # ty: ignore[unresolved-attribute]
372
+
373
+ # click's help formatter caps at 80 columns and ignores COLUMNS, so the
374
+ # width has to travel in as a Context setting.
375
+ width = get_columns_value(request.config)
376
+ if width is not None:
377
+ kwargs.setdefault("terminal_width", width)
378
+
379
+ result = super().invoke(*args, **kwargs) # ty: ignore[unresolved-attribute]
380
+
381
+ if _patch_active(request):
382
+ return result
383
+
384
+ transforms = _resolve_transforms(request)
385
+ return StrippedResult(result, transforms) if transforms.active else result
386
+
387
+
388
+ def _click_runner_class() -> type:
389
+ """Build the click runner subclass, importing click lazily.
390
+
391
+ Built fresh on every call rather than cached. A cached class pins the
392
+ ``CliRunner`` from whichever click copy was importable first, and under
393
+ ``pytester`` a later run re-imports click fresh. The runner would then
394
+ build a ``Result`` from the stale copy while patch mode patches the fresh
395
+ ``click.testing.Result``, silently dropping all post-processing.
396
+
397
+ Returns:
398
+ The ``DevtoolsClickRunner`` class.
399
+ """
400
+ from click.testing import CliRunner # noqa: PLC0415
401
+
402
+ return type("DevtoolsClickRunner", (_ResultPostProcessMixin, CliRunner), {})
403
+
404
+
405
+ @pytest.fixture
406
+ def cli_runner(request: pytest.FixtureRequest) -> Any:
407
+ """Provide a click CliRunner whose Result output is post-processed.
408
+
409
+ Use in place of ``click.testing.CliRunner()`` so that ANSI escape sequences
410
+ and other configured noise are removed from ``result.output``,
411
+ ``result.stdout`` and ``result.stderr``.
412
+
413
+ Args:
414
+ request: The pytest fixture request object.
415
+
416
+ Returns:
417
+ A CliRunner subclass instance.
418
+ """
419
+ if not _has_click():
420
+ pytest.fail(
421
+ "The cli_runner fixture requires click. Install pytest-devtools[click].",
422
+ pytrace=False,
423
+ )
424
+ _check_min_version("click", MIN_CLICK_VERSION)
425
+
426
+ runner = _click_runner_class()()
427
+ runner._devtools_request = request # noqa: SLF001
428
+ return runner
429
+
430
+
431
+ def _typer_runner_class() -> type:
432
+ """Build the typer runner subclass, importing typer lazily.
433
+
434
+ Subclassing typer's own runner covers both typer eras: before 0.26 it is a
435
+ click CliRunner subclass, from 0.26 an independent implementation over
436
+ typer's vendored click.
437
+
438
+ Built fresh on every call rather than cached, for the same reason as the
439
+ click runner, plus one specific to typer: ``invoke()`` re-derives the click
440
+ command via ``get_command()``, which resolves unset ``Typer()`` defaults
441
+ with ``isinstance(value, DefaultPlaceholder)`` checks. Run against a
442
+ freshly imported typer, those checks fail across the two module copies and
443
+ let an unresolved placeholder leak through as real data.
444
+ """
445
+ from typer.testing import CliRunner # noqa: PLC0415
446
+
447
+ return type("DevtoolsTyperRunner", (_ResultPostProcessMixin, CliRunner), {})
448
+
449
+
450
+ @pytest.fixture
451
+ def typer_runner(request: pytest.FixtureRequest) -> Any:
452
+ """Provide a typer CliRunner whose Result output is post-processed.
453
+
454
+ Use in place of ``typer.testing.CliRunner()`` so that ANSI escape sequences
455
+ and other configured noise are removed from ``result.output``,
456
+ ``result.stdout`` and ``result.stderr``.
457
+
458
+ Args:
459
+ request: The pytest fixture request object.
460
+
461
+ Returns:
462
+ A typer CliRunner subclass instance.
463
+ """
464
+ if not _has_typer():
465
+ pytest.fail(
466
+ "The typer_runner fixture requires typer. Install pytest-devtools[typer].",
467
+ pytrace=False,
468
+ )
469
+ _check_min_version("typer", MIN_TYPER_VERSION)
470
+
471
+ runner = _typer_runner_class()()
472
+ runner._devtools_request = request # noqa: SLF001
473
+ return runner
474
+
475
+
476
+ def _result_classes() -> list[type]:
477
+ """Collect the installed frameworks' Result classes.
478
+
479
+ Before typer 0.26 ``typer.testing.Result`` is ``click.testing.Result``, so
480
+ de-duplicate by identity to avoid patching the same class twice.
481
+
482
+ Returns:
483
+ The distinct Result classes to patch.
484
+ """
485
+ classes: list[type] = []
486
+
487
+ if _has_click():
488
+ from click.testing import Result # noqa: PLC0415
489
+
490
+ classes.append(Result)
491
+
492
+ if _has_typer():
493
+ from typer.testing import Result as TyperResult # noqa: PLC0415
494
+
495
+ if TyperResult not in classes:
496
+ classes.append(TyperResult)
497
+
498
+ return classes
499
+
500
+
501
+ @pytest.fixture(autouse=True)
502
+ def _patch_cli_result(
503
+ request: pytest.FixtureRequest,
504
+ monkeypatch: pytest.MonkeyPatch,
505
+ ) -> None:
506
+ """Post-process Result output for runners this plugin did not create.
507
+
508
+ Opt in with ``cli_runner_patch_result`` when runners are constructed
509
+ elsewhere, for instance in a project's own conftest fixture. monkeypatch
510
+ teardown restores the original properties after each test.
511
+
512
+ The replacement properties resolve transforms on every read rather than
513
+ once here, so ``tmp_path`` materializes only if a test actually reads a
514
+ patched ``Result`` attribute, not for every test that merely has patching
515
+ and tmp_path stripping enabled.
516
+
517
+ Args:
518
+ request: The pytest fixture request object.
519
+ monkeypatch: The pytest monkeypatch fixture.
520
+ """
521
+ if not _patch_active(request):
522
+ return
523
+
524
+ if not _transforms_configured(request):
525
+ return
526
+
527
+ for result_cls in _result_classes():
528
+ for name in ("output", "stdout", "stderr"):
529
+ original = getattr(result_cls, name)
530
+ monkeypatch.setattr(
531
+ result_cls,
532
+ name,
533
+ property(
534
+ lambda self, _fget=original.fget: _resolve_transforms(request).apply(
535
+ _fget(self)
536
+ )
537
+ ),
538
+ )
@@ -43,7 +43,7 @@ def add_options(parser: pytest.Parser) -> None:
43
43
  )
44
44
 
45
45
 
46
- def _get_columns_value(config: Config) -> int | None:
46
+ def get_columns_value(config: Config) -> int | None:
47
47
  """Determine the COLUMNS value from CLI and ini options.
48
48
 
49
49
  Args:
@@ -71,6 +71,6 @@ def _set_columns(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch
71
71
  request: The pytest fixture request object.
72
72
  monkeypatch: The pytest monkeypatch fixture.
73
73
  """
74
- columns = _get_columns_value(request.config)
74
+ columns = get_columns_value(request.config)
75
75
  if columns is not None:
76
76
  monkeypatch.setenv("COLUMNS", str(columns))
@@ -9,8 +9,11 @@ import pytest
9
9
  if TYPE_CHECKING:
10
10
  from collections.abc import Generator
11
11
 
12
- from devtools import capsys_strip, columns, debug_fixture, whitespace
12
+ from devtools import capsys_strip, cli_runners, columns, debug_fixture, whitespace
13
13
  from devtools.capsys_strip import capsys as capsys # noqa: PLC0414
14
+ from devtools.cli_runners import _patch_cli_result as _patch_cli_result # noqa: PLC0414
15
+ from devtools.cli_runners import cli_runner as cli_runner # noqa: PLC0414
16
+ from devtools.cli_runners import typer_runner as typer_runner # noqa: PLC0414
14
17
  from devtools.columns import _set_columns as _set_columns # noqa: PLC0414
15
18
  from devtools.debug_fixture import debug as debug # noqa: PLC0414
16
19
  from devtools.debug_fixture import phase_report_key
@@ -22,6 +25,7 @@ def pytest_addoption(parser: pytest.Parser) -> None:
22
25
  capsys_strip.add_options(parser)
23
26
  whitespace.add_options(parser)
24
27
  debug_fixture.add_options(parser)
28
+ cli_runners.add_options(parser)
25
29
 
26
30
 
27
31
  def pytest_configure(config: pytest.Config) -> None:
File without changes