pytest-devtools 1.0.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.0.0/LICENSE +9 -0
- pytest_devtools-1.0.0/PKG-INFO +285 -0
- pytest_devtools-1.0.0/README.md +258 -0
- pytest_devtools-1.0.0/pyproject.toml +134 -0
- pytest_devtools-1.0.0/src/devtools/__init__.py +1 -0
- pytest_devtools-1.0.0/src/devtools/capsys_strip.py +138 -0
- pytest_devtools-1.0.0/src/devtools/columns.py +76 -0
- pytest_devtools-1.0.0/src/devtools/debug_fixture.py +263 -0
- pytest_devtools-1.0.0/src/devtools/plugin.py +68 -0
- pytest_devtools-1.0.0/src/devtools/whitespace.py +112 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 natelandau
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pytest-devtools
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Pytest plugin providing debug fixtures, ANSI-stripped capsys, whitespace-visible assertions, and terminal column management.
|
|
5
|
+
Author: Nathaniel Landau
|
|
6
|
+
Author-email: Nathaniel Landau <github@natelandau.com>
|
|
7
|
+
License: MIT License
|
|
8
|
+
|
|
9
|
+
Copyright (c) 2026 natelandau
|
|
10
|
+
|
|
11
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
16
|
+
Classifier: Framework :: Pytest
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Software Development :: Testing
|
|
23
|
+
Requires-Dist: pytest>=9.0.2
|
|
24
|
+
Requires-Dist: rich>=14.3.2
|
|
25
|
+
Requires-Python: >=3.11
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# pytest-devtools
|
|
29
|
+
|
|
30
|
+
Small pytest plugin that provides some niceties for writing and debugging tests.
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
- **Debug fixture** -- Pretty-print variables, paths, and data structures with Rich. Output appears only when tests fail (or always with `--print-debug`).
|
|
35
|
+
- **ANSI-stripped capsys** -- Automatically strip ANSI escape sequences from captured output so assertions don't break on color codes.
|
|
36
|
+
- **Visible whitespace** -- Replace invisible whitespace characters (tabs, trailing spaces, carriage returns, newlines) with Unicode symbols in assertion failure diffs.
|
|
37
|
+
- **Terminal column width** -- Optionally set the `COLUMNS` environment variable so Rich and other terminal-aware libraries don't introduce unwanted line wraps.
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Using uv
|
|
43
|
+
uv add pytest-devtools
|
|
44
|
+
|
|
45
|
+
# Using pip
|
|
46
|
+
pip install pytest-devtools
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Requirements:** Python 3.11+ and pytest 7.0+
|
|
50
|
+
|
|
51
|
+
The plugin activates automatically once installed. No `conftest.py` changes are needed.
|
|
52
|
+
|
|
53
|
+
## Debug Fixture
|
|
54
|
+
|
|
55
|
+
The `debug` fixture gives you a callable that pretty-prints any Python object using Rich. Output is collected during the test and flushed to stderr only when the test fails.
|
|
56
|
+
|
|
57
|
+
### Basic Usage
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
def test_user_creation(debug, tmp_path):
|
|
61
|
+
user = {"name": "Alice", "roles": ["admin", "editor"]}
|
|
62
|
+
debug(user)
|
|
63
|
+
|
|
64
|
+
config_path = tmp_path / "config.toml"
|
|
65
|
+
config_path.write_text("[settings]\nverbose = true")
|
|
66
|
+
debug(config_path)
|
|
67
|
+
|
|
68
|
+
assert user["name"] == "Alice"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
On failure, stderr shows the Rich-formatted output between rule separators:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
──────────────────────────── Debug ─────────────────────────────
|
|
75
|
+
{'name': 'Alice', 'roles': ['admin', 'editor']}
|
|
76
|
+
──────────────────────────── Debug ─────────────────────────────
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Multiple Values and Titles
|
|
80
|
+
|
|
81
|
+
Pass multiple arguments in a single call, and use `title` to label sections:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
def test_transform(debug):
|
|
85
|
+
before = [1, 2, 3]
|
|
86
|
+
after = [x * 2 for x in before]
|
|
87
|
+
debug(before, after, title="Transform")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Per-Call Options
|
|
91
|
+
|
|
92
|
+
Every option can be overridden on a per-call basis:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
def test_deep_structure(debug, tmp_path):
|
|
96
|
+
nested = {"a": {"b": {"c": {"d": "deep"}}}}
|
|
97
|
+
|
|
98
|
+
# Limit nesting depth
|
|
99
|
+
debug(nested, max_depth=2)
|
|
100
|
+
|
|
101
|
+
# Limit collection length
|
|
102
|
+
debug(list(range(100)), max_length=5)
|
|
103
|
+
|
|
104
|
+
# Show type annotations
|
|
105
|
+
debug(nested, show_type=True)
|
|
106
|
+
|
|
107
|
+
# Show directory tree for Path objects
|
|
108
|
+
debug(tmp_path, list_dir_contents=True)
|
|
109
|
+
|
|
110
|
+
# Disable tmp_path prefix stripping
|
|
111
|
+
debug(tmp_path / "output.txt", strip_tmp_path=False)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Path Handling
|
|
115
|
+
|
|
116
|
+
When you pass a `pathlib.Path` to `debug`:
|
|
117
|
+
|
|
118
|
+
- **tmp_path stripping** (default: on) -- If the path is inside `tmp_path`, only the relative portion is shown. A path like `/var/folders/.../pytest-1234/test_foo0/subdir/file.txt` displays as `subdir/file.txt`.
|
|
119
|
+
- **Directory listing** (default: off) -- When enabled and the path is a directory, a Rich tree shows the full directory contents recursively.
|
|
120
|
+
|
|
121
|
+
### CLI Options
|
|
122
|
+
|
|
123
|
+
| Flag | Description |
|
|
124
|
+
| ------------------------------ | --------------------------------------------------- |
|
|
125
|
+
| `--print-debug` | Always show debug output, even on passing tests |
|
|
126
|
+
| `--debug-strip-tmp-path` | Strip `tmp_path` prefix from Path objects (default) |
|
|
127
|
+
| `--no-debug-strip-tmp-path` | Show full absolute paths |
|
|
128
|
+
| `--debug-list-dir-contents` | Show directory tree for Path directories |
|
|
129
|
+
| `--no-debug-list-dir-contents` | Don't list directory contents (default) |
|
|
130
|
+
| `--debug-max-depth=N` | Limit nesting depth in pretty-printed output |
|
|
131
|
+
| `--debug-max-length=N` | Limit collection length in pretty-printed output |
|
|
132
|
+
| `--debug-show-type` | Show type annotations above each value |
|
|
133
|
+
| `--no-debug-show-type` | Don't show type annotations (default) |
|
|
134
|
+
|
|
135
|
+
### INI Options
|
|
136
|
+
|
|
137
|
+
Add these to `pyproject.toml` under `[tool.pytest.ini_options]`:
|
|
138
|
+
|
|
139
|
+
```toml
|
|
140
|
+
[tool.pytest.ini_options]
|
|
141
|
+
print_debug = true
|
|
142
|
+
debug_strip_tmp_path = true
|
|
143
|
+
debug_list_dir_contents = false
|
|
144
|
+
debug_max_depth = 4
|
|
145
|
+
debug_max_length = 20
|
|
146
|
+
debug_show_type = false
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Option Precedence
|
|
150
|
+
|
|
151
|
+
Per-call arguments take highest priority, then CLI flags, then INI settings:
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
per-call override > CLI flag > INI option > built-in default
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## ANSI-Stripped capsys
|
|
158
|
+
|
|
159
|
+
By default, `capsys.readouterr()` returns output with ANSI escape sequences removed. This makes assertions simpler when testing code that uses colored output (Rich, Click, Colorama, etc.).
|
|
160
|
+
|
|
161
|
+
### Basic Usage
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
def test_greeting(capsys):
|
|
165
|
+
# Imagine this function uses Rich to print colored output
|
|
166
|
+
print("\x1b[32mHello, world!\x1b[0m")
|
|
167
|
+
|
|
168
|
+
captured = capsys.readouterr()
|
|
169
|
+
assert captured.out == "Hello, world!\n" # No ANSI codes to worry about
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Keeping ANSI Codes
|
|
173
|
+
|
|
174
|
+
For tests that need to verify color output, disable stripping per-test with a marker:
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
import pytest
|
|
178
|
+
|
|
179
|
+
@pytest.mark.keep_ansi
|
|
180
|
+
def test_color_codes(capsys):
|
|
181
|
+
print("\x1b[32mgreen\x1b[0m")
|
|
182
|
+
captured = capsys.readouterr()
|
|
183
|
+
assert "\x1b[32m" in captured.out
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Or disable stripping globally with a CLI flag:
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
pytest --no-strip-ansi
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### INI Option
|
|
193
|
+
|
|
194
|
+
```toml
|
|
195
|
+
[tool.pytest.ini_options]
|
|
196
|
+
strip_ansi = false
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Visible Whitespace in Assertions
|
|
200
|
+
|
|
201
|
+
When two strings differ only by whitespace, pytest's default diff is hard to read. This plugin replaces invisible characters with visible Unicode symbols in assertion failure output.
|
|
202
|
+
|
|
203
|
+
### Symbol Reference
|
|
204
|
+
|
|
205
|
+
| Character | Symbol | Name |
|
|
206
|
+
| ---------------------- | ------ | ---------------- |
|
|
207
|
+
| Trailing space | `·` | Middle dot |
|
|
208
|
+
| Tab (`\t`) | `→` | Rightwards arrow |
|
|
209
|
+
| Carriage return (`\r`) | `←` | Leftwards arrow |
|
|
210
|
+
| Newline (`\n`) | `↵` | Return symbol |
|
|
211
|
+
|
|
212
|
+
### Example Output
|
|
213
|
+
|
|
214
|
+
For a test like:
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
def test_output():
|
|
218
|
+
assert "hello " == "hello"
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The failure message shows:
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
AssertionError: 'hello·' == 'hello'
|
|
225
|
+
|
|
226
|
+
Whitespace-visible comparison:
|
|
227
|
+
Left: 'hello·'
|
|
228
|
+
Right: 'hello'
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Disabling
|
|
232
|
+
|
|
233
|
+
Disable with the `--no-show-whitespace` CLI flag or in INI:
|
|
234
|
+
|
|
235
|
+
```toml
|
|
236
|
+
[tool.pytest.ini_options]
|
|
237
|
+
show_whitespace = false
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
> **Note:** Whitespace visibility only activates for `==` comparisons between strings, and only when the replacement actually changes the display. Non-string comparisons and strings without special whitespace are unaffected.
|
|
241
|
+
|
|
242
|
+
## Terminal Column Width
|
|
243
|
+
|
|
244
|
+
Many terminal-aware libraries (Rich, Click, etc.) detect the terminal width at runtime. In test environments, the detected width is often very small, causing unwanted line wraps in captured output. This plugin can set the `COLUMNS` environment variable for every test to prevent this.
|
|
245
|
+
|
|
246
|
+
This feature is **disabled by default**. Enable it with the `--columns` CLI flag or via INI options.
|
|
247
|
+
|
|
248
|
+
### CLI Option
|
|
249
|
+
|
|
250
|
+
Set `COLUMNS` for a single run:
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
pytest --columns=180
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### INI Options
|
|
257
|
+
|
|
258
|
+
Enable permanently in `pyproject.toml`:
|
|
259
|
+
|
|
260
|
+
```toml
|
|
261
|
+
[tool.pytest.ini_options]
|
|
262
|
+
set_columns = true # Enable the feature
|
|
263
|
+
columns = 180 # Value to set (default when enabled)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
The `--columns` CLI flag overrides the INI `columns` value when both are present.
|
|
267
|
+
|
|
268
|
+
## Configuration Summary
|
|
269
|
+
|
|
270
|
+
All features can be configured via CLI flags, `pyproject.toml` INI options, or (for the debug fixture) per-call arguments.
|
|
271
|
+
|
|
272
|
+
| Feature | Enabled by default | Enable/Disable with |
|
|
273
|
+
| ------------------ | ---------------------- | --------------------------------------------------- |
|
|
274
|
+
| Debug fixture | Output on failure only | N/A (always available) |
|
|
275
|
+
| ANSI stripping | Yes | `--no-strip-ansi` or `strip_ansi = false` |
|
|
276
|
+
| Visible whitespace | Yes | `--no-show-whitespace` or `show_whitespace = false` |
|
|
277
|
+
| Column width | No | `--columns=N` or `set_columns = true` |
|
|
278
|
+
|
|
279
|
+
## AI Policy
|
|
280
|
+
|
|
281
|
+
All AI generated content is and always will be meticulously reviewed and approved by the author.
|
|
282
|
+
|
|
283
|
+
## License
|
|
284
|
+
|
|
285
|
+
MIT
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# pytest-devtools
|
|
2
|
+
|
|
3
|
+
Small pytest plugin that provides some niceties for writing and debugging tests.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Debug fixture** -- Pretty-print variables, paths, and data structures with Rich. Output appears only when tests fail (or always with `--print-debug`).
|
|
8
|
+
- **ANSI-stripped capsys** -- Automatically strip ANSI escape sequences from captured output so assertions don't break on color codes.
|
|
9
|
+
- **Visible whitespace** -- Replace invisible whitespace characters (tabs, trailing spaces, carriage returns, newlines) with Unicode symbols in assertion failure diffs.
|
|
10
|
+
- **Terminal column width** -- Optionally set the `COLUMNS` environment variable so Rich and other terminal-aware libraries don't introduce unwanted line wraps.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# Using uv
|
|
16
|
+
uv add pytest-devtools
|
|
17
|
+
|
|
18
|
+
# Using pip
|
|
19
|
+
pip install pytest-devtools
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**Requirements:** Python 3.11+ and pytest 7.0+
|
|
23
|
+
|
|
24
|
+
The plugin activates automatically once installed. No `conftest.py` changes are needed.
|
|
25
|
+
|
|
26
|
+
## Debug Fixture
|
|
27
|
+
|
|
28
|
+
The `debug` fixture gives you a callable that pretty-prints any Python object using Rich. Output is collected during the test and flushed to stderr only when the test fails.
|
|
29
|
+
|
|
30
|
+
### Basic Usage
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
def test_user_creation(debug, tmp_path):
|
|
34
|
+
user = {"name": "Alice", "roles": ["admin", "editor"]}
|
|
35
|
+
debug(user)
|
|
36
|
+
|
|
37
|
+
config_path = tmp_path / "config.toml"
|
|
38
|
+
config_path.write_text("[settings]\nverbose = true")
|
|
39
|
+
debug(config_path)
|
|
40
|
+
|
|
41
|
+
assert user["name"] == "Alice"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
On failure, stderr shows the Rich-formatted output between rule separators:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
──────────────────────────── Debug ─────────────────────────────
|
|
48
|
+
{'name': 'Alice', 'roles': ['admin', 'editor']}
|
|
49
|
+
──────────────────────────── Debug ─────────────────────────────
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Multiple Values and Titles
|
|
53
|
+
|
|
54
|
+
Pass multiple arguments in a single call, and use `title` to label sections:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
def test_transform(debug):
|
|
58
|
+
before = [1, 2, 3]
|
|
59
|
+
after = [x * 2 for x in before]
|
|
60
|
+
debug(before, after, title="Transform")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Per-Call Options
|
|
64
|
+
|
|
65
|
+
Every option can be overridden on a per-call basis:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
def test_deep_structure(debug, tmp_path):
|
|
69
|
+
nested = {"a": {"b": {"c": {"d": "deep"}}}}
|
|
70
|
+
|
|
71
|
+
# Limit nesting depth
|
|
72
|
+
debug(nested, max_depth=2)
|
|
73
|
+
|
|
74
|
+
# Limit collection length
|
|
75
|
+
debug(list(range(100)), max_length=5)
|
|
76
|
+
|
|
77
|
+
# Show type annotations
|
|
78
|
+
debug(nested, show_type=True)
|
|
79
|
+
|
|
80
|
+
# Show directory tree for Path objects
|
|
81
|
+
debug(tmp_path, list_dir_contents=True)
|
|
82
|
+
|
|
83
|
+
# Disable tmp_path prefix stripping
|
|
84
|
+
debug(tmp_path / "output.txt", strip_tmp_path=False)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Path Handling
|
|
88
|
+
|
|
89
|
+
When you pass a `pathlib.Path` to `debug`:
|
|
90
|
+
|
|
91
|
+
- **tmp_path stripping** (default: on) -- If the path is inside `tmp_path`, only the relative portion is shown. A path like `/var/folders/.../pytest-1234/test_foo0/subdir/file.txt` displays as `subdir/file.txt`.
|
|
92
|
+
- **Directory listing** (default: off) -- When enabled and the path is a directory, a Rich tree shows the full directory contents recursively.
|
|
93
|
+
|
|
94
|
+
### CLI Options
|
|
95
|
+
|
|
96
|
+
| Flag | Description |
|
|
97
|
+
| ------------------------------ | --------------------------------------------------- |
|
|
98
|
+
| `--print-debug` | Always show debug output, even on passing tests |
|
|
99
|
+
| `--debug-strip-tmp-path` | Strip `tmp_path` prefix from Path objects (default) |
|
|
100
|
+
| `--no-debug-strip-tmp-path` | Show full absolute paths |
|
|
101
|
+
| `--debug-list-dir-contents` | Show directory tree for Path directories |
|
|
102
|
+
| `--no-debug-list-dir-contents` | Don't list directory contents (default) |
|
|
103
|
+
| `--debug-max-depth=N` | Limit nesting depth in pretty-printed output |
|
|
104
|
+
| `--debug-max-length=N` | Limit collection length in pretty-printed output |
|
|
105
|
+
| `--debug-show-type` | Show type annotations above each value |
|
|
106
|
+
| `--no-debug-show-type` | Don't show type annotations (default) |
|
|
107
|
+
|
|
108
|
+
### INI Options
|
|
109
|
+
|
|
110
|
+
Add these to `pyproject.toml` under `[tool.pytest.ini_options]`:
|
|
111
|
+
|
|
112
|
+
```toml
|
|
113
|
+
[tool.pytest.ini_options]
|
|
114
|
+
print_debug = true
|
|
115
|
+
debug_strip_tmp_path = true
|
|
116
|
+
debug_list_dir_contents = false
|
|
117
|
+
debug_max_depth = 4
|
|
118
|
+
debug_max_length = 20
|
|
119
|
+
debug_show_type = false
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Option Precedence
|
|
123
|
+
|
|
124
|
+
Per-call arguments take highest priority, then CLI flags, then INI settings:
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
per-call override > CLI flag > INI option > built-in default
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## ANSI-Stripped capsys
|
|
131
|
+
|
|
132
|
+
By default, `capsys.readouterr()` returns output with ANSI escape sequences removed. This makes assertions simpler when testing code that uses colored output (Rich, Click, Colorama, etc.).
|
|
133
|
+
|
|
134
|
+
### Basic Usage
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
def test_greeting(capsys):
|
|
138
|
+
# Imagine this function uses Rich to print colored output
|
|
139
|
+
print("\x1b[32mHello, world!\x1b[0m")
|
|
140
|
+
|
|
141
|
+
captured = capsys.readouterr()
|
|
142
|
+
assert captured.out == "Hello, world!\n" # No ANSI codes to worry about
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Keeping ANSI Codes
|
|
146
|
+
|
|
147
|
+
For tests that need to verify color output, disable stripping per-test with a marker:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
import pytest
|
|
151
|
+
|
|
152
|
+
@pytest.mark.keep_ansi
|
|
153
|
+
def test_color_codes(capsys):
|
|
154
|
+
print("\x1b[32mgreen\x1b[0m")
|
|
155
|
+
captured = capsys.readouterr()
|
|
156
|
+
assert "\x1b[32m" in captured.out
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Or disable stripping globally with a CLI flag:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
pytest --no-strip-ansi
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### INI Option
|
|
166
|
+
|
|
167
|
+
```toml
|
|
168
|
+
[tool.pytest.ini_options]
|
|
169
|
+
strip_ansi = false
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Visible Whitespace in Assertions
|
|
173
|
+
|
|
174
|
+
When two strings differ only by whitespace, pytest's default diff is hard to read. This plugin replaces invisible characters with visible Unicode symbols in assertion failure output.
|
|
175
|
+
|
|
176
|
+
### Symbol Reference
|
|
177
|
+
|
|
178
|
+
| Character | Symbol | Name |
|
|
179
|
+
| ---------------------- | ------ | ---------------- |
|
|
180
|
+
| Trailing space | `·` | Middle dot |
|
|
181
|
+
| Tab (`\t`) | `→` | Rightwards arrow |
|
|
182
|
+
| Carriage return (`\r`) | `←` | Leftwards arrow |
|
|
183
|
+
| Newline (`\n`) | `↵` | Return symbol |
|
|
184
|
+
|
|
185
|
+
### Example Output
|
|
186
|
+
|
|
187
|
+
For a test like:
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
def test_output():
|
|
191
|
+
assert "hello " == "hello"
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The failure message shows:
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
AssertionError: 'hello·' == 'hello'
|
|
198
|
+
|
|
199
|
+
Whitespace-visible comparison:
|
|
200
|
+
Left: 'hello·'
|
|
201
|
+
Right: 'hello'
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Disabling
|
|
205
|
+
|
|
206
|
+
Disable with the `--no-show-whitespace` CLI flag or in INI:
|
|
207
|
+
|
|
208
|
+
```toml
|
|
209
|
+
[tool.pytest.ini_options]
|
|
210
|
+
show_whitespace = false
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
> **Note:** Whitespace visibility only activates for `==` comparisons between strings, and only when the replacement actually changes the display. Non-string comparisons and strings without special whitespace are unaffected.
|
|
214
|
+
|
|
215
|
+
## Terminal Column Width
|
|
216
|
+
|
|
217
|
+
Many terminal-aware libraries (Rich, Click, etc.) detect the terminal width at runtime. In test environments, the detected width is often very small, causing unwanted line wraps in captured output. This plugin can set the `COLUMNS` environment variable for every test to prevent this.
|
|
218
|
+
|
|
219
|
+
This feature is **disabled by default**. Enable it with the `--columns` CLI flag or via INI options.
|
|
220
|
+
|
|
221
|
+
### CLI Option
|
|
222
|
+
|
|
223
|
+
Set `COLUMNS` for a single run:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
pytest --columns=180
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### INI Options
|
|
230
|
+
|
|
231
|
+
Enable permanently in `pyproject.toml`:
|
|
232
|
+
|
|
233
|
+
```toml
|
|
234
|
+
[tool.pytest.ini_options]
|
|
235
|
+
set_columns = true # Enable the feature
|
|
236
|
+
columns = 180 # Value to set (default when enabled)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The `--columns` CLI flag overrides the INI `columns` value when both are present.
|
|
240
|
+
|
|
241
|
+
## Configuration Summary
|
|
242
|
+
|
|
243
|
+
All features can be configured via CLI flags, `pyproject.toml` INI options, or (for the debug fixture) per-call arguments.
|
|
244
|
+
|
|
245
|
+
| Feature | Enabled by default | Enable/Disable with |
|
|
246
|
+
| ------------------ | ---------------------- | --------------------------------------------------- |
|
|
247
|
+
| Debug fixture | Output on failure only | N/A (always available) |
|
|
248
|
+
| ANSI stripping | Yes | `--no-strip-ansi` or `strip_ansi = false` |
|
|
249
|
+
| Visible whitespace | Yes | `--no-show-whitespace` or `show_whitespace = false` |
|
|
250
|
+
| Column width | No | `--columns=N` or `set_columns = true` |
|
|
251
|
+
|
|
252
|
+
## AI Policy
|
|
253
|
+
|
|
254
|
+
All AI generated content is and always will be meticulously reviewed and approved by the author.
|
|
255
|
+
|
|
256
|
+
## License
|
|
257
|
+
|
|
258
|
+
MIT
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
authors = [{ name = "Nathaniel Landau", email = "github@natelandau.com" }]
|
|
3
|
+
classifiers = [
|
|
4
|
+
"Framework :: Pytest",
|
|
5
|
+
"Intended Audience :: Developers",
|
|
6
|
+
"Programming Language :: Python :: 3.11",
|
|
7
|
+
"Programming Language :: Python :: 3.12",
|
|
8
|
+
"Programming Language :: Python :: 3.13",
|
|
9
|
+
"Programming Language :: Python :: 3.14",
|
|
10
|
+
"Topic :: Software Development :: Testing",
|
|
11
|
+
]
|
|
12
|
+
dependencies = ["pytest>=9.0.2", "rich>=14.3.2"]
|
|
13
|
+
description = "Pytest plugin providing debug fixtures, ANSI-stripped capsys, whitespace-visible assertions, and terminal column management."
|
|
14
|
+
license = { file = "LICENSE" }
|
|
15
|
+
name = "pytest-devtools"
|
|
16
|
+
readme = "README.md"
|
|
17
|
+
requires-python = ">=3.11"
|
|
18
|
+
version = "1.0.0"
|
|
19
|
+
|
|
20
|
+
[project.entry-points.pytest11]
|
|
21
|
+
pytest-devtools = "devtools.plugin"
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
build-backend = "uv_build"
|
|
25
|
+
requires = ["uv_build>=0.10.0,<0.11.0"]
|
|
26
|
+
|
|
27
|
+
[tool.uv.build-backend]
|
|
28
|
+
module-name = "devtools"
|
|
29
|
+
|
|
30
|
+
[dependency-groups]
|
|
31
|
+
dev = [
|
|
32
|
+
"commitizen>=4.13.7",
|
|
33
|
+
"duty>=1.9.0",
|
|
34
|
+
"prek>=0.3.2",
|
|
35
|
+
"pytest-clarity>=1.0.1",
|
|
36
|
+
"pytest-mock>=3.15.1",
|
|
37
|
+
"pytest-repeat>=0.9.4",
|
|
38
|
+
"pytest-sugar>=1.1.1",
|
|
39
|
+
"pytest-xdist>=3.8.0",
|
|
40
|
+
"ruff>=0.15.0",
|
|
41
|
+
"ty>=0.0.15",
|
|
42
|
+
"typos>=1.43.4",
|
|
43
|
+
"yamllint>=1.38.0",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.commitizen]
|
|
47
|
+
bump_message = "bump(release): v$current_version → v$new_version"
|
|
48
|
+
changelog_merge_prerelease = true
|
|
49
|
+
tag_format = "v$version"
|
|
50
|
+
update_changelog_on_bump = true
|
|
51
|
+
version = "0.1.0"
|
|
52
|
+
version_provider = "uv"
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
|
|
56
|
+
addopts = "--color=yes --doctest-modules --strict-config --strict-markers -n auto --dist loadfile"
|
|
57
|
+
cache_dir = ".cache/pytest"
|
|
58
|
+
filterwarnings = ['error', 'ignore:.*Pydantic.*:UserWarning', 'ignore::DeprecationWarning:']
|
|
59
|
+
markers = ["serial"]
|
|
60
|
+
testpaths = ["tests"]
|
|
61
|
+
xfail_strict = true
|
|
62
|
+
|
|
63
|
+
[tool.ruff] # https://github.com/charliermarsh/ruff
|
|
64
|
+
|
|
65
|
+
exclude = [".cache", ".dev", ".git", ".venv", ".worktrees", "_build", "_site", "build", "dist"]
|
|
66
|
+
fix = true
|
|
67
|
+
line-length = 100
|
|
68
|
+
output-format = "grouped"
|
|
69
|
+
src = ["src", "tests"]
|
|
70
|
+
target-version = "py311"
|
|
71
|
+
[tool.ruff.lint]
|
|
72
|
+
ignore = [
|
|
73
|
+
"ANN002", # Missing type annotation for `*args`
|
|
74
|
+
"ANN003", # Missing type annotation for `**kwargs`
|
|
75
|
+
"ANN204", # missing return type annotation for special method `__init__`
|
|
76
|
+
"ANN401", # Dynamically typed expressions (typing.Any) are disallowed,`
|
|
77
|
+
"B006", # mutable-argument-default
|
|
78
|
+
"B008", # function-call-in-default-argument
|
|
79
|
+
"COM812", # Trailing comma missing"
|
|
80
|
+
"CPY001", # Missing copyright notice at top of file
|
|
81
|
+
"D107", # undocumented-public-init
|
|
82
|
+
"E501", # line-too-long
|
|
83
|
+
"FIX002", # Line contains TODO, consider resolving the issue
|
|
84
|
+
"S311", # suspicious-non-cryptographic-random-usage
|
|
85
|
+
"TD001", # invalid-todo-tag
|
|
86
|
+
"TD002", # Missing author in TODO
|
|
87
|
+
"TD003", # Missing issue link on the line following this TODO
|
|
88
|
+
]
|
|
89
|
+
per-file-ignores = { "tests/**/*.py" = [
|
|
90
|
+
"A002",
|
|
91
|
+
"A003",
|
|
92
|
+
"ANN001", # Missing type annotation for function argument `cls`
|
|
93
|
+
"ANN002", # Missing type annotation for `*args`
|
|
94
|
+
"ANN003", # Missing type annotation for `**kwargs`
|
|
95
|
+
"ANN201", # Missing return type annotation
|
|
96
|
+
"ARG001", # Unused function argument
|
|
97
|
+
"ARG002", # Unused method argument
|
|
98
|
+
"ARG005", # Unused lambda argument
|
|
99
|
+
"D", # Docstring rules relaxed in tests
|
|
100
|
+
"ERA001", # Commented out code
|
|
101
|
+
"F403",
|
|
102
|
+
"F405", # May be undefined from type imports
|
|
103
|
+
"FBT001", # Boolean-typed positional argument in function definition
|
|
104
|
+
"PGH003", # Use specific rule codes when ignoring type issues
|
|
105
|
+
"PLC0415", # Imports should be at top of file
|
|
106
|
+
"PLR0913",
|
|
107
|
+
"PLR2004",
|
|
108
|
+
"PT011", # set the `match` parameter or use a more specific exception
|
|
109
|
+
"S101",
|
|
110
|
+
"SLF001", # Calling private method
|
|
111
|
+
"W292", # No blank line at end of file - included b/c cursor struggles to add a trailing newline and burns through requests trying to fix this linting error.
|
|
112
|
+
] }
|
|
113
|
+
select = ["ALL"]
|
|
114
|
+
unfixable = [
|
|
115
|
+
"ERA001", # Commented out code
|
|
116
|
+
"F401", # unused-import
|
|
117
|
+
"F841", # unused-variable
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
[tool.ruff.lint.mccabe]
|
|
121
|
+
# Unlike Flake8, default to a complexity level of 10.
|
|
122
|
+
max-complexity = 10
|
|
123
|
+
|
|
124
|
+
[tool.ruff.lint.pydocstyle]
|
|
125
|
+
convention = "google"
|
|
126
|
+
|
|
127
|
+
[tool.ruff.lint.pylint]
|
|
128
|
+
max-args = 6
|
|
129
|
+
|
|
130
|
+
[tool.ruff.format]
|
|
131
|
+
indent-style = "space"
|
|
132
|
+
line-ending = "auto"
|
|
133
|
+
quote-style = "double"
|
|
134
|
+
skip-magic-trailing-comma = false
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""pytest-devtools package."""
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""ANSI escape sequence stripping for capsys captured output.
|
|
2
|
+
|
|
3
|
+
Override pytest's built-in capsys fixture to automatically strip ANSI escape
|
|
4
|
+
sequences from captured stdout and stderr, making assertions against captured
|
|
5
|
+
output simpler and more reliable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
from _pytest.capture import CaptureFixture, CaptureResult
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from _pytest.config import Config
|
|
18
|
+
|
|
19
|
+
ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def strip_ansi(text: str) -> str:
|
|
23
|
+
"""Remove ANSI escape sequences from a string.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
text: The string potentially containing ANSI escape sequences.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
The string with all ANSI SGR sequences removed.
|
|
30
|
+
"""
|
|
31
|
+
return ANSI_PATTERN.sub("", text)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def add_options(parser: pytest.Parser) -> None:
|
|
35
|
+
"""Register CLI options for ANSI stripping.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
parser: The pytest option parser to add options to.
|
|
39
|
+
"""
|
|
40
|
+
group = parser.getgroup("devtools-capsys", "ANSI stripping for capsys")
|
|
41
|
+
group.addoption(
|
|
42
|
+
"--no-strip-ansi",
|
|
43
|
+
action="store_true",
|
|
44
|
+
default=False,
|
|
45
|
+
help="Disable automatic ANSI escape sequence stripping from capsys output",
|
|
46
|
+
)
|
|
47
|
+
parser.addini(
|
|
48
|
+
"strip_ansi",
|
|
49
|
+
type="bool",
|
|
50
|
+
default=True,
|
|
51
|
+
help="Enable/disable ANSI stripping from capsys output (default: true)",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def configure(config: Config) -> None:
|
|
56
|
+
"""Register the keep_ansi marker.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
config: The pytest config object.
|
|
60
|
+
"""
|
|
61
|
+
config.addinivalue_line(
|
|
62
|
+
"markers",
|
|
63
|
+
"keep_ansi: disable ANSI stripping from capsys for this test",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _should_strip(request: pytest.FixtureRequest) -> bool:
|
|
68
|
+
"""Determine whether ANSI stripping should be applied for this test.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
request: The pytest fixture request object.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
True if ANSI codes should be stripped, False otherwise.
|
|
75
|
+
"""
|
|
76
|
+
if request.config.getoption("no_strip_ansi", default=False):
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
if not request.config.getini("strip_ansi"):
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
return not request.node.get_closest_marker("keep_ansi")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class StrippedCaptureFixture:
|
|
86
|
+
"""Wrapper around CaptureFixture that strips ANSI from readouterr() results.
|
|
87
|
+
|
|
88
|
+
Delegate all attribute access to the underlying CaptureFixture, intercepting
|
|
89
|
+
only readouterr() to strip ANSI escape sequences.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, original: CaptureFixture[str]) -> None:
|
|
93
|
+
self._original = original
|
|
94
|
+
|
|
95
|
+
def readouterr(self) -> CaptureResult[str]:
|
|
96
|
+
"""Read and strip ANSI from captured output.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
CaptureResult with ANSI sequences removed from both out and err.
|
|
100
|
+
"""
|
|
101
|
+
result = self._original.readouterr()
|
|
102
|
+
return CaptureResult(
|
|
103
|
+
out=strip_ansi(result.out),
|
|
104
|
+
err=strip_ansi(result.err),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def __getattr__(self, name: str) -> Any:
|
|
108
|
+
"""Delegate all other attribute access to the original fixture.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
name: The attribute name to look up.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
The attribute from the original CaptureFixture.
|
|
115
|
+
"""
|
|
116
|
+
return getattr(self._original, name)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@pytest.fixture
|
|
120
|
+
def capsys(
|
|
121
|
+
request: pytest.FixtureRequest,
|
|
122
|
+
capsys: CaptureFixture[str],
|
|
123
|
+
) -> CaptureFixture[str] | StrippedCaptureFixture:
|
|
124
|
+
"""Override built-in capsys to optionally strip ANSI escape sequences.
|
|
125
|
+
|
|
126
|
+
When ANSI stripping is enabled (the default), wrap the original capsys
|
|
127
|
+
fixture so that readouterr() returns output with ANSI codes removed.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
request: The pytest fixture request object.
|
|
131
|
+
capsys: The original pytest capsys fixture.
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Either the original capsys or a wrapped version that strips ANSI codes.
|
|
135
|
+
"""
|
|
136
|
+
if _should_strip(request):
|
|
137
|
+
return StrippedCaptureFixture(capsys)
|
|
138
|
+
return capsys
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Set COLUMNS environment variable for test runs.
|
|
2
|
+
|
|
3
|
+
Prevent code under test (including Rich-based applications) from detecting
|
|
4
|
+
a narrow terminal width and introducing unwanted line wraps in captured output.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from _pytest.config import Config
|
|
15
|
+
|
|
16
|
+
DEFAULT_COLUMNS = 180
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def add_options(parser: pytest.Parser) -> None:
|
|
20
|
+
"""Register CLI options for terminal column width management.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
parser: The pytest option parser to add options to.
|
|
24
|
+
"""
|
|
25
|
+
group = parser.getgroup("devtools-columns", "Terminal column width")
|
|
26
|
+
group.addoption(
|
|
27
|
+
"--columns",
|
|
28
|
+
action="store",
|
|
29
|
+
type=int,
|
|
30
|
+
default=None,
|
|
31
|
+
help=f"Set COLUMNS environment variable to this value (default: {DEFAULT_COLUMNS})",
|
|
32
|
+
)
|
|
33
|
+
parser.addini(
|
|
34
|
+
"set_columns",
|
|
35
|
+
type="bool",
|
|
36
|
+
default=False,
|
|
37
|
+
help="Enable setting COLUMNS environment variable (default: false)",
|
|
38
|
+
)
|
|
39
|
+
parser.addini(
|
|
40
|
+
"columns",
|
|
41
|
+
default=str(DEFAULT_COLUMNS),
|
|
42
|
+
help=f"Value for COLUMNS environment variable (default: {DEFAULT_COLUMNS})",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get_columns_value(config: Config) -> int | None:
|
|
47
|
+
"""Determine the COLUMNS value from CLI and ini options.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
config: The pytest config object.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
The column width to set, or None if columns should not be set.
|
|
54
|
+
"""
|
|
55
|
+
cli_value: int | None = config.getoption("columns", default=None)
|
|
56
|
+
if cli_value is not None:
|
|
57
|
+
return cli_value
|
|
58
|
+
|
|
59
|
+
if not config.getini("set_columns"):
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
ini_value: str = config.getini("columns")
|
|
63
|
+
return int(ini_value)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.fixture(autouse=True)
|
|
67
|
+
def _set_columns(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
68
|
+
"""Set COLUMNS environment variable for each test.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
request: The pytest fixture request object.
|
|
72
|
+
monkeypatch: The pytest monkeypatch fixture.
|
|
73
|
+
"""
|
|
74
|
+
columns = _get_columns_value(request.config)
|
|
75
|
+
if columns is not None:
|
|
76
|
+
monkeypatch.setenv("COLUMNS", str(columns))
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Debug fixture for pretty-printing variables using Rich.
|
|
2
|
+
|
|
3
|
+
Provide a callable fixture that collects Rich-formatted debug output during
|
|
4
|
+
a test and displays it on failure (or always with --print-debug).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import sys
|
|
11
|
+
from io import StringIO
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Generator
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
from rich.pretty import pretty_repr
|
|
21
|
+
from rich.tree import Tree
|
|
22
|
+
|
|
23
|
+
phase_report_key = pytest.StashKey[dict[str, pytest.CollectReport]]()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def add_options(parser: pytest.Parser) -> None:
|
|
27
|
+
"""Register CLI options for the debug fixture.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
parser: The pytest option parser to add options to.
|
|
31
|
+
"""
|
|
32
|
+
group = parser.getgroup("devtools-debug", "Debug fixture options")
|
|
33
|
+
group.addoption(
|
|
34
|
+
"--print-debug",
|
|
35
|
+
action="store_true",
|
|
36
|
+
default=False,
|
|
37
|
+
help="Always show debug output, not just on test failure",
|
|
38
|
+
)
|
|
39
|
+
group.addoption(
|
|
40
|
+
"--debug-strip-tmp-path",
|
|
41
|
+
action="store_true",
|
|
42
|
+
default=None,
|
|
43
|
+
dest="debug_strip_tmp_path",
|
|
44
|
+
help="Strip tmp_path prefix from Path objects (default: true)",
|
|
45
|
+
)
|
|
46
|
+
group.addoption(
|
|
47
|
+
"--no-debug-strip-tmp-path",
|
|
48
|
+
action="store_false",
|
|
49
|
+
dest="debug_strip_tmp_path",
|
|
50
|
+
help="Don't strip tmp_path prefix from Path objects",
|
|
51
|
+
)
|
|
52
|
+
group.addoption(
|
|
53
|
+
"--debug-list-dir-contents",
|
|
54
|
+
action="store_true",
|
|
55
|
+
default=None,
|
|
56
|
+
dest="debug_list_dir_contents",
|
|
57
|
+
help="List directory contents for Path directories (default: false)",
|
|
58
|
+
)
|
|
59
|
+
group.addoption(
|
|
60
|
+
"--no-debug-list-dir-contents",
|
|
61
|
+
action="store_false",
|
|
62
|
+
dest="debug_list_dir_contents",
|
|
63
|
+
help="Don't list directory contents",
|
|
64
|
+
)
|
|
65
|
+
group.addoption(
|
|
66
|
+
"--debug-max-depth",
|
|
67
|
+
action="store",
|
|
68
|
+
type=int,
|
|
69
|
+
default=None,
|
|
70
|
+
help="Max nesting depth for debug pretty-printing",
|
|
71
|
+
)
|
|
72
|
+
group.addoption(
|
|
73
|
+
"--debug-max-length",
|
|
74
|
+
action="store",
|
|
75
|
+
type=int,
|
|
76
|
+
default=None,
|
|
77
|
+
help="Max collection length for debug pretty-printing",
|
|
78
|
+
)
|
|
79
|
+
group.addoption(
|
|
80
|
+
"--debug-show-type",
|
|
81
|
+
action="store_true",
|
|
82
|
+
default=None,
|
|
83
|
+
dest="debug_show_type",
|
|
84
|
+
help="Show type annotations in debug output",
|
|
85
|
+
)
|
|
86
|
+
group.addoption(
|
|
87
|
+
"--no-debug-show-type",
|
|
88
|
+
action="store_false",
|
|
89
|
+
dest="debug_show_type",
|
|
90
|
+
help="Don't show type annotations in debug output",
|
|
91
|
+
)
|
|
92
|
+
parser.addini("print_debug", type="bool", default=False, help="Always show debug output")
|
|
93
|
+
parser.addini("debug_strip_tmp_path", type="bool", default=True, help="Strip tmp_path prefix")
|
|
94
|
+
parser.addini("debug_list_dir_contents", type="bool", default=False, help="List dir contents")
|
|
95
|
+
parser.addini("debug_max_depth", default="", help="Max nesting depth")
|
|
96
|
+
parser.addini("debug_max_length", default="", help="Max collection length")
|
|
97
|
+
parser.addini("debug_show_type", type="bool", default=False, help="Show type annotations")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _resolve_option(
|
|
101
|
+
request: pytest.FixtureRequest,
|
|
102
|
+
name: str,
|
|
103
|
+
per_call: bool | int | None, # noqa: FBT001
|
|
104
|
+
) -> Any:
|
|
105
|
+
"""Resolve a config option with per-call override.
|
|
106
|
+
|
|
107
|
+
Per-call value takes precedence, then CLI flag, then ini option.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
request: The pytest fixture request.
|
|
111
|
+
name: The option name (matching both getoption dest and ini key).
|
|
112
|
+
per_call: The per-call override value, or None to use global default.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
The resolved option value.
|
|
116
|
+
"""
|
|
117
|
+
if per_call is not None:
|
|
118
|
+
return per_call
|
|
119
|
+
|
|
120
|
+
cli_value = request.config.getoption(name, default=None)
|
|
121
|
+
if cli_value is not None:
|
|
122
|
+
return cli_value
|
|
123
|
+
|
|
124
|
+
return request.config.getini(name)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _build_dir_tree(path: Path, base_name: str) -> Tree:
|
|
128
|
+
"""Build a Rich Tree showing directory contents.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
path: The directory path to list.
|
|
132
|
+
base_name: The display name for the root of the tree.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
A Rich Tree object representing the directory structure.
|
|
136
|
+
"""
|
|
137
|
+
tree = Tree(f"{base_name}/")
|
|
138
|
+
for child in sorted(path.iterdir()):
|
|
139
|
+
if child.is_dir():
|
|
140
|
+
subtree = _build_dir_tree(child, child.name)
|
|
141
|
+
tree.add(subtree)
|
|
142
|
+
else:
|
|
143
|
+
tree.add(child.name)
|
|
144
|
+
return tree
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class DebugPrinter:
|
|
148
|
+
"""Callable debug printer that collects Rich-formatted output.
|
|
149
|
+
|
|
150
|
+
Collect formatted output during a test and flush it to stderr on
|
|
151
|
+
demand (on test failure or when --print-debug is active).
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def __init__(self, request: pytest.FixtureRequest) -> None:
|
|
155
|
+
self._request = request
|
|
156
|
+
self._entries: list[str] = []
|
|
157
|
+
|
|
158
|
+
def __call__(
|
|
159
|
+
self,
|
|
160
|
+
*args: Any,
|
|
161
|
+
title: str | None = None,
|
|
162
|
+
strip_tmp_path: bool | None = None,
|
|
163
|
+
list_dir_contents: bool | None = None,
|
|
164
|
+
max_depth: int | None = None,
|
|
165
|
+
max_length: int | None = None,
|
|
166
|
+
show_type: bool | None = None,
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Pretty-print one or more values and store the output.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
*args: Objects to pretty-print.
|
|
172
|
+
title: Optional title for the Console.rule() separators.
|
|
173
|
+
strip_tmp_path: Override global strip_tmp_path setting.
|
|
174
|
+
list_dir_contents: Override global list_dir_contents setting.
|
|
175
|
+
max_depth: Override global max_depth setting.
|
|
176
|
+
max_length: Override global max_length setting.
|
|
177
|
+
show_type: Override global show_type setting.
|
|
178
|
+
"""
|
|
179
|
+
resolved_strip = _resolve_option(self._request, "debug_strip_tmp_path", strip_tmp_path)
|
|
180
|
+
resolved_list_dir = _resolve_option(
|
|
181
|
+
self._request, "debug_list_dir_contents", list_dir_contents
|
|
182
|
+
)
|
|
183
|
+
resolved_depth_raw = _resolve_option(self._request, "debug_max_depth", max_depth)
|
|
184
|
+
resolved_length_raw = _resolve_option(self._request, "debug_max_length", max_length)
|
|
185
|
+
resolved_show_type = _resolve_option(self._request, "debug_show_type", show_type)
|
|
186
|
+
|
|
187
|
+
resolved_depth = int(resolved_depth_raw) if resolved_depth_raw else None
|
|
188
|
+
resolved_length = int(resolved_length_raw) if resolved_length_raw else None
|
|
189
|
+
|
|
190
|
+
tmp_path: Path | None = None
|
|
191
|
+
if resolved_strip:
|
|
192
|
+
with contextlib.suppress(pytest.FixtureLookupError):
|
|
193
|
+
tmp_path = self._request.getfixturevalue("tmp_path")
|
|
194
|
+
|
|
195
|
+
buf = StringIO()
|
|
196
|
+
console = Console(file=buf, force_terminal=True)
|
|
197
|
+
|
|
198
|
+
rule_title = title or "Debug"
|
|
199
|
+
console.rule(rule_title)
|
|
200
|
+
|
|
201
|
+
for arg in args:
|
|
202
|
+
if resolved_show_type:
|
|
203
|
+
console.print(f"[dim]({type(arg).__name__})[/dim]")
|
|
204
|
+
|
|
205
|
+
if isinstance(arg, Path):
|
|
206
|
+
display_path = arg
|
|
207
|
+
if tmp_path and resolved_strip:
|
|
208
|
+
with contextlib.suppress(ValueError):
|
|
209
|
+
display_path = arg.relative_to(tmp_path)
|
|
210
|
+
|
|
211
|
+
if arg.is_dir() and resolved_list_dir:
|
|
212
|
+
dir_tree = _build_dir_tree(arg, str(display_path))
|
|
213
|
+
console.print(dir_tree)
|
|
214
|
+
else:
|
|
215
|
+
console.print(str(display_path))
|
|
216
|
+
else:
|
|
217
|
+
formatted = pretty_repr(
|
|
218
|
+
arg,
|
|
219
|
+
max_depth=resolved_depth,
|
|
220
|
+
max_length=resolved_length,
|
|
221
|
+
)
|
|
222
|
+
console.print(formatted)
|
|
223
|
+
|
|
224
|
+
closing_title = f"/{title}" if title else "Debug"
|
|
225
|
+
console.rule(closing_title)
|
|
226
|
+
|
|
227
|
+
self._entries.append(buf.getvalue())
|
|
228
|
+
|
|
229
|
+
def flush(self) -> None:
|
|
230
|
+
"""Write all collected debug entries to stderr."""
|
|
231
|
+
if self._entries:
|
|
232
|
+
output = "\n".join(self._entries)
|
|
233
|
+
sys.stderr.write(output)
|
|
234
|
+
sys.stderr.flush()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@pytest.fixture
|
|
238
|
+
def debug(request: pytest.FixtureRequest) -> Generator[DebugPrinter, None, None]:
|
|
239
|
+
"""Provide a callable debug printer for pretty-printing variables.
|
|
240
|
+
|
|
241
|
+
Collect Rich-formatted output during the test. On teardown, flush
|
|
242
|
+
the output to stderr if the test failed or --print-debug is active.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
request: The pytest fixture request.
|
|
246
|
+
|
|
247
|
+
Yields:
|
|
248
|
+
A callable DebugPrinter instance.
|
|
249
|
+
"""
|
|
250
|
+
printer = DebugPrinter(request)
|
|
251
|
+
yield printer
|
|
252
|
+
|
|
253
|
+
always_print = request.config.getoption("print_debug", default=False) or request.config.getini(
|
|
254
|
+
"print_debug"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
if always_print:
|
|
258
|
+
printer.flush()
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
report = request.node.stash.get(phase_report_key, {})
|
|
262
|
+
if report.get("call") and report["call"].failed:
|
|
263
|
+
printer.flush()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Central hook registration for pytest-devtools plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Generator
|
|
11
|
+
|
|
12
|
+
from devtools import capsys_strip, columns, debug_fixture, whitespace
|
|
13
|
+
from devtools.capsys_strip import capsys as capsys # noqa: PLC0414
|
|
14
|
+
from devtools.columns import _set_columns as _set_columns # noqa: PLC0414
|
|
15
|
+
from devtools.debug_fixture import debug as debug # noqa: PLC0414
|
|
16
|
+
from devtools.debug_fixture import phase_report_key
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
20
|
+
"""Register CLI options for all pytest-devtools features."""
|
|
21
|
+
columns.add_options(parser)
|
|
22
|
+
capsys_strip.add_options(parser)
|
|
23
|
+
whitespace.add_options(parser)
|
|
24
|
+
debug_fixture.add_options(parser)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
28
|
+
"""Register markers for all pytest-devtools features."""
|
|
29
|
+
capsys_strip.configure(config)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.hookimpl(wrapper=True, tryfirst=True)
|
|
33
|
+
def pytest_runtest_makereport(
|
|
34
|
+
item: pytest.Item,
|
|
35
|
+
call: pytest.CallInfo[None], # noqa: ARG001
|
|
36
|
+
) -> Generator[None, pytest.TestReport, pytest.TestReport]:
|
|
37
|
+
"""Store test phase reports for debug fixture teardown access.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
item: The test item.
|
|
41
|
+
call: The call information.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
The test report.
|
|
45
|
+
"""
|
|
46
|
+
rep = yield
|
|
47
|
+
item.stash.setdefault(phase_report_key, {})[rep.when] = rep
|
|
48
|
+
return rep
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def pytest_assertrepr_compare(
|
|
52
|
+
config: pytest.Config,
|
|
53
|
+
op: str,
|
|
54
|
+
left: object,
|
|
55
|
+
right: object,
|
|
56
|
+
) -> list[str] | None:
|
|
57
|
+
"""Delegate assertion comparison to whitespace module.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
config: The pytest config object.
|
|
61
|
+
op: The comparison operator.
|
|
62
|
+
left: The left operand.
|
|
63
|
+
right: The right operand.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Custom explanation lines or None for default behavior.
|
|
67
|
+
"""
|
|
68
|
+
return whitespace.pytest_assertrepr_compare(config, op, left, right)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Make invisible whitespace characters visible in assertion failure diffs.
|
|
2
|
+
|
|
3
|
+
Replace tabs, carriage returns, trailing spaces, and newlines with visible
|
|
4
|
+
Unicode symbols so that whitespace-only differences in string comparisons
|
|
5
|
+
are immediately obvious in pytest assertion output.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
TRAILING_SPACES_PATTERN = re.compile(r" +$", re.MULTILINE)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def add_options(parser: pytest.Parser) -> None:
|
|
20
|
+
"""Register CLI options for whitespace visibility.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
parser: The pytest option parser to add options to.
|
|
24
|
+
"""
|
|
25
|
+
group = parser.getgroup("devtools-whitespace", "Whitespace visibility in assertions")
|
|
26
|
+
group.addoption(
|
|
27
|
+
"--no-show-whitespace",
|
|
28
|
+
action="store_true",
|
|
29
|
+
default=False,
|
|
30
|
+
help="Disable visible whitespace symbols in assertion failure diffs",
|
|
31
|
+
)
|
|
32
|
+
parser.addini(
|
|
33
|
+
"show_whitespace",
|
|
34
|
+
type="bool",
|
|
35
|
+
default=True,
|
|
36
|
+
help="Enable/disable whitespace visibility in assertion diffs (default: true)",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_enabled(config: pytest.Config) -> bool:
|
|
41
|
+
"""Check whether whitespace visibility is enabled.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
config: The pytest config object.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
True if whitespace visibility is enabled.
|
|
48
|
+
"""
|
|
49
|
+
if config.getoption("no_show_whitespace", default=False):
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
return config.getini("show_whitespace")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def make_whitespace_visible(text: str) -> str:
|
|
56
|
+
"""Replace invisible whitespace characters with visible symbols.
|
|
57
|
+
|
|
58
|
+
Replace tabs with arrows, carriage returns with left arrows, trailing
|
|
59
|
+
spaces with middle dots, and newlines with return symbols.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
text: The string in which to make whitespace visible.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
The string with whitespace characters replaced by visible symbols.
|
|
66
|
+
"""
|
|
67
|
+
text = text.replace("\t", "\u2192")
|
|
68
|
+
text = text.replace("\r", "\u2190")
|
|
69
|
+
text = TRAILING_SPACES_PATTERN.sub(lambda m: "\u00b7" * len(m.group()), text)
|
|
70
|
+
return text.replace("\n", "\u21b5\n")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def pytest_assertrepr_compare(
|
|
74
|
+
config: pytest.Config,
|
|
75
|
+
op: str,
|
|
76
|
+
left: object,
|
|
77
|
+
right: object,
|
|
78
|
+
) -> list[str] | None:
|
|
79
|
+
"""Provide custom assertion failure output with visible whitespace.
|
|
80
|
+
|
|
81
|
+
When comparing strings with ==, generate comparison lines that show
|
|
82
|
+
invisible whitespace as visible Unicode symbols.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
config: The pytest config object.
|
|
86
|
+
op: The comparison operator (e.g. "==").
|
|
87
|
+
left: The left operand.
|
|
88
|
+
right: The right operand.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
A list of explanation strings, or None to use default behavior.
|
|
92
|
+
"""
|
|
93
|
+
if not _is_enabled(config):
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
if op != "==" or not isinstance(left, str) or not isinstance(right, str):
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
visible_left = make_whitespace_visible(left)
|
|
100
|
+
visible_right = make_whitespace_visible(right)
|
|
101
|
+
|
|
102
|
+
# Only activate if whitespace replacement actually changed something
|
|
103
|
+
if visible_left == left and visible_right == right:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
return [
|
|
107
|
+
f"'{visible_left}' == '{visible_right}'",
|
|
108
|
+
"",
|
|
109
|
+
"Whitespace-visible comparison:",
|
|
110
|
+
f" Left: '{visible_left}'",
|
|
111
|
+
f" Right: '{visible_right}'",
|
|
112
|
+
]
|