testsweet 0.2.2__tar.gz → 0.2.3__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.
- {testsweet-0.2.2 → testsweet-0.2.3}/CHANGELOG.md +15 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/PKG-INFO +1 -1
- {testsweet-0.2.2 → testsweet-0.2.3}/docs/getting-started.md +91 -40
- {testsweet-0.2.2 → testsweet-0.2.3}/pyproject.toml +1 -1
- {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/__init__.py +2 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/__main__.py +65 -12
- {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_assertion.py +14 -6
- {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_outcomes.py +16 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_report.py +68 -22
- {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_runner.py +41 -18
- {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_tag.py +0 -2
- testsweet-0.2.3/tests/assertion.py +234 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/cli.py +74 -20
- testsweet-0.2.3/tests/fixtures/main/capture_demo.py +13 -0
- testsweet-0.2.3/tests/fixtures/runner/__init__.py +0 -0
- testsweet-0.2.3/tests/main.py +90 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/outcomes.py +29 -0
- testsweet-0.2.3/tests/report.py +287 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/runner.py +169 -91
- {testsweet-0.2.2 → testsweet-0.2.3}/uv.lock +1 -1
- testsweet-0.2.2/tests/report.py +0 -151
- {testsweet-0.2.2 → testsweet-0.2.3}/.github/workflows/lint.yml +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/.github/workflows/publish.yml +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/.github/workflows/tests.yml +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/.github/workflows/types.yml +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/.gitignore +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/.pre-commit-config.yaml +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/.python-version +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/CLAUDE.md +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/LICENSE +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/README.md +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/docs/contributing.md +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/docs/examples/catches.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/docs/examples/classes.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/docs/examples/functions.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/docs/img/testsweet_200x200.png +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/docs/reference.md +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_catches.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_class_helpers.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_classify.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_condition_decorator.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_config.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_discover.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_loaders.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_markers.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_params.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_plugins.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_resolve.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_skip.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_tag_filter.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_targets.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_walk.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_xfail.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/py.typed +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/__init__.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/catches.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/config.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/discover.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/__init__.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/empty.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/imported_only.py +0 -0
- {testsweet-0.2.2/tests/fixtures/runner → testsweet-0.2.3/tests/fixtures/main}/__init__.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/mixed.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/mixed_local_imported.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/multiple.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/non_callable_marker.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/all_pass.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/assertion_diagnostics.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_calls_recorded.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_decorated_simple.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_decorated_with_cm.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_enter_only.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_enter_raises.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_exit_raises.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_method_fails.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_mixed_with_function.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_simple.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_test_context_raises.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_test_context_with_params.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_with_inheritance.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_with_test_context.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_with_underscore_methods.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/empty.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/has_broken_import.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/has_failure.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/keyboard_interrupt.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/non_assertion_error.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/orphan_params.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/orphan_skip.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/orphan_tag.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/orphan_xfail.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/params_empty.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/params_generator.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/params_lazy_generator.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/params_lazy_list.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/params_lazy_on_class_method.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/params_no_decoration.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/params_on_class_method.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/params_simple.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/params_with_failure.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/skip_on_class_method.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/skip_on_params.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/tagged_class.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/uses_relative_import.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/xfail_on_params.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/single.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/markers.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/params.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/plugins.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/resolve.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/skip.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/tag.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/tag_filter.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/targets.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/test_unittest_shim.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/walk.py +0 -0
- {testsweet-0.2.2 → testsweet-0.2.3}/tests/xfail.py +0 -0
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
Changelog
|
|
2
2
|
=========
|
|
3
3
|
|
|
4
|
+
[0.2.3] (2026-06-28)
|
|
5
|
+
--------------------
|
|
6
|
+
|
|
7
|
+
### Improvements
|
|
8
|
+
|
|
9
|
+
- Added tests and documentation for the assertion explainer.
|
|
10
|
+
- Added color output for outcomes.
|
|
11
|
+
- Grouped output by module and class.
|
|
12
|
+
- Included timing in the summary.
|
|
13
|
+
- Captured each test's stdout and stderr, replaying them in the
|
|
14
|
+
failure detail block.
|
|
15
|
+
|
|
16
|
+
[0.2.3]: https://github.com/kaapstorm/testsweet/releases/tag/v0.2.3
|
|
17
|
+
|
|
18
|
+
|
|
4
19
|
[0.2.2] (2026-06-27)
|
|
5
20
|
--------------------
|
|
6
21
|
|
|
@@ -138,46 +138,6 @@ Subclasses can chain a parent's `__test_context__` via
|
|
|
138
138
|
`super().__test_context__()`.
|
|
139
139
|
|
|
140
140
|
|
|
141
|
-
Plugins
|
|
142
|
-
-------
|
|
143
|
-
|
|
144
|
-
Testsweet discovers plugins via the `testsweet.plugins` entry-point
|
|
145
|
-
group. A plugin is any module exposing both of:
|
|
146
|
-
|
|
147
|
-
* `session()` — a context manager that wraps the entire test run.
|
|
148
|
-
Use for one-time setup/teardown (e.g. provisioning a test database).
|
|
149
|
-
* `unit(name)` — a context manager that wraps each test call. Use for
|
|
150
|
-
per-test isolation.
|
|
151
|
-
|
|
152
|
-
Both are required. If a plugin doesn't need one, define a no-op:
|
|
153
|
-
|
|
154
|
-
```python
|
|
155
|
-
from contextlib import contextmanager
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
@contextmanager
|
|
159
|
-
def session():
|
|
160
|
-
# ... real setup/teardown ...
|
|
161
|
-
yield
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
@contextmanager
|
|
165
|
-
def unit(name):
|
|
166
|
-
yield
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
Plugins are installed as ordinary Python distributions and register
|
|
170
|
-
themselves in their own `pyproject.toml`:
|
|
171
|
-
|
|
172
|
-
```toml
|
|
173
|
-
[project.entry-points."testsweet.plugins"]
|
|
174
|
-
django = "testsweet_django"
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
See [testsweet-django](https://github.com/kaapstrom/testsweet-django)
|
|
178
|
-
for a working example.
|
|
179
|
-
|
|
180
|
-
|
|
181
141
|
Parametrized tests
|
|
182
142
|
------------------
|
|
183
143
|
|
|
@@ -208,6 +168,57 @@ it consumed at run time, use `@params_lazy` instead.
|
|
|
208
168
|
> functions or methods you want discovered.
|
|
209
169
|
|
|
210
170
|
|
|
171
|
+
Getting the most from explanations
|
|
172
|
+
----------------------------------
|
|
173
|
+
|
|
174
|
+
When an `assert` fails, Testsweet prints the value of each operand in
|
|
175
|
+
the failing expression, so you can often see what went wrong without
|
|
176
|
+
reaching for a debugger:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
@test
|
|
180
|
+
def counts_items():
|
|
181
|
+
items = [1, 2]
|
|
182
|
+
count = len(items)
|
|
183
|
+
assert count == 3
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
======================================================================
|
|
188
|
+
FAIL: tests.example.counts_items
|
|
189
|
+
----------------------------------------------------------------------
|
|
190
|
+
Traceback (most recent call last):
|
|
191
|
+
...
|
|
192
|
+
AssertionError
|
|
193
|
+
count = 2
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
To show those values, Testsweet re-evaluates the operands of the failing
|
|
197
|
+
expression. Running code a second time can have side effects, so to
|
|
198
|
+
make sure that building the explanation never changes how your program
|
|
199
|
+
behaves, Testsweet deliberately **skips function calls, attribute
|
|
200
|
+
access, and subscripts**. Constant literals are skipped too, since they
|
|
201
|
+
carry no information.
|
|
202
|
+
|
|
203
|
+
That means an assertion built directly out of calls, attributes, or
|
|
204
|
+
subscripts has nothing left to explain:
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
assert response.json()['status'] == 200 # no operand values shown
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Bind the interesting values to plain local variables and assert on
|
|
211
|
+
those instead:
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
status = response.json()['status']
|
|
215
|
+
assert status == 200 # explanation: status = 404
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
This is good practice anyway: it keeps each assertion to a single idea
|
|
219
|
+
and makes the failure output read like the question you were asking.
|
|
220
|
+
|
|
221
|
+
|
|
211
222
|
Skipping and expected failures
|
|
212
223
|
------------------------------
|
|
213
224
|
|
|
@@ -374,3 +385,43 @@ test_files = ["test_*.py", "*_test.py"]
|
|
|
374
385
|
```
|
|
375
386
|
|
|
376
387
|
See the [reference](reference.md) for the full list of public APIs.
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
Plugins
|
|
391
|
+
-------
|
|
392
|
+
|
|
393
|
+
Testsweet discovers plugins via the `testsweet.plugins` entry-point
|
|
394
|
+
group. A plugin is any module exposing both of:
|
|
395
|
+
|
|
396
|
+
* `session()` — a context manager that wraps the entire test run.
|
|
397
|
+
Use for one-time setup/teardown (e.g. provisioning a test database).
|
|
398
|
+
* `unit(name)` — a context manager that wraps each test call. Use for
|
|
399
|
+
per-test isolation.
|
|
400
|
+
|
|
401
|
+
Both are required. If a plugin doesn't need one, define a no-op:
|
|
402
|
+
|
|
403
|
+
```python
|
|
404
|
+
from contextlib import contextmanager
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
@contextmanager
|
|
408
|
+
def session():
|
|
409
|
+
# ... real setup/teardown ...
|
|
410
|
+
yield
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@contextmanager
|
|
414
|
+
def unit(name):
|
|
415
|
+
yield
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
Plugins are installed as ordinary Python distributions and register
|
|
419
|
+
themselves in their own `pyproject.toml`:
|
|
420
|
+
|
|
421
|
+
```toml
|
|
422
|
+
[project.entry-points."testsweet.plugins"]
|
|
423
|
+
django = "testsweet_django"
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
See [testsweet-django](https://github.com/kaapstrom/testsweet-django)
|
|
427
|
+
for a working example.
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import argparse
|
|
2
|
+
import os
|
|
2
3
|
import pathlib
|
|
3
4
|
import sys
|
|
5
|
+
import time
|
|
4
6
|
|
|
5
7
|
from testsweet._config import load_config
|
|
6
8
|
from testsweet._loaders import scoped_sys_path
|
|
7
9
|
from testsweet._plugins import load_plugins, session_for, unit_wrapper
|
|
8
|
-
from testsweet._outcomes import Errored, Failed,
|
|
10
|
+
from testsweet._outcomes import Errored, Failed, Result, XPassed
|
|
9
11
|
from testsweet._report import (
|
|
10
12
|
format_result_line,
|
|
11
13
|
print_failure_detail,
|
|
@@ -45,6 +47,31 @@ Options:
|
|
|
45
47
|
"""
|
|
46
48
|
|
|
47
49
|
|
|
50
|
+
def _split_group(name: str) -> tuple[str | None, str]:
|
|
51
|
+
"""Split 'ClassName.method[n]' into ('ClassName', 'method[n]').
|
|
52
|
+
|
|
53
|
+
Returns (None, name) for standalone functions with no class prefix.
|
|
54
|
+
"""
|
|
55
|
+
dot = name.find('.')
|
|
56
|
+
return (name[:dot], name[dot + 1:]) if dot != -1 else (None, name)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _supports_color() -> bool:
|
|
60
|
+
if not sys.stdout.isatty():
|
|
61
|
+
return False
|
|
62
|
+
if os.environ.get('NO_COLOR'):
|
|
63
|
+
return False
|
|
64
|
+
if sys.platform == 'win32':
|
|
65
|
+
# VT processing is auto-enabled from Python 3.12+. On older
|
|
66
|
+
# versions, accept Windows Terminal (WT_SESSION) and ANSICON.
|
|
67
|
+
return (
|
|
68
|
+
sys.version_info >= (3, 12)
|
|
69
|
+
or bool(os.environ.get('WT_SESSION'))
|
|
70
|
+
or bool(os.environ.get('ANSICON'))
|
|
71
|
+
)
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
|
|
48
75
|
def _build_parser() -> argparse.ArgumentParser:
|
|
49
76
|
parser = argparse.ArgumentParser(add_help=False)
|
|
50
77
|
parser.add_argument(
|
|
@@ -94,25 +121,51 @@ def main(argv: list[str]) -> int:
|
|
|
94
121
|
config = load_config(pathlib.Path.cwd())
|
|
95
122
|
plugins = load_plugins()
|
|
96
123
|
wrap_unit = unit_wrapper(plugins)
|
|
97
|
-
|
|
98
|
-
|
|
124
|
+
use_color = _supports_color()
|
|
125
|
+
results: list[Result] = []
|
|
126
|
+
real_failures: list[tuple[str, Result]] = []
|
|
127
|
+
start = time.monotonic()
|
|
99
128
|
with session_for(plugins):
|
|
100
129
|
groups = discover_targets(args.targets, config)
|
|
130
|
+
last_module: str | None = None
|
|
131
|
+
last_class: str | None = None
|
|
101
132
|
for module, names in groups:
|
|
102
|
-
for
|
|
133
|
+
for result in run(
|
|
103
134
|
module,
|
|
104
135
|
names=names,
|
|
105
136
|
wrap_unit=wrap_unit,
|
|
106
137
|
keep=keep,
|
|
107
138
|
):
|
|
108
|
-
full_name = f'{module.__name__}.{name}'
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
139
|
+
full_name = f'{module.__name__}.{result.name}'
|
|
140
|
+
class_name, short_name = _split_group(result.name)
|
|
141
|
+
if module.__name__ != last_module:
|
|
142
|
+
print(module.__name__)
|
|
143
|
+
last_module = module.__name__
|
|
144
|
+
last_class = None
|
|
145
|
+
if class_name != last_class:
|
|
146
|
+
if class_name is not None:
|
|
147
|
+
print(f' {class_name}')
|
|
148
|
+
last_class = class_name
|
|
149
|
+
indent = ' ' if class_name else ' '
|
|
150
|
+
result_line = format_result_line(
|
|
151
|
+
short_name,
|
|
152
|
+
result.outcome,
|
|
153
|
+
use_color=use_color,
|
|
154
|
+
)
|
|
155
|
+
print(f'{indent}{result_line}')
|
|
156
|
+
results.append(result)
|
|
157
|
+
if isinstance(result.outcome, (Failed, Errored, XPassed)):
|
|
158
|
+
real_failures.append((full_name, result))
|
|
159
|
+
elapsed = time.monotonic() - start
|
|
160
|
+
for full_name, result in real_failures:
|
|
161
|
+
print_failure_detail(
|
|
162
|
+
full_name,
|
|
163
|
+
result.outcome,
|
|
164
|
+
result.stdout,
|
|
165
|
+
result.stderr,
|
|
166
|
+
)
|
|
167
|
+
print()
|
|
168
|
+
print(summarize(results, use_color=use_color, elapsed=elapsed))
|
|
116
169
|
return 1 if real_failures else 0
|
|
117
170
|
|
|
118
171
|
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Reads the source file referenced by the failing assertion's traceback,
|
|
4
4
|
locates the ``assert`` AST node, and re-evaluates its sub-expressions
|
|
5
|
-
in the original frame to show their values.
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
in the original frame to show their values. Sub-expressions that can
|
|
6
|
+
fire side effects when re-evaluated are deliberately skipped: calls,
|
|
7
|
+
attribute access (``__getattr__``/properties), and subscripting
|
|
8
|
+
(``__getitem__``).
|
|
8
9
|
|
|
9
10
|
Failures (missing source, syntax errors, eval errors) silently yield
|
|
10
11
|
``None`` — the explainer is a nicety, not a correctness requirement.
|
|
@@ -30,9 +31,16 @@ def explain_assertion(exc: AssertionError) -> str | None:
|
|
|
30
31
|
lines = []
|
|
31
32
|
seen: set[str] = set()
|
|
32
33
|
for sub in _sub_exprs(assert_node.test):
|
|
33
|
-
if isinstance(sub, (
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
if isinstance(sub, (
|
|
35
|
+
ast.Constant,
|
|
36
|
+
ast.Call,
|
|
37
|
+
ast.Attribute,
|
|
38
|
+
ast.Subscript,
|
|
39
|
+
)):
|
|
40
|
+
# Skip constants (no information) and anything that can
|
|
41
|
+
# fire side effects when re-evaluated: calls, attribute
|
|
42
|
+
# access (``__getattr__``/properties), and subscripting
|
|
43
|
+
# (``__getitem__``).
|
|
36
44
|
continue
|
|
37
45
|
src = ast.unparse(sub)
|
|
38
46
|
if src in seen:
|
|
@@ -59,3 +59,19 @@ class XPassed:
|
|
|
59
59
|
|
|
60
60
|
|
|
61
61
|
Outcome = Passed | Failed | Errored | Skipped | XFailed | XPassed
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class Result:
|
|
66
|
+
"""A test unit's outcome plus any output it produced.
|
|
67
|
+
|
|
68
|
+
``run()`` returns ``list[Result]``. ``stdout`` and ``stderr`` hold
|
|
69
|
+
the test's captured streams; they are empty unless the unit wrote
|
|
70
|
+
to those streams. Captured output is replayed only when the test
|
|
71
|
+
fails — see ``_report.print_failure_detail``.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
name: str
|
|
75
|
+
outcome: Outcome
|
|
76
|
+
stdout: str = ''
|
|
77
|
+
stderr: str = ''
|
|
@@ -14,11 +14,22 @@ from testsweet._outcomes import (
|
|
|
14
14
|
Failed,
|
|
15
15
|
Outcome,
|
|
16
16
|
Passed,
|
|
17
|
+
Result,
|
|
17
18
|
Skipped,
|
|
18
19
|
XFailed,
|
|
19
20
|
XPassed,
|
|
20
21
|
)
|
|
21
22
|
|
|
23
|
+
_GREEN = '32'
|
|
24
|
+
_YELLOW = '33'
|
|
25
|
+
_RED = '31'
|
|
26
|
+
_BOLD_RED = '1;31'
|
|
27
|
+
_MAGENTA = '35'
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _c(text: str, code: str, enabled: bool) -> str:
|
|
31
|
+
return f'\033[{code}m{text}\033[0m' if enabled else text
|
|
32
|
+
|
|
22
33
|
|
|
23
34
|
_XPASS_DETAIL = (
|
|
24
35
|
'Test was marked @xfail but passed. '
|
|
@@ -30,36 +41,63 @@ def _suffix(reason: str | None) -> str:
|
|
|
30
41
|
return f': {reason}' if reason else ''
|
|
31
42
|
|
|
32
43
|
|
|
33
|
-
def format_result_line(
|
|
44
|
+
def format_result_line(
|
|
45
|
+
full_name: str,
|
|
46
|
+
outcome: Outcome,
|
|
47
|
+
use_color: bool = False,
|
|
48
|
+
) -> str:
|
|
34
49
|
"""One-line summary suitable for streaming output."""
|
|
35
50
|
match outcome:
|
|
36
51
|
case Passed():
|
|
37
|
-
|
|
52
|
+
status = _c('ok', _GREEN, use_color)
|
|
53
|
+
return f'{full_name} ... {status}'
|
|
38
54
|
case Skipped(reason=reason):
|
|
39
|
-
|
|
55
|
+
status = _c(f'skipped{_suffix(reason)}', _YELLOW, use_color)
|
|
56
|
+
return f'{full_name} ... {status}'
|
|
40
57
|
case XFailed(reason=reason):
|
|
41
|
-
|
|
58
|
+
status = _c(f'xfailed{_suffix(reason)}', _YELLOW, use_color)
|
|
59
|
+
return f'{full_name} ... {status}'
|
|
42
60
|
case XPassed(reason=reason):
|
|
43
|
-
|
|
61
|
+
status = _c(f'XPASSED{_suffix(reason)}', _MAGENTA, use_color)
|
|
62
|
+
return f'{full_name} ... {status}'
|
|
44
63
|
case Failed(exc=exc):
|
|
45
64
|
detail = str(exc) or assertion_source(exc) or ''
|
|
46
|
-
|
|
65
|
+
status = _c(
|
|
66
|
+
f'FAIL: AssertionError: {detail}',
|
|
67
|
+
_BOLD_RED,
|
|
68
|
+
use_color,
|
|
69
|
+
)
|
|
70
|
+
return f'{full_name} ... {status}'
|
|
47
71
|
case Errored(exc=exc):
|
|
48
|
-
|
|
49
|
-
f'
|
|
72
|
+
status = _c(
|
|
73
|
+
f'ERROR: {type(exc).__name__}: {exc}', _RED, use_color,
|
|
50
74
|
)
|
|
75
|
+
return f'{full_name} ... {status}'
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _print_captured(stdout: str, stderr: str, file: TextIO) -> None:
|
|
79
|
+
if stdout:
|
|
80
|
+
print('-' * 26 + ' Captured stdout ' + '-' * 27, file=file)
|
|
81
|
+
print(stdout, end='' if stdout.endswith('\n') else '\n', file=file)
|
|
82
|
+
if stderr:
|
|
83
|
+
print('-' * 26 + ' Captured stderr ' + '-' * 27, file=file)
|
|
84
|
+
print(stderr, end='' if stderr.endswith('\n') else '\n', file=file)
|
|
51
85
|
|
|
52
86
|
|
|
53
87
|
def print_failure_detail(
|
|
54
88
|
full_name: str,
|
|
55
89
|
outcome: Outcome,
|
|
56
|
-
|
|
90
|
+
stdout: str = '',
|
|
91
|
+
stderr: str = '',
|
|
92
|
+
file: TextIO | None = None,
|
|
57
93
|
) -> None:
|
|
58
94
|
"""Multi-line failure block.
|
|
59
95
|
|
|
60
96
|
Fires only for ``Failed``, ``Errored``, and ``XPassed``. Other
|
|
61
97
|
outcomes do not get a detail block.
|
|
62
98
|
"""
|
|
99
|
+
if file is None:
|
|
100
|
+
file = sys.stdout
|
|
63
101
|
match outcome:
|
|
64
102
|
case Passed() | Skipped() | XFailed():
|
|
65
103
|
return
|
|
@@ -69,14 +107,17 @@ def print_failure_detail(
|
|
|
69
107
|
print(f'XPASSED: {full_name}', file=file)
|
|
70
108
|
print('-' * 70, file=file)
|
|
71
109
|
print(_XPASS_DETAIL, file=file)
|
|
110
|
+
_print_captured(stdout, stderr, file)
|
|
72
111
|
return
|
|
73
112
|
case Failed(exc=exc):
|
|
74
113
|
_print_traceback_block('FAIL', full_name, exc, file)
|
|
75
114
|
explanation = explain_assertion(exc)
|
|
76
115
|
if explanation is not None:
|
|
77
116
|
print(explanation, file=file)
|
|
117
|
+
_print_captured(stdout, stderr, file)
|
|
78
118
|
case Errored(exc=exc):
|
|
79
119
|
_print_traceback_block('ERROR', full_name, exc, file)
|
|
120
|
+
_print_captured(stdout, stderr, file)
|
|
80
121
|
|
|
81
122
|
|
|
82
123
|
def _print_traceback_block(
|
|
@@ -110,27 +151,32 @@ def _outcome_key(outcome: Outcome) -> str:
|
|
|
110
151
|
|
|
111
152
|
|
|
112
153
|
_SUMMARY_ORDER = (
|
|
113
|
-
('passed', 'passed'),
|
|
114
|
-
('failed', 'failed'),
|
|
115
|
-
('errored', 'error'),
|
|
116
|
-
('skipped', 'skipped'),
|
|
117
|
-
('xfailed', 'xfailed'),
|
|
118
|
-
('xpassed', 'xpassed'),
|
|
154
|
+
('passed', 'passed', _GREEN),
|
|
155
|
+
('failed', 'failed', _BOLD_RED),
|
|
156
|
+
('errored', 'error', _RED),
|
|
157
|
+
('skipped', 'skipped', _YELLOW),
|
|
158
|
+
('xfailed', 'xfailed', _YELLOW),
|
|
159
|
+
('xpassed', 'xpassed', _MAGENTA),
|
|
119
160
|
)
|
|
120
161
|
|
|
121
162
|
|
|
122
|
-
def summarize(
|
|
163
|
+
def summarize(
|
|
164
|
+
results: Iterable[Result],
|
|
165
|
+
use_color: bool = False,
|
|
166
|
+
elapsed: float | None = None,
|
|
167
|
+
) -> str:
|
|
123
168
|
"""One-line summary of result counts."""
|
|
124
169
|
counts: collections.Counter = collections.Counter()
|
|
125
170
|
total = 0
|
|
126
|
-
for
|
|
127
|
-
counts[_outcome_key(outcome)] += 1
|
|
171
|
+
for r in results:
|
|
172
|
+
counts[_outcome_key(r.outcome)] += 1
|
|
128
173
|
total += 1
|
|
174
|
+
timing = f' in {elapsed:.2f}s' if elapsed is not None else ''
|
|
129
175
|
if total == 0:
|
|
130
|
-
return '0 tests'
|
|
176
|
+
return f'0 tests{timing}'
|
|
131
177
|
parts = [
|
|
132
|
-
f'{counts[key]} {label}'
|
|
133
|
-
for key, label in _SUMMARY_ORDER
|
|
178
|
+
_c(f'{counts[key]} {label}', color, use_color)
|
|
179
|
+
for key, label, color in _SUMMARY_ORDER
|
|
134
180
|
if counts.get(key)
|
|
135
181
|
]
|
|
136
|
-
return ',
|
|
182
|
+
return f'{", ".join(parts)}{timing}'
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
"""Run resolved test units and collect results."""
|
|
2
|
-
|
|
2
|
+
import io
|
|
3
|
+
from contextlib import (
|
|
4
|
+
AbstractContextManager,
|
|
5
|
+
nullcontext,
|
|
6
|
+
redirect_stderr,
|
|
7
|
+
redirect_stdout,
|
|
8
|
+
)
|
|
3
9
|
from types import ModuleType
|
|
4
10
|
from typing import Any, Callable
|
|
5
11
|
|
|
@@ -10,6 +16,7 @@ from testsweet._outcomes import (
|
|
|
10
16
|
Failed,
|
|
11
17
|
Outcome,
|
|
12
18
|
Passed,
|
|
19
|
+
Result,
|
|
13
20
|
Skipped,
|
|
14
21
|
XFailed,
|
|
15
22
|
XPassed,
|
|
@@ -24,7 +31,7 @@ def run(
|
|
|
24
31
|
names: list[str] | None = None,
|
|
25
32
|
wrap_unit: Callable[[str], AbstractContextManager[Any]] | None = None,
|
|
26
33
|
keep: TagFilter | None = None,
|
|
27
|
-
) -> list[
|
|
34
|
+
) -> list[Result]:
|
|
28
35
|
"""Run the tests in ``module``.
|
|
29
36
|
|
|
30
37
|
If ``names`` is given, only run tests whose qualified names appear
|
|
@@ -35,16 +42,18 @@ def run(
|
|
|
35
42
|
method's effective tag set is the union of its class's tags and
|
|
36
43
|
its own.
|
|
37
44
|
|
|
38
|
-
Returns a list of ``
|
|
39
|
-
|
|
40
|
-
``XPassed
|
|
45
|
+
Returns a list of ``Result`` records. Each carries the unit's
|
|
46
|
+
``name``, its ``outcome`` (one of ``Passed``, ``Failed``,
|
|
47
|
+
``Errored``, ``Skipped``, ``XFailed``, ``XPassed``), and the
|
|
48
|
+
``stdout``/``stderr`` it printed (empty unless it wrote to those
|
|
49
|
+
streams; captured only while the unit body runs).
|
|
41
50
|
"""
|
|
42
51
|
if wrap_unit is None:
|
|
43
52
|
def wrap_unit(_name: str) -> AbstractContextManager[Any]:
|
|
44
53
|
return nullcontext()
|
|
45
|
-
results: list[
|
|
54
|
+
results: list[Result] = []
|
|
46
55
|
for name, call in resolve_units(module, names, keep=keep):
|
|
47
|
-
results.append(
|
|
56
|
+
results.append(_run_one(name, call, wrap_unit))
|
|
48
57
|
return results
|
|
49
58
|
|
|
50
59
|
|
|
@@ -52,29 +61,43 @@ def _run_one(
|
|
|
52
61
|
name: str,
|
|
53
62
|
call: Callable[[], Any],
|
|
54
63
|
wrap_unit: Callable[[str], AbstractContextManager[Any]],
|
|
55
|
-
) ->
|
|
64
|
+
) -> Result:
|
|
56
65
|
try:
|
|
57
66
|
skip_marker: SkipMarker | None = active_marker(call, SKIP_MARKER)
|
|
58
67
|
except Exception as exc:
|
|
59
|
-
return Errored(exc)
|
|
68
|
+
return Result(name, Errored(exc))
|
|
60
69
|
if skip_marker is not None:
|
|
61
|
-
return Skipped(skip_marker.reason)
|
|
70
|
+
return Result(name, Skipped(skip_marker.reason))
|
|
62
71
|
try:
|
|
63
72
|
xfail_marker: XFailMarker | None = active_marker(call, XFAIL_MARKER)
|
|
64
73
|
except Exception as exc:
|
|
65
|
-
return Errored(exc)
|
|
74
|
+
return Result(name, Errored(exc))
|
|
75
|
+
out_buf = io.StringIO()
|
|
76
|
+
err_buf = io.StringIO()
|
|
66
77
|
if xfail_marker is not None:
|
|
67
78
|
try:
|
|
68
|
-
with
|
|
79
|
+
with (
|
|
80
|
+
wrap_unit(name),
|
|
81
|
+
redirect_stdout(out_buf),
|
|
82
|
+
redirect_stderr(err_buf),
|
|
83
|
+
):
|
|
69
84
|
call()
|
|
70
85
|
except Exception as exc:
|
|
71
|
-
|
|
72
|
-
|
|
86
|
+
outcome: Outcome = XFailed(exc, xfail_marker.reason)
|
|
87
|
+
else:
|
|
88
|
+
outcome = XPassed(xfail_marker.reason)
|
|
89
|
+
return Result(name, outcome, out_buf.getvalue(), err_buf.getvalue())
|
|
73
90
|
try:
|
|
74
|
-
with
|
|
91
|
+
with (
|
|
92
|
+
wrap_unit(name),
|
|
93
|
+
redirect_stdout(out_buf),
|
|
94
|
+
redirect_stderr(err_buf),
|
|
95
|
+
):
|
|
75
96
|
call()
|
|
76
97
|
except AssertionError as exc:
|
|
77
|
-
|
|
98
|
+
outcome = Failed(exc)
|
|
78
99
|
except Exception as exc:
|
|
79
|
-
|
|
80
|
-
|
|
100
|
+
outcome = Errored(exc)
|
|
101
|
+
else:
|
|
102
|
+
outcome = Passed()
|
|
103
|
+
return Result(name, outcome, out_buf.getvalue(), err_buf.getvalue())
|
|
@@ -7,8 +7,6 @@ def tag(*names):
|
|
|
7
7
|
|
|
8
8
|
Stack ``@tag`` calls or pass multiple names in one call; the
|
|
9
9
|
accumulated set is stored as a frozenset under ``TAGS_MARKER``.
|
|
10
|
-
Tags are stored but not yet used by the runner in 0.2.0 —
|
|
11
|
-
groundwork for future filtering.
|
|
12
10
|
"""
|
|
13
11
|
if not names:
|
|
14
12
|
raise TypeError('tag() requires at least one tag name')
|