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.
Files changed (117) hide show
  1. {testsweet-0.2.2 → testsweet-0.2.3}/CHANGELOG.md +15 -0
  2. {testsweet-0.2.2 → testsweet-0.2.3}/PKG-INFO +1 -1
  3. {testsweet-0.2.2 → testsweet-0.2.3}/docs/getting-started.md +91 -40
  4. {testsweet-0.2.2 → testsweet-0.2.3}/pyproject.toml +1 -1
  5. {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/__init__.py +2 -0
  6. {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/__main__.py +65 -12
  7. {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_assertion.py +14 -6
  8. {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_outcomes.py +16 -0
  9. {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_report.py +68 -22
  10. {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_runner.py +41 -18
  11. {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_tag.py +0 -2
  12. testsweet-0.2.3/tests/assertion.py +234 -0
  13. {testsweet-0.2.2 → testsweet-0.2.3}/tests/cli.py +74 -20
  14. testsweet-0.2.3/tests/fixtures/main/capture_demo.py +13 -0
  15. testsweet-0.2.3/tests/fixtures/runner/__init__.py +0 -0
  16. testsweet-0.2.3/tests/main.py +90 -0
  17. {testsweet-0.2.2 → testsweet-0.2.3}/tests/outcomes.py +29 -0
  18. testsweet-0.2.3/tests/report.py +287 -0
  19. {testsweet-0.2.2 → testsweet-0.2.3}/tests/runner.py +169 -91
  20. {testsweet-0.2.2 → testsweet-0.2.3}/uv.lock +1 -1
  21. testsweet-0.2.2/tests/report.py +0 -151
  22. {testsweet-0.2.2 → testsweet-0.2.3}/.github/workflows/lint.yml +0 -0
  23. {testsweet-0.2.2 → testsweet-0.2.3}/.github/workflows/publish.yml +0 -0
  24. {testsweet-0.2.2 → testsweet-0.2.3}/.github/workflows/tests.yml +0 -0
  25. {testsweet-0.2.2 → testsweet-0.2.3}/.github/workflows/types.yml +0 -0
  26. {testsweet-0.2.2 → testsweet-0.2.3}/.gitignore +0 -0
  27. {testsweet-0.2.2 → testsweet-0.2.3}/.pre-commit-config.yaml +0 -0
  28. {testsweet-0.2.2 → testsweet-0.2.3}/.python-version +0 -0
  29. {testsweet-0.2.2 → testsweet-0.2.3}/CLAUDE.md +0 -0
  30. {testsweet-0.2.2 → testsweet-0.2.3}/LICENSE +0 -0
  31. {testsweet-0.2.2 → testsweet-0.2.3}/README.md +0 -0
  32. {testsweet-0.2.2 → testsweet-0.2.3}/docs/contributing.md +0 -0
  33. {testsweet-0.2.2 → testsweet-0.2.3}/docs/examples/catches.py +0 -0
  34. {testsweet-0.2.2 → testsweet-0.2.3}/docs/examples/classes.py +0 -0
  35. {testsweet-0.2.2 → testsweet-0.2.3}/docs/examples/functions.py +0 -0
  36. {testsweet-0.2.2 → testsweet-0.2.3}/docs/img/testsweet_200x200.png +0 -0
  37. {testsweet-0.2.2 → testsweet-0.2.3}/docs/reference.md +0 -0
  38. {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_catches.py +0 -0
  39. {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_class_helpers.py +0 -0
  40. {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_classify.py +0 -0
  41. {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_condition_decorator.py +0 -0
  42. {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_config.py +0 -0
  43. {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_discover.py +0 -0
  44. {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_loaders.py +0 -0
  45. {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_markers.py +0 -0
  46. {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_params.py +0 -0
  47. {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_plugins.py +0 -0
  48. {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_resolve.py +0 -0
  49. {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_skip.py +0 -0
  50. {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_tag_filter.py +0 -0
  51. {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_targets.py +0 -0
  52. {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_walk.py +0 -0
  53. {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/_xfail.py +0 -0
  54. {testsweet-0.2.2 → testsweet-0.2.3}/src/testsweet/py.typed +0 -0
  55. {testsweet-0.2.2 → testsweet-0.2.3}/tests/__init__.py +0 -0
  56. {testsweet-0.2.2 → testsweet-0.2.3}/tests/catches.py +0 -0
  57. {testsweet-0.2.2 → testsweet-0.2.3}/tests/config.py +0 -0
  58. {testsweet-0.2.2 → testsweet-0.2.3}/tests/discover.py +0 -0
  59. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/__init__.py +0 -0
  60. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/empty.py +0 -0
  61. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/imported_only.py +0 -0
  62. {testsweet-0.2.2/tests/fixtures/runner → testsweet-0.2.3/tests/fixtures/main}/__init__.py +0 -0
  63. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/mixed.py +0 -0
  64. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/mixed_local_imported.py +0 -0
  65. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/multiple.py +0 -0
  66. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/non_callable_marker.py +0 -0
  67. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/all_pass.py +0 -0
  68. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/assertion_diagnostics.py +0 -0
  69. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_calls_recorded.py +0 -0
  70. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_decorated_simple.py +0 -0
  71. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_decorated_with_cm.py +0 -0
  72. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_enter_only.py +0 -0
  73. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_enter_raises.py +0 -0
  74. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_exit_raises.py +0 -0
  75. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_method_fails.py +0 -0
  76. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_mixed_with_function.py +0 -0
  77. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_simple.py +0 -0
  78. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_test_context_raises.py +0 -0
  79. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_test_context_with_params.py +0 -0
  80. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_with_inheritance.py +0 -0
  81. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_with_test_context.py +0 -0
  82. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/class_with_underscore_methods.py +0 -0
  83. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/empty.py +0 -0
  84. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/has_broken_import.py +0 -0
  85. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/has_failure.py +0 -0
  86. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/keyboard_interrupt.py +0 -0
  87. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/non_assertion_error.py +0 -0
  88. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/orphan_params.py +0 -0
  89. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/orphan_skip.py +0 -0
  90. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/orphan_tag.py +0 -0
  91. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/orphan_xfail.py +0 -0
  92. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/params_empty.py +0 -0
  93. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/params_generator.py +0 -0
  94. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/params_lazy_generator.py +0 -0
  95. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/params_lazy_list.py +0 -0
  96. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/params_lazy_on_class_method.py +0 -0
  97. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/params_no_decoration.py +0 -0
  98. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/params_on_class_method.py +0 -0
  99. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/params_simple.py +0 -0
  100. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/params_with_failure.py +0 -0
  101. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/skip_on_class_method.py +0 -0
  102. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/skip_on_params.py +0 -0
  103. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/tagged_class.py +0 -0
  104. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/uses_relative_import.py +0 -0
  105. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/runner/xfail_on_params.py +0 -0
  106. {testsweet-0.2.2 → testsweet-0.2.3}/tests/fixtures/single.py +0 -0
  107. {testsweet-0.2.2 → testsweet-0.2.3}/tests/markers.py +0 -0
  108. {testsweet-0.2.2 → testsweet-0.2.3}/tests/params.py +0 -0
  109. {testsweet-0.2.2 → testsweet-0.2.3}/tests/plugins.py +0 -0
  110. {testsweet-0.2.2 → testsweet-0.2.3}/tests/resolve.py +0 -0
  111. {testsweet-0.2.2 → testsweet-0.2.3}/tests/skip.py +0 -0
  112. {testsweet-0.2.2 → testsweet-0.2.3}/tests/tag.py +0 -0
  113. {testsweet-0.2.2 → testsweet-0.2.3}/tests/tag_filter.py +0 -0
  114. {testsweet-0.2.2 → testsweet-0.2.3}/tests/targets.py +0 -0
  115. {testsweet-0.2.2 → testsweet-0.2.3}/tests/test_unittest_shim.py +0 -0
  116. {testsweet-0.2.2 → testsweet-0.2.3}/tests/walk.py +0 -0
  117. {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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: testsweet
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Python testing for humans
5
5
  Project-URL: Homepage, https://github.com/kaapstorm/testsweet
6
6
  Project-URL: Source, https://github.com/kaapstorm/testsweet
@@ -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,6 +1,6 @@
1
1
  [project]
2
2
  name = "testsweet"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  description = "Python testing for humans"
5
5
  license = "Apache-2.0"
6
6
  license-files = ["LICENSE"]
@@ -7,6 +7,7 @@ from testsweet._outcomes import (
7
7
  Failed,
8
8
  Outcome,
9
9
  Passed,
10
+ Result,
10
11
  Skipped,
11
12
  XFailed,
12
13
  XPassed,
@@ -27,6 +28,7 @@ __all__ = [
27
28
  'Outcome',
28
29
  'Passed',
29
30
  'Plugin',
31
+ 'Result',
30
32
  'Skipped',
31
33
  'XFailed',
32
34
  'XPassed',
@@ -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, Outcome, XPassed
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
- results: list[tuple[str, Outcome]] = []
98
- real_failures: list[tuple[str, Outcome]] = []
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 name, outcome in run(
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
- print(format_result_line(full_name, outcome))
110
- results.append((full_name, outcome))
111
- if isinstance(outcome, (Failed, Errored, XPassed)):
112
- real_failures.append((full_name, outcome))
113
- for full_name, outcome in real_failures:
114
- print_failure_detail(full_name, outcome)
115
- print(summarize(results))
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. ``Call`` sub-expressions
6
- are deliberately skipped: re-evaluating a call would fire its side
7
- effects a second time.
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, (ast.Constant, ast.Call)):
34
- # Skip constants (no information) and calls (re-evaluating
35
- # would fire side effects a second time).
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(full_name: str, outcome: Outcome) -> str:
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
- return f'{full_name} ... ok'
52
+ status = _c('ok', _GREEN, use_color)
53
+ return f'{full_name} ... {status}'
38
54
  case Skipped(reason=reason):
39
- return f'{full_name} ... skipped{_suffix(reason)}'
55
+ status = _c(f'skipped{_suffix(reason)}', _YELLOW, use_color)
56
+ return f'{full_name} ... {status}'
40
57
  case XFailed(reason=reason):
41
- return f'{full_name} ... xfailed{_suffix(reason)}'
58
+ status = _c(f'xfailed{_suffix(reason)}', _YELLOW, use_color)
59
+ return f'{full_name} ... {status}'
42
60
  case XPassed(reason=reason):
43
- return f'{full_name} ... XPASSED{_suffix(reason)}'
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
- return f'{full_name} ... FAIL: AssertionError: {detail}'
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
- return (
49
- f'{full_name} ... ERROR: {type(exc).__name__}: {exc}'
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
- file: TextIO = sys.stdout,
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(results: Iterable[tuple[str, Outcome]]) -> str:
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 _name, outcome in results:
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 ', '.join(parts)
182
+ return f'{", ".join(parts)}{timing}'
@@ -1,5 +1,11 @@
1
1
  """Run resolved test units and collect results."""
2
- from contextlib import AbstractContextManager, nullcontext
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[tuple[str, Outcome]]:
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 ``(name, outcome)`` tuples. ``outcome`` is one
39
- of ``Passed``, ``Failed``, ``Errored``, ``Skipped``, ``XFailed``,
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[tuple[str, Outcome]] = []
54
+ results: list[Result] = []
46
55
  for name, call in resolve_units(module, names, keep=keep):
47
- results.append((name, _run_one(name, call, wrap_unit)))
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
- ) -> Outcome:
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 wrap_unit(name):
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
- return XFailed(exc, xfail_marker.reason)
72
- return XPassed(xfail_marker.reason)
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 wrap_unit(name):
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
- return Failed(exc)
98
+ outcome = Failed(exc)
78
99
  except Exception as exc:
79
- return Errored(exc)
80
- return Passed()
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')