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.
- {pytest_devtools-1.2.0 → pytest_devtools-1.3.0}/PKG-INFO +121 -2
- {pytest_devtools-1.2.0 → pytest_devtools-1.3.0}/README.md +115 -0
- {pytest_devtools-1.2.0 → pytest_devtools-1.3.0}/pyproject.toml +15 -9
- {pytest_devtools-1.2.0 → pytest_devtools-1.3.0}/src/devtools/_options.py +21 -0
- pytest_devtools-1.3.0/src/devtools/_text.py +66 -0
- {pytest_devtools-1.2.0 → pytest_devtools-1.3.0}/src/devtools/capsys_strip.py +5 -54
- pytest_devtools-1.3.0/src/devtools/cli_runners.py +538 -0
- {pytest_devtools-1.2.0 → pytest_devtools-1.3.0}/src/devtools/columns.py +2 -2
- {pytest_devtools-1.2.0 → pytest_devtools-1.3.0}/src/devtools/plugin.py +5 -1
- {pytest_devtools-1.2.0 → pytest_devtools-1.3.0}/LICENSE +0 -0
- {pytest_devtools-1.2.0 → pytest_devtools-1.3.0}/src/devtools/__init__.py +0 -0
- {pytest_devtools-1.2.0 → pytest_devtools-1.3.0}/src/devtools/debug_fixture.py +0 -0
- {pytest_devtools-1.2.0 → pytest_devtools-1.3.0}/src/devtools/whitespace.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: pytest-devtools
|
|
3
|
-
Version: 1.
|
|
4
|
-
Summary: Pytest plugin providing debug fixtures, ANSI-stripped capsys, whitespace-visible assertions,
|
|
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,
|
|
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.
|
|
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
|
-
"
|
|
34
|
-
"
|
|
37
|
+
"click>=8.2",
|
|
38
|
+
"commitizen>=4.16.3",
|
|
39
|
+
"coverage>=7.14.1",
|
|
35
40
|
"duty>=1.9.0",
|
|
36
|
-
"prek>=0.
|
|
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.
|
|
42
|
-
"ty>=0.0.
|
|
43
|
-
"
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|