pytest-pyspec 0.8.1__py3-none-any.whl → 1.0.0__py3-none-any.whl
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.
Potentially problematic release.
This version of pytest-pyspec might be problematic. Click here for more details.
- pytest_pyspec/plugin.py +134 -33
- pytest_pyspec/tree.py +386 -0
- pytest_pyspec-1.0.0.dist-info/METADATA +202 -0
- pytest_pyspec-1.0.0.dist-info/RECORD +8 -0
- {pytest_pyspec-0.8.1.dist-info → pytest_pyspec-1.0.0.dist-info}/WHEEL +1 -1
- pytest_pyspec-1.0.0.dist-info/entry_points.txt +2 -0
- pytest_pyspec/item.py +0 -153
- pytest_pyspec/output.py +0 -30
- pytest_pyspec-0.8.1.dist-info/METADATA +0 -150
- pytest_pyspec-0.8.1.dist-info/RECORD +0 -9
- pytest_pyspec-0.8.1.dist-info/entry_points.txt +0 -3
- {pytest_pyspec-0.8.1.dist-info → pytest_pyspec-1.0.0.dist-info/licenses}/LICENSE +0 -0
pytest_pyspec/plugin.py
CHANGED
|
@@ -1,10 +1,32 @@
|
|
|
1
|
+
|
|
2
|
+
"""
|
|
3
|
+
Pytest plugin that prints test output in an RSpec-like, readable format.
|
|
4
|
+
|
|
5
|
+
When enabled with the ``--pyspec`` flag, this plugin:
|
|
6
|
+
- Extends test discovery to also match ``it_`` functions and classes whose
|
|
7
|
+
names start with ``Describe``, ``With``, ``Without``, or ``When``.
|
|
8
|
+
- Builds a tree of containers/tests and renders them with friendly
|
|
9
|
+
descriptions (favoring docstrings when present).
|
|
10
|
+
- Uses pytest's stash to pass the current/previous Test objects between
|
|
11
|
+
hooks, so we can decide when to print a container header.
|
|
12
|
+
"""
|
|
13
|
+
from typing import Any, Tuple, Generator
|
|
14
|
+
|
|
1
15
|
import pytest
|
|
2
|
-
from pytest_pyspec.
|
|
3
|
-
|
|
16
|
+
from pytest_pyspec.tree import SemanticTreeBuilder, Test
|
|
17
|
+
|
|
4
18
|
|
|
19
|
+
def pytest_addoption(
|
|
20
|
+
parser: pytest.Parser,
|
|
21
|
+
pluginmanager: pytest.PytestPluginManager
|
|
22
|
+
) -> None:
|
|
23
|
+
"""
|
|
24
|
+
Register the ``--pyspec`` command-line flag.
|
|
5
25
|
|
|
6
|
-
|
|
7
|
-
|
|
26
|
+
The flag toggles all formatting and discovery behavior provided by this
|
|
27
|
+
plugin. We keep discovery changes behind the flag to avoid surprises in
|
|
28
|
+
normal pytest runs.
|
|
29
|
+
"""
|
|
8
30
|
group = parser.getgroup('general')
|
|
9
31
|
group.addoption(
|
|
10
32
|
'--pyspec',
|
|
@@ -14,13 +36,23 @@ def pytest_addoption(parser: pytest.Parser,
|
|
|
14
36
|
)
|
|
15
37
|
|
|
16
38
|
|
|
17
|
-
|
|
18
|
-
def pytest_configure(config: pytest.Config):
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
39
|
+
ENABLED_KEY = pytest.StashKey[bool]()
|
|
40
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Initialize plugin state and, when enabled, extend test discovery rules.
|
|
43
|
+
|
|
44
|
+
- ``enabled`` is set only when ``--pyspec`` is present and ``-v`` is not
|
|
45
|
+
used (verbose mode already prints enough, so we defer to pytest's
|
|
46
|
+
default output in that case).
|
|
47
|
+
- Appends discovery patterns for ``it_`` functions and ``Describe``, ``With``,
|
|
48
|
+
``Without``, and ``When`` classes when pyspec is on.
|
|
49
|
+
"""
|
|
50
|
+
# Store enabled state in config.stash for access in all hooks.
|
|
51
|
+
enabled = config.getoption('pyspec') and not config.getoption('verbose')
|
|
52
|
+
config.stash[ENABLED_KEY] = enabled
|
|
22
53
|
|
|
23
54
|
if config.getoption('pyspec'):
|
|
55
|
+
# Extend discovery to match RSpec-like naming.
|
|
24
56
|
python_functions = config.getini("python_functions")
|
|
25
57
|
python_functions.append('it_')
|
|
26
58
|
config.option.python_functions = python_functions
|
|
@@ -28,53 +60,122 @@ def pytest_configure(config: pytest.Config):
|
|
|
28
60
|
python_classes = config.getini("python_classes")
|
|
29
61
|
python_classes.append('Describe')
|
|
30
62
|
python_classes.append('With')
|
|
63
|
+
python_classes.append('Without')
|
|
64
|
+
python_classes.append('When')
|
|
31
65
|
config.option.python_classes = python_classes
|
|
32
66
|
|
|
33
67
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
68
|
+
# Stash keys used to share Test objects between hooks.
|
|
69
|
+
# These allow the report hook to know the current and previous test, so we can
|
|
70
|
+
# decide when to print a container header (on container change).
|
|
71
|
+
TEST_KEY = pytest.StashKey[Test]()
|
|
72
|
+
PREV_TEST_KEY = pytest.StashKey[Test]()
|
|
73
|
+
def pytest_collection_modifyitems(
|
|
74
|
+
session: pytest.Session,
|
|
75
|
+
config: pytest.Config,
|
|
76
|
+
items: list[pytest.Item],
|
|
77
|
+
) -> None:
|
|
78
|
+
"""
|
|
79
|
+
After collection, wrap each pytest item with our Test model and stash it.
|
|
80
|
+
|
|
81
|
+
The previous Test is also stashed so later hooks can determine container
|
|
82
|
+
boundaries and print headers only when the container changes.
|
|
83
|
+
"""
|
|
84
|
+
enabled = config.stash.get(ENABLED_KEY, False)
|
|
85
|
+
if enabled:
|
|
86
|
+
builder = SemanticTreeBuilder()
|
|
39
87
|
prev_test = None
|
|
88
|
+
processed_parents = set()
|
|
89
|
+
|
|
40
90
|
for i, item in enumerate(items):
|
|
41
|
-
test =
|
|
42
|
-
item.stash[
|
|
43
|
-
item.stash[
|
|
91
|
+
test = builder.build_tree_for_test(item)
|
|
92
|
+
item.stash[TEST_KEY] = test
|
|
93
|
+
item.stash[PREV_TEST_KEY] = prev_test
|
|
44
94
|
prev_test = test
|
|
95
|
+
|
|
96
|
+
# Update item name to the test case description with prefix for VSCode
|
|
97
|
+
item.name = test.description_with_prefix
|
|
98
|
+
|
|
99
|
+
# Update parent node names for VSCode test explorer (only once per parent)
|
|
100
|
+
current = test.parent
|
|
101
|
+
while current is not None and hasattr(current, '_item'):
|
|
102
|
+
if current._item not in processed_parents:
|
|
103
|
+
current._item.name = current.description_with_prefix
|
|
104
|
+
processed_parents.add(current._item)
|
|
105
|
+
current = current.parent if hasattr(current, 'parent') else None
|
|
45
106
|
|
|
46
107
|
|
|
47
108
|
@pytest.hookimpl(hookwrapper=True)
|
|
48
|
-
def pytest_runtest_makereport(
|
|
109
|
+
def pytest_runtest_makereport(
|
|
110
|
+
item: pytest.Item,
|
|
111
|
+
call: pytest.CallInfo
|
|
112
|
+
) -> Generator[Any, Any, None]:
|
|
113
|
+
"""
|
|
114
|
+
Inject Test metadata into the report object for later formatting.
|
|
115
|
+
|
|
116
|
+
As a hookwrapper we yield to let pytest create the report, then attach our
|
|
117
|
+
Test and prev_test (from stash) so ``pytest_report_teststatus`` can render
|
|
118
|
+
the right lines.
|
|
119
|
+
"""
|
|
49
120
|
outcome = yield
|
|
121
|
+
enabled = item.config.stash.get(ENABLED_KEY, False)
|
|
50
122
|
if enabled:
|
|
51
123
|
report: pytest.Report = outcome.get_result()
|
|
52
|
-
#TODO Check whether the report has a stash
|
|
53
|
-
#TODO move previous test to test class
|
|
54
|
-
if
|
|
55
|
-
report.test = item.stash[
|
|
56
|
-
report.prev_test = item.stash[
|
|
124
|
+
# TODO Check whether the report has a stash
|
|
125
|
+
# TODO move previous test to test class
|
|
126
|
+
if TEST_KEY in item.stash:
|
|
127
|
+
report.test = item.stash[TEST_KEY]
|
|
128
|
+
report.prev_test = item.stash[PREV_TEST_KEY]
|
|
129
|
+
|
|
57
130
|
|
|
58
131
|
|
|
59
|
-
def pytest_report_teststatus(
|
|
132
|
+
def pytest_report_teststatus(
|
|
133
|
+
report: pytest.TestReport,
|
|
134
|
+
config: pytest.Config
|
|
135
|
+
) -> Any:
|
|
136
|
+
"""
|
|
137
|
+
Produce short status and a human-friendly text line for each test event.
|
|
138
|
+
|
|
139
|
+
Behavior (only when pyspec is enabled and a Test is attached):
|
|
140
|
+
- On setup: if the parent nodes changed, print the parent tree.
|
|
141
|
+
- On call (or skipped during setup): print the test line with its status
|
|
142
|
+
mark. We reuse pytest's status tuple format.
|
|
143
|
+
"""
|
|
144
|
+
enabled = config.stash.get(ENABLED_KEY, False)
|
|
60
145
|
if enabled and hasattr(report, 'test'):
|
|
61
146
|
test = report.test
|
|
62
147
|
prev_test = report.prev_test
|
|
63
148
|
|
|
64
149
|
if report.when == 'setup':
|
|
65
|
-
if
|
|
66
|
-
|
|
67
|
-
# Show
|
|
68
|
-
output =
|
|
150
|
+
# Check if we need to print parent nodes
|
|
151
|
+
if not prev_test or test.parent != prev_test.parent:
|
|
152
|
+
# Show only new parent nodes (not already displayed)
|
|
153
|
+
output = test.get_new_parent_nodes_string(prev_test)
|
|
69
154
|
return '', output, ('', {'white': True})
|
|
70
155
|
|
|
156
|
+
# Determine if this is the last test with the same parent
|
|
157
|
+
is_last_in_parent = False
|
|
158
|
+
if test.parent and hasattr(test.parent, 'tests'):
|
|
159
|
+
try:
|
|
160
|
+
idx = test.parent.tests.index(test)
|
|
161
|
+
is_last_in_parent = idx == len(test.parent.tests) - 1
|
|
162
|
+
except (ValueError, AttributeError):
|
|
163
|
+
pass
|
|
164
|
+
|
|
71
165
|
if report.when == 'call':
|
|
72
166
|
test.outcome = report.outcome
|
|
73
|
-
output =
|
|
167
|
+
output = test.format_for_output()
|
|
168
|
+
# Always start test line with a newline
|
|
169
|
+
output = '\n' + output
|
|
170
|
+
# Only add a single newline after the last test in a parent
|
|
171
|
+
if is_last_in_parent:
|
|
172
|
+
output += '\n'
|
|
74
173
|
return report.outcome, output, ''
|
|
75
|
-
|
|
174
|
+
|
|
76
175
|
if report.when == 'setup' and report.skipped:
|
|
77
176
|
test.outcome = report.outcome
|
|
78
|
-
output =
|
|
79
|
-
|
|
80
|
-
|
|
177
|
+
output = test.format_for_output()
|
|
178
|
+
output = '\n' + output
|
|
179
|
+
if is_last_in_parent:
|
|
180
|
+
output += '\n'
|
|
181
|
+
return report.outcome, output, ''
|
pytest_pyspec/tree.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Semantic tree structure for pytest-pyspec.
|
|
3
|
+
|
|
4
|
+
Provides specific node types for different levels of test organization.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
from typing import Dict, Optional
|
|
9
|
+
import re
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PytestNode:
|
|
14
|
+
"""Base class for all semantic tree nodes."""
|
|
15
|
+
|
|
16
|
+
# Subclasses can override these
|
|
17
|
+
prefixes_to_remove = []
|
|
18
|
+
description_prefix = None
|
|
19
|
+
|
|
20
|
+
def __init__(self, item: pytest.Item):
|
|
21
|
+
self._item = item
|
|
22
|
+
# Store original name before any modifications
|
|
23
|
+
self._original_name = item.name
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def description(self) -> str:
|
|
27
|
+
"""Return the base description without article/prefix."""
|
|
28
|
+
docstring = self._description_from_docstring()
|
|
29
|
+
if docstring:
|
|
30
|
+
return docstring
|
|
31
|
+
return self._description_from_identifier()
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def description_with_prefix(self) -> str:
|
|
35
|
+
"""Return the description with appropriate prefix (a/an, with/without/when)."""
|
|
36
|
+
if not self.description_prefix:
|
|
37
|
+
return self.description
|
|
38
|
+
|
|
39
|
+
# For all prefixes, keep them lowercase
|
|
40
|
+
return f"{self.description_prefix} {self.description}"
|
|
41
|
+
|
|
42
|
+
def _description_from_docstring(self) -> Optional[str]:
|
|
43
|
+
"""Extract description from docstring if available."""
|
|
44
|
+
docstring = getattr(self._item.obj, "__doc__", None)
|
|
45
|
+
if docstring:
|
|
46
|
+
first_line = docstring.splitlines()[0].strip()
|
|
47
|
+
return first_line
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
def _description_from_identifier(self) -> str:
|
|
51
|
+
"""Convert a Python identifier into a human-readable description."""
|
|
52
|
+
# First convert identifier to words (CamelCase and snake_case)
|
|
53
|
+
normalized = self._convert_identifier_to_words(self._original_name)
|
|
54
|
+
# Lowercase common words (before removing prefixes to preserve proper casing)
|
|
55
|
+
normalized = self._lowercase_common_words(normalized)
|
|
56
|
+
# Then remove configured prefixes
|
|
57
|
+
normalized = self._remove_test_prefixes(normalized)
|
|
58
|
+
return normalized
|
|
59
|
+
|
|
60
|
+
def _convert_identifier_to_words(self, name: str) -> str:
|
|
61
|
+
"""Convert Python identifier to words (CamelCase and snake_case)."""
|
|
62
|
+
# First replace underscores with spaces
|
|
63
|
+
name = name.replace('_', ' ')
|
|
64
|
+
# Insert spaces before capitals (except at start)
|
|
65
|
+
with_spaces = re.sub(r'(?!^)([A-Z])', r' \g<1>', name)
|
|
66
|
+
# Normalize multiple spaces to single space
|
|
67
|
+
with_spaces = ' '.join(with_spaces.split())
|
|
68
|
+
return with_spaces
|
|
69
|
+
|
|
70
|
+
def _remove_test_prefixes(self, text: str) -> str:
|
|
71
|
+
"""Remove configured test-related prefixes (test, describe, it, etc.)."""
|
|
72
|
+
for prefix in self.prefixes_to_remove:
|
|
73
|
+
pattern = rf'^{prefix}\s+'
|
|
74
|
+
text = re.sub(pattern, '', text, flags=re.IGNORECASE)
|
|
75
|
+
return text
|
|
76
|
+
|
|
77
|
+
def _lowercase_common_words(self, text: str) -> str:
|
|
78
|
+
"""Lowercase common words (articles, prepositions, conjunctions) except at the start."""
|
|
79
|
+
# Common words to lowercase (articles, prepositions, conjunctions, auxiliary verbs)
|
|
80
|
+
common_words = {
|
|
81
|
+
'the', 'a', 'an', 'and', 'or', 'but', 'nor', 'for', 'yet', 'so',
|
|
82
|
+
'is', 'are', 'was', 'were', 'be', 'been', 'being',
|
|
83
|
+
'has', 'have', 'had', 'do', 'does', 'did',
|
|
84
|
+
'in', 'on', 'at', 'to', 'of', 'by', 'with', 'from', 'as'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
words = text.split()
|
|
88
|
+
if not words:
|
|
89
|
+
return text
|
|
90
|
+
|
|
91
|
+
# Keep the first word as-is, lowercase common words in the rest
|
|
92
|
+
result = [words[0]]
|
|
93
|
+
for word in words[1:]:
|
|
94
|
+
if word.lower() in common_words:
|
|
95
|
+
result.append(word.lower())
|
|
96
|
+
else:
|
|
97
|
+
result.append(word)
|
|
98
|
+
|
|
99
|
+
return ' '.join(result)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def level(self) -> int:
|
|
103
|
+
"""Calculate depth in the tree."""
|
|
104
|
+
depth = 0
|
|
105
|
+
node = self.parent if hasattr(self, 'parent') else None
|
|
106
|
+
while node:
|
|
107
|
+
depth += 1
|
|
108
|
+
node = node.parent if hasattr(node, 'parent') else None
|
|
109
|
+
return depth
|
|
110
|
+
|
|
111
|
+
def format_for_output(self) -> str:
|
|
112
|
+
"""Format this node as a string for display output."""
|
|
113
|
+
raise NotImplementedError("Subclasses must implement format_for_output()")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class TestFile(PytestNode):
|
|
117
|
+
"""Represents a test file (module)."""
|
|
118
|
+
prefixes_to_remove = ['test']
|
|
119
|
+
description_prefix = None
|
|
120
|
+
|
|
121
|
+
def __init__(self, item: pytest.Item):
|
|
122
|
+
super().__init__(item)
|
|
123
|
+
self.described_objects: list['DescribedObject'] = []
|
|
124
|
+
|
|
125
|
+
def add_described_object(self, described_object: 'DescribedObject') -> None:
|
|
126
|
+
"""Add a top-level test class to this file."""
|
|
127
|
+
self.described_objects.append(described_object)
|
|
128
|
+
described_object.parent = self
|
|
129
|
+
|
|
130
|
+
def format_for_output(self) -> str:
|
|
131
|
+
"""Format test file for display output."""
|
|
132
|
+
indent = " " * self.level
|
|
133
|
+
return f"{indent}{self.description_with_prefix}"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class DescribedObject(PytestNode):
|
|
137
|
+
"""Represents a top-level test class (e.g., DescribeMyClass)."""
|
|
138
|
+
prefixes_to_remove = ['test', 'describe']
|
|
139
|
+
|
|
140
|
+
def __init__(self, item: pytest.Item):
|
|
141
|
+
super().__init__(item)
|
|
142
|
+
self.parent: Optional[TestFile] = None
|
|
143
|
+
self.contexts: list['TestContext'] = []
|
|
144
|
+
self.tests: list['Test'] = []
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def description_prefix(self) -> str:
|
|
148
|
+
"""Return 'a' or 'an' based on the first letter of description."""
|
|
149
|
+
first_char = self.description[0].lower() if self.description else 'x'
|
|
150
|
+
return 'an' if first_char in 'aeiou' else 'a'
|
|
151
|
+
|
|
152
|
+
def add_context(self, context: 'TestContext') -> None:
|
|
153
|
+
"""Add a nested context to this described object."""
|
|
154
|
+
self.contexts.append(context)
|
|
155
|
+
context.parent = self
|
|
156
|
+
|
|
157
|
+
def add_test(self, test: 'Test') -> None:
|
|
158
|
+
"""Add a test directly to this described object."""
|
|
159
|
+
self.tests.append(test)
|
|
160
|
+
test.parent = self
|
|
161
|
+
|
|
162
|
+
def format_for_output(self) -> str:
|
|
163
|
+
"""Format described object for display output."""
|
|
164
|
+
indent = " " * self.level
|
|
165
|
+
return f"{indent}{self.description_with_prefix}"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class TestContext(PytestNode):
|
|
169
|
+
"""Represents a nested context class (e.g., WithSomeCondition)."""
|
|
170
|
+
prefixes_to_remove = ['test', 'with', 'without', 'when']
|
|
171
|
+
|
|
172
|
+
def __init__(self, item: pytest.Item):
|
|
173
|
+
super().__init__(item)
|
|
174
|
+
self.parent: Optional['DescribedObject | TestContext'] = None
|
|
175
|
+
self.contexts: list['TestContext'] = []
|
|
176
|
+
self.tests: list['Test'] = []
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def description_prefix(self) -> Optional[str]:
|
|
180
|
+
"""Return 'with', 'without', or 'when' based on the original name."""
|
|
181
|
+
name_lower = self._original_name.lower()
|
|
182
|
+
# Check 'without' first since it contains 'with'
|
|
183
|
+
if name_lower.startswith('without'):
|
|
184
|
+
return 'without'
|
|
185
|
+
# Check for 'when' prefix
|
|
186
|
+
if name_lower.startswith('when'):
|
|
187
|
+
return 'when'
|
|
188
|
+
# All other contexts get 'with' prefix (including those starting with 'with')
|
|
189
|
+
return 'with'
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def description_with_prefix(self) -> str:
|
|
193
|
+
"""Return description with prefix, keeping 'with'/'without'/'when' lowercase."""
|
|
194
|
+
if not self.description_prefix:
|
|
195
|
+
return self.description
|
|
196
|
+
|
|
197
|
+
# Check if description already starts with the prefix
|
|
198
|
+
desc_lower = self.description.lower()
|
|
199
|
+
if desc_lower.startswith(f"{self.description_prefix} ") or desc_lower == self.description_prefix:
|
|
200
|
+
# Description already has the prefix, return as-is
|
|
201
|
+
return self.description
|
|
202
|
+
|
|
203
|
+
# For contexts, always keep with/without/when lowercase
|
|
204
|
+
return f"{self.description_prefix} {self.description}"
|
|
205
|
+
|
|
206
|
+
def add_context(self, context: 'TestContext') -> None:
|
|
207
|
+
"""Add a nested context to this context."""
|
|
208
|
+
self.contexts.append(context)
|
|
209
|
+
context.parent = self
|
|
210
|
+
|
|
211
|
+
def add_test(self, test: 'Test') -> None:
|
|
212
|
+
"""Add a test to this context."""
|
|
213
|
+
self.tests.append(test)
|
|
214
|
+
test.parent = self
|
|
215
|
+
|
|
216
|
+
def format_for_output(self) -> str:
|
|
217
|
+
"""Format context for display output."""
|
|
218
|
+
indent = " " * self.level
|
|
219
|
+
return f"{indent}{self.description_with_prefix}"
|
|
220
|
+
|
|
221
|
+
class Test(PytestNode):
|
|
222
|
+
"""Represents an individual test function."""
|
|
223
|
+
prefixes_to_remove = ['test', 'it']
|
|
224
|
+
description_prefix = None
|
|
225
|
+
|
|
226
|
+
def __init__(self, item: pytest.Item):
|
|
227
|
+
super().__init__(item)
|
|
228
|
+
self.parent: Optional['DescribedObject | TestContext'] = None
|
|
229
|
+
self.outcome: Optional[str] = None
|
|
230
|
+
|
|
231
|
+
def format_for_output(self) -> str:
|
|
232
|
+
"""Format test for display output with status symbol."""
|
|
233
|
+
indent = " " * self.level
|
|
234
|
+
status = self._get_status_symbol()
|
|
235
|
+
return f"{indent}{status} {self.description}"
|
|
236
|
+
|
|
237
|
+
def _get_status_symbol(self) -> str:
|
|
238
|
+
"""Get the status symbol based on test outcome."""
|
|
239
|
+
if self.outcome == 'passed':
|
|
240
|
+
return '✓'
|
|
241
|
+
elif self.outcome == 'failed':
|
|
242
|
+
return '✗'
|
|
243
|
+
else:
|
|
244
|
+
return '»'
|
|
245
|
+
|
|
246
|
+
def get_parent_tree_string(self) -> str:
|
|
247
|
+
"""Get formatted string of all parent nodes from root to this test.
|
|
248
|
+
|
|
249
|
+
Builds the hierarchical header showing all parent contexts for a test.
|
|
250
|
+
Called when the parent context changes between tests to display the
|
|
251
|
+
full context path (e.g., "A Function" -> "with Test Case" -> "with Context").
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
Multi-line string with formatted parent nodes, each on a new line.
|
|
255
|
+
Skips TestFile nodes (module level) as they're not typically displayed.
|
|
256
|
+
Returns empty string if test has no parents.
|
|
257
|
+
"""
|
|
258
|
+
output = ""
|
|
259
|
+
if self.parent:
|
|
260
|
+
# Collect parent nodes
|
|
261
|
+
nodes = []
|
|
262
|
+
current = self.parent
|
|
263
|
+
while current:
|
|
264
|
+
# Skip TestFile nodes as they're not typically displayed
|
|
265
|
+
if not isinstance(current, TestFile):
|
|
266
|
+
nodes.insert(0, current)
|
|
267
|
+
current = current.parent if hasattr(current, 'parent') else None
|
|
268
|
+
|
|
269
|
+
# Convert each parent node to string
|
|
270
|
+
for node in nodes:
|
|
271
|
+
output += "\n" + node.format_for_output()
|
|
272
|
+
return output
|
|
273
|
+
|
|
274
|
+
def get_new_parent_nodes_string(self, prev_test: Optional['Test']) -> str:
|
|
275
|
+
"""Get formatted string of only the new parent nodes since prev_test.
|
|
276
|
+
|
|
277
|
+
Finds the common ancestor between this test and prev_test, then returns
|
|
278
|
+
only the parent nodes that are new (not shared with prev_test).
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
prev_test: The previous test that was displayed
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
Multi-line string with formatted new parent nodes, each on a new line.
|
|
285
|
+
Returns empty string if no new parents need to be displayed.
|
|
286
|
+
"""
|
|
287
|
+
if not prev_test:
|
|
288
|
+
# No previous test, show all parents
|
|
289
|
+
return self.get_parent_tree_string()
|
|
290
|
+
|
|
291
|
+
# Get all parent nodes for both tests
|
|
292
|
+
self_parents = []
|
|
293
|
+
current = self.parent
|
|
294
|
+
while current:
|
|
295
|
+
if not isinstance(current, TestFile):
|
|
296
|
+
self_parents.insert(0, current)
|
|
297
|
+
current = current.parent if hasattr(current, 'parent') else None
|
|
298
|
+
|
|
299
|
+
prev_parents = []
|
|
300
|
+
current = prev_test.parent
|
|
301
|
+
while current:
|
|
302
|
+
if not isinstance(current, TestFile):
|
|
303
|
+
prev_parents.insert(0, current)
|
|
304
|
+
current = current.parent if hasattr(current, 'parent') else None
|
|
305
|
+
|
|
306
|
+
# Find where the parent chains diverge
|
|
307
|
+
common_depth = 0
|
|
308
|
+
for i in range(min(len(self_parents), len(prev_parents))):
|
|
309
|
+
if self_parents[i] is prev_parents[i]:
|
|
310
|
+
common_depth = i + 1
|
|
311
|
+
else:
|
|
312
|
+
break
|
|
313
|
+
|
|
314
|
+
# Only print the new parent nodes
|
|
315
|
+
new_parents = self_parents[common_depth:]
|
|
316
|
+
output = ""
|
|
317
|
+
for node in new_parents:
|
|
318
|
+
output += "\n" + node.format_for_output()
|
|
319
|
+
|
|
320
|
+
return output
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
class SemanticTreeBuilder:
|
|
324
|
+
"""Factory for building the semantic tree from pytest items."""
|
|
325
|
+
|
|
326
|
+
def __init__(self):
|
|
327
|
+
self._node_cache: Dict[pytest.Item, PytestNode] = {}
|
|
328
|
+
|
|
329
|
+
def build_tree_for_test(self, test_item: pytest.Item) -> Test:
|
|
330
|
+
"""Build a Test node and its complete ancestor tree."""
|
|
331
|
+
test = Test(test_item)
|
|
332
|
+
self._node_cache[test_item] = test
|
|
333
|
+
|
|
334
|
+
# Build the parent chain from bottom to top
|
|
335
|
+
child_node = test
|
|
336
|
+
parent_item = test_item.parent
|
|
337
|
+
|
|
338
|
+
while parent_item and not isinstance(parent_item.obj, ModuleType):
|
|
339
|
+
if parent_item in self._node_cache:
|
|
340
|
+
parent_node = self._node_cache[parent_item]
|
|
341
|
+
else:
|
|
342
|
+
parent_node = self._create_node(parent_item)
|
|
343
|
+
self._node_cache[parent_item] = parent_node
|
|
344
|
+
|
|
345
|
+
# Link child to parent using type-specific methods
|
|
346
|
+
if not hasattr(child_node, 'parent') or child_node.parent is None:
|
|
347
|
+
self._link_child_to_parent(parent_node, child_node)
|
|
348
|
+
|
|
349
|
+
# Move up the tree
|
|
350
|
+
child_node = parent_node
|
|
351
|
+
parent_item = parent_item.parent
|
|
352
|
+
|
|
353
|
+
return test
|
|
354
|
+
|
|
355
|
+
def _link_child_to_parent(self, parent: PytestNode, child: PytestNode) -> None:
|
|
356
|
+
"""Link a child node to its parent using type-specific methods."""
|
|
357
|
+
if isinstance(child, Test):
|
|
358
|
+
if isinstance(parent, (DescribedObject, TestContext)):
|
|
359
|
+
parent.add_test(child)
|
|
360
|
+
elif isinstance(child, TestContext):
|
|
361
|
+
if isinstance(parent, DescribedObject):
|
|
362
|
+
parent.add_context(child)
|
|
363
|
+
elif isinstance(parent, TestContext):
|
|
364
|
+
parent.add_context(child)
|
|
365
|
+
elif isinstance(child, DescribedObject):
|
|
366
|
+
if isinstance(parent, TestFile):
|
|
367
|
+
parent.add_described_object(child)
|
|
368
|
+
|
|
369
|
+
def _create_node(self, item: pytest.Item) -> PytestNode:
|
|
370
|
+
"""Create the appropriate node type based on item characteristics."""
|
|
371
|
+
if hasattr(item, 'parent') and item.parent:
|
|
372
|
+
parent_obj = item.parent.obj
|
|
373
|
+
|
|
374
|
+
if isinstance(parent_obj, ModuleType):
|
|
375
|
+
return DescribedObject(item)
|
|
376
|
+
|
|
377
|
+
return TestContext(item)
|
|
378
|
+
|
|
379
|
+
return DescribedObject(item)
|
|
380
|
+
|
|
381
|
+
def get_root_node(self, test: Test) -> Optional[PytestNode]:
|
|
382
|
+
"""Get the root node."""
|
|
383
|
+
node = test
|
|
384
|
+
while node.parent:
|
|
385
|
+
node = node.parent
|
|
386
|
+
return node if node != test else None
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytest-pyspec
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A plugin that transforms the pytest output into a result similar to the RSpec. It enables the use of docstrings to display results and also enables the use of the prefixes "describe", "with" and "it".
|
|
5
|
+
Project-URL: Repository, https://github.com/felipecrp/pytest-pyspec
|
|
6
|
+
Project-URL: Issues, https://github.com/felipecrp/pytest-pyspec/issues
|
|
7
|
+
Author-email: Felipe Curty <felipecrp@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: MacOS
|
|
13
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
14
|
+
Classifier: Operating System :: POSIX
|
|
15
|
+
Classifier: Operating System :: Unix
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
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 :: Libraries
|
|
23
|
+
Classifier: Topic :: Software Development :: Testing
|
|
24
|
+
Classifier: Topic :: Utilities
|
|
25
|
+
Requires-Python: <4,>=3.10
|
|
26
|
+
Requires-Dist: pytest<10,>=9
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
[](https://github.com/felipecrp/pytest-pyspec/actions/workflows/pytest.yml)
|
|
30
|
+
|
|
31
|
+
# pytest-pyspec
|
|
32
|
+
|
|
33
|
+
The **pytest-pyspec** plugin transforms pytest output into a beautiful, readable format similar to RSpec. It provides semantic meaning to your tests by organizing them into descriptive hierarchies.
|
|
34
|
+
|
|
35
|
+
## Features
|
|
36
|
+
|
|
37
|
+
- **Semantic Output**: Transform pytest's default output into readable, hierarchical descriptions
|
|
38
|
+
- **Multiple Prefixes**: Support for `describe/test` (objects), `with/without/when` (contexts), and `it/test` (tests)
|
|
39
|
+
- **Docstring Support**: Override test descriptions using docstrings
|
|
40
|
+
- **Consolidated Output**: Smart grouping that avoids repeating parent headers
|
|
41
|
+
- **Natural Language**: Automatic lowercase formatting of common words (the, is, are, etc.)
|
|
42
|
+
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
45
|
+
### Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install pytest pytest-pyspec
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Running
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pytest --pyspec
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Examples
|
|
58
|
+
|
|
59
|
+
### Car Scenario
|
|
60
|
+
|
|
61
|
+
A minimal car example with properties and behaviors:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
class DescribeCar:
|
|
65
|
+
def test_has_engine(self):
|
|
66
|
+
assert True
|
|
67
|
+
|
|
68
|
+
class WithFullTank:
|
|
69
|
+
def test_drive_long_distance(self):
|
|
70
|
+
assert True
|
|
71
|
+
|
|
72
|
+
class WithoutFuel:
|
|
73
|
+
def test_cannot_start_engine(self):
|
|
74
|
+
assert True
|
|
75
|
+
|
|
76
|
+
class WhenTheEngineIsRunning:
|
|
77
|
+
def test_consumes_fuel(self):
|
|
78
|
+
assert True
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
With **pytest-pyspec**, this produces:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
a Car
|
|
85
|
+
✓ has engine
|
|
86
|
+
|
|
87
|
+
with Full Tank
|
|
88
|
+
✓ drive long distance
|
|
89
|
+
|
|
90
|
+
without Fuel
|
|
91
|
+
✓ cannot start engine
|
|
92
|
+
|
|
93
|
+
when the Engine is Running
|
|
94
|
+
✓ consumes fuel
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Available Prefixes
|
|
98
|
+
|
|
99
|
+
**pytest-pyspec** supports three types of prefixes to create semantic test hierarchies:
|
|
100
|
+
|
|
101
|
+
#### 1. Object Classes (use `describe` or `test`)
|
|
102
|
+
|
|
103
|
+
Define what you're testing:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
class DescribeCar: # or class TestCar:
|
|
107
|
+
def test_has_four_wheels(self):
|
|
108
|
+
assert True
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Output:
|
|
112
|
+
```
|
|
113
|
+
a Car
|
|
114
|
+
✓ has four wheels
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
#### 2. Context Classes (use `with`, `without`, or `when`)
|
|
118
|
+
|
|
119
|
+
Define the context or state:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
class DescribeCar:
|
|
123
|
+
class WithFullTank:
|
|
124
|
+
def test_can_drive_long_distances(self):
|
|
125
|
+
assert True
|
|
126
|
+
|
|
127
|
+
class WithoutFuel:
|
|
128
|
+
def test_cannot_start_engine(self):
|
|
129
|
+
assert True
|
|
130
|
+
|
|
131
|
+
class WhenTheEngineIsRunning:
|
|
132
|
+
def test_consumes_fuel(self):
|
|
133
|
+
assert True
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Output:
|
|
137
|
+
```
|
|
138
|
+
a Car
|
|
139
|
+
with Full Tank
|
|
140
|
+
✓ can drive long distances
|
|
141
|
+
|
|
142
|
+
without Fuel
|
|
143
|
+
✓ cannot start engine
|
|
144
|
+
|
|
145
|
+
when the Engine is Running
|
|
146
|
+
✓ consumes fuel
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
#### 3. Test Functions (use `it_` or `test_`)
|
|
150
|
+
|
|
151
|
+
Define the expected behavior:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
class DescribeCar:
|
|
155
|
+
def it_has_four_wheels(self):
|
|
156
|
+
assert True
|
|
157
|
+
|
|
158
|
+
def test_has_engine(self):
|
|
159
|
+
assert True
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Output:
|
|
163
|
+
```
|
|
164
|
+
a Car
|
|
165
|
+
✓ has four wheels
|
|
166
|
+
✓ has engine
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Using Docstrings
|
|
170
|
+
|
|
171
|
+
Override automatic naming with custom descriptions:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
class TestCar:
|
|
175
|
+
"""sports car"""
|
|
176
|
+
|
|
177
|
+
def test_top_speed(self):
|
|
178
|
+
"""reaches 200 mph"""
|
|
179
|
+
assert True
|
|
180
|
+
|
|
181
|
+
class WhenTheNitroIsActivated:
|
|
182
|
+
"""when nitro boost is activated"""
|
|
183
|
+
|
|
184
|
+
def test_acceleration(self):
|
|
185
|
+
"""accelerates rapidly"""
|
|
186
|
+
assert True
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Output:
|
|
190
|
+
```
|
|
191
|
+
a sports car
|
|
192
|
+
✓ reaches 200 mph
|
|
193
|
+
|
|
194
|
+
when nitro boost is activated
|
|
195
|
+
✓ accelerates rapidly
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Configuration
|
|
199
|
+
|
|
200
|
+
The plugin is automatically enabled when you use the `--pyspec` flag. No additional configuration is required.
|
|
201
|
+
|
|
202
|
+
For more information, see the [documentation](https://github.com/felipecrp/pytest-pyspec/blob/main/doc/README.md).
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
pytest_pyspec/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
pytest_pyspec/plugin.py,sha256=HS3iA2Xiz9LzdiB_k-2K0YpLtz5vF72Q7yS6l7LmhRk,6869
|
|
3
|
+
pytest_pyspec/tree.py,sha256=1Qg0oh7T-__vQEC9w828GI79lLAPrfzZx8wcPDY8JsE,14812
|
|
4
|
+
pytest_pyspec-1.0.0.dist-info/METADATA,sha256=O8ZEekcDGnnxQm0rOjxmET5GMmgWoK_tQY6_uyB1uz4,4916
|
|
5
|
+
pytest_pyspec-1.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
6
|
+
pytest_pyspec-1.0.0.dist-info/entry_points.txt,sha256=6YScWbxnkpduYs2Ef3gKOe6rV8Cjbj-OxCGMDNdV4Kc,48
|
|
7
|
+
pytest_pyspec-1.0.0.dist-info/licenses/LICENSE,sha256=PXKqh3rDWRsfhWsjBQ27qqYzE5E05n-eFRH95LsKRT4,1094
|
|
8
|
+
pytest_pyspec-1.0.0.dist-info/RECORD,,
|
pytest_pyspec/item.py
DELETED
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
from types import ModuleType
|
|
2
|
-
from typing import Dict, List
|
|
3
|
-
|
|
4
|
-
import re
|
|
5
|
-
|
|
6
|
-
import pytest
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class Item:
|
|
10
|
-
def __init__(self, item: pytest.Item) -> None:
|
|
11
|
-
self._item = item
|
|
12
|
-
|
|
13
|
-
@property
|
|
14
|
-
def description(self):
|
|
15
|
-
docstring = self._item.obj.__doc__
|
|
16
|
-
if docstring:
|
|
17
|
-
return self._parse_docstring(docstring)
|
|
18
|
-
return self._parse_itemname(self._item.name)
|
|
19
|
-
|
|
20
|
-
def _parse_docstring(self, docstring):
|
|
21
|
-
description = docstring.splitlines()[0]
|
|
22
|
-
description = description.strip()
|
|
23
|
-
return description
|
|
24
|
-
|
|
25
|
-
def _parse_name(self, name):
|
|
26
|
-
description = re.sub(
|
|
27
|
-
r'(?!^[A-Z])([A-Z])',
|
|
28
|
-
r'_\g<1>',
|
|
29
|
-
name)
|
|
30
|
-
description = description.lower()
|
|
31
|
-
description = ' '.join(description.split('_'))
|
|
32
|
-
return description
|
|
33
|
-
|
|
34
|
-
def _parse_itemname(self, name):
|
|
35
|
-
description = self._parse_name(name)
|
|
36
|
-
description = re.sub(
|
|
37
|
-
r'^(test|it) ',
|
|
38
|
-
'',
|
|
39
|
-
description)
|
|
40
|
-
return description
|
|
41
|
-
|
|
42
|
-
def __repr__(self):
|
|
43
|
-
return self._item.__repr__()
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
class Test(Item):
|
|
47
|
-
def __init__(self, item: pytest.Item) -> None:
|
|
48
|
-
super().__init__(item)
|
|
49
|
-
self.container: Container = None
|
|
50
|
-
self.outcome: str = None
|
|
51
|
-
|
|
52
|
-
@property
|
|
53
|
-
def level(self) -> int:
|
|
54
|
-
if not self.container:
|
|
55
|
-
return 0
|
|
56
|
-
return self.container.level +1
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
class Container(Item):
|
|
60
|
-
def __init__(self, item: pytest.Item):
|
|
61
|
-
super().__init__(item)
|
|
62
|
-
self.tests: List[Test] = list()
|
|
63
|
-
self.containers: List[Container] = list()
|
|
64
|
-
self.parent = None
|
|
65
|
-
|
|
66
|
-
def add(self, test: Test):
|
|
67
|
-
self.tests.append(test)
|
|
68
|
-
test.container = self
|
|
69
|
-
|
|
70
|
-
def add_container(self, container: 'Container'):
|
|
71
|
-
self.containers.append(container)
|
|
72
|
-
container.parent = self
|
|
73
|
-
|
|
74
|
-
def flat_list(self):
|
|
75
|
-
containers = []
|
|
76
|
-
container = self
|
|
77
|
-
while container:
|
|
78
|
-
containers.insert(0, container)
|
|
79
|
-
container = container.parent
|
|
80
|
-
return containers
|
|
81
|
-
|
|
82
|
-
@property
|
|
83
|
-
def level(self) -> int:
|
|
84
|
-
level = 0
|
|
85
|
-
|
|
86
|
-
item = self._item
|
|
87
|
-
while item.parent and not isinstance(item.parent.obj, ModuleType):
|
|
88
|
-
level += 1
|
|
89
|
-
item = item.parent
|
|
90
|
-
|
|
91
|
-
return level
|
|
92
|
-
|
|
93
|
-
def _parse_docstring(self, docstring):
|
|
94
|
-
description = super()._parse_docstring(docstring)
|
|
95
|
-
if not description.startswith(('with ', 'without ')):
|
|
96
|
-
description = f'A {description}'
|
|
97
|
-
return description
|
|
98
|
-
|
|
99
|
-
def _parse_itemname(self, name):
|
|
100
|
-
description = self._parse_name(name)
|
|
101
|
-
description = re.sub(
|
|
102
|
-
r'^(test|describe) ',
|
|
103
|
-
'A ',
|
|
104
|
-
description)
|
|
105
|
-
return description
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
class ItemFactory:
|
|
109
|
-
def __init__(self) -> None:
|
|
110
|
-
self.container_factory = ContainerFactory()
|
|
111
|
-
|
|
112
|
-
def create(self, item: pytest.Item) -> Test:
|
|
113
|
-
test_item = Test(item)
|
|
114
|
-
|
|
115
|
-
container_item = item.parent
|
|
116
|
-
container = self.container_factory.create(container_item)
|
|
117
|
-
if container:
|
|
118
|
-
container.add(test_item)
|
|
119
|
-
|
|
120
|
-
return test_item
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
class ContainerFactory:
|
|
124
|
-
def __init__(self) -> None:
|
|
125
|
-
self.containers: Dict[str, Container] = dict()
|
|
126
|
-
|
|
127
|
-
def create(self, item) -> Container:
|
|
128
|
-
containers = self._create_containers(item)
|
|
129
|
-
if not containers:
|
|
130
|
-
return None
|
|
131
|
-
|
|
132
|
-
return containers[-1]
|
|
133
|
-
|
|
134
|
-
def _create_unique_container(self, item):
|
|
135
|
-
if item not in self.containers:
|
|
136
|
-
container = Container(item)
|
|
137
|
-
self.containers[item] = container
|
|
138
|
-
|
|
139
|
-
container = self.containers.get(item)
|
|
140
|
-
return container
|
|
141
|
-
|
|
142
|
-
def _create_containers(self, item):
|
|
143
|
-
containers = []
|
|
144
|
-
child_container = None
|
|
145
|
-
while item and not isinstance(item.obj, ModuleType):
|
|
146
|
-
container = self._create_unique_container(item)
|
|
147
|
-
if child_container:
|
|
148
|
-
container.add_container(child_container)
|
|
149
|
-
|
|
150
|
-
containers.insert(0, container)
|
|
151
|
-
child_container = container
|
|
152
|
-
item = item.parent
|
|
153
|
-
return containers
|
pytest_pyspec/output.py
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
from .item import Container, Test
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
def print_container(container: Container):
|
|
6
|
-
output = "\n"
|
|
7
|
-
if container:
|
|
8
|
-
for container in container.flat_list():
|
|
9
|
-
output += print_parent_container(container)
|
|
10
|
-
container = container.parent
|
|
11
|
-
return output
|
|
12
|
-
|
|
13
|
-
def print_parent_container(container: Container):
|
|
14
|
-
ident = " " * container.level
|
|
15
|
-
output = f"\n{ident}{container.description}"
|
|
16
|
-
return output
|
|
17
|
-
|
|
18
|
-
def print_test(test: Test):
|
|
19
|
-
ident = " " * test.level
|
|
20
|
-
status = print_test_status(test)
|
|
21
|
-
output = f"\n{ident}{status} {test.description}"
|
|
22
|
-
return output
|
|
23
|
-
|
|
24
|
-
def print_test_status(test: Test):
|
|
25
|
-
if test.outcome == 'passed':
|
|
26
|
-
return '✓'
|
|
27
|
-
elif test.outcome == 'failed':
|
|
28
|
-
return '✗'
|
|
29
|
-
else:
|
|
30
|
-
return '»'
|
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.1
|
|
2
|
-
Name: pytest-pyspec
|
|
3
|
-
Version: 0.8.1
|
|
4
|
-
Summary: A plugin that transforms the pytest output into a result similar to the RSpec. It enables the use of docstrings to display results and also enables the use of the prefixes "describe", "with" and "it".
|
|
5
|
-
Home-page: https://github.com/felipecrp/pytest-pyspec
|
|
6
|
-
License: GPL-3.0-only
|
|
7
|
-
Author: Felipe Curty
|
|
8
|
-
Author-email: felipecrp@gmail.com
|
|
9
|
-
Requires-Python: >=3.8,<4.0
|
|
10
|
-
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
|
11
|
-
Classifier: Programming Language :: Python :: 3
|
|
12
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
-
Requires-Dist: pytest (>=7.2.1,<8.0.0)
|
|
18
|
-
Project-URL: Repository, https://github.com/felipecrp/pytest-pyspec
|
|
19
|
-
Description-Content-Type: text/markdown
|
|
20
|
-
|
|
21
|
-
[](https://github.com/felipecrp/pytest-pyspec/actions/workflows/pytest.yml)
|
|
22
|
-
|
|
23
|
-
The **pytest-pyspec** plugin provides semantics to the pytest output. It
|
|
24
|
-
transforms the pytest's output into a result similar to the RSpec.
|
|
25
|
-
|
|
26
|
-
The default pytest output is like the following:
|
|
27
|
-
|
|
28
|
-
```
|
|
29
|
-
test/test_pytest.py::TestFunction::test_use_test_name PASSED
|
|
30
|
-
test/test_pytest.py::TestFunction::test_use_the_prefix_test PASSED
|
|
31
|
-
test/test_pytest.py::TestFunction::test_use_the_prefix_it PASSED
|
|
32
|
-
test/test_pytest.py::TestFunction::WithDocstring::test_use_docstring PASSED
|
|
33
|
-
```
|
|
34
|
-
The **pytest-pyspec** transforms the output into the following:
|
|
35
|
-
|
|
36
|
-
```
|
|
37
|
-
A function
|
|
38
|
-
✓ use test name
|
|
39
|
-
✓ use the prefix test
|
|
40
|
-
✓ use the prefix it
|
|
41
|
-
|
|
42
|
-
A function
|
|
43
|
-
with docstring
|
|
44
|
-
✓ use docstring
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
You just need to prefix your test case classes with:
|
|
48
|
-
|
|
49
|
-
- _describe / test_ to represent objects
|
|
50
|
-
- _with / without_ to represent context
|
|
51
|
-
|
|
52
|
-
And prefix your tests with:
|
|
53
|
-
|
|
54
|
-
- _it / test_ to represent objects
|
|
55
|
-
|
|
56
|
-
The following is a sample test that generates the previous tests` output.
|
|
57
|
-
|
|
58
|
-
```python
|
|
59
|
-
class TestFunction:
|
|
60
|
-
def test_use_test_name(self):
|
|
61
|
-
assert 1 == 1
|
|
62
|
-
|
|
63
|
-
def test_use_the_prefix_test(self):
|
|
64
|
-
assert 1 == 1
|
|
65
|
-
|
|
66
|
-
def test_use_the_prefix_it(self):
|
|
67
|
-
assert 1 == 1
|
|
68
|
-
|
|
69
|
-
class WithDocstring:
|
|
70
|
-
def test_use_docstring(self):
|
|
71
|
-
assert 1 == 1
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
Moreover, you can use a docstring to overwrite the test description. The
|
|
75
|
-
following tests have the same output as the previous tests:
|
|
76
|
-
|
|
77
|
-
```python
|
|
78
|
-
class TestA:
|
|
79
|
-
""" Function """
|
|
80
|
-
def test_1(self):
|
|
81
|
-
""" use test name """
|
|
82
|
-
assert 1 == 1
|
|
83
|
-
|
|
84
|
-
def test_2(self):
|
|
85
|
-
""" use the prefix test """
|
|
86
|
-
assert 1 == 1
|
|
87
|
-
|
|
88
|
-
def test_3(self):
|
|
89
|
-
""" use the prefix it """
|
|
90
|
-
assert 1 == 1
|
|
91
|
-
|
|
92
|
-
class TestB:
|
|
93
|
-
""" with docstring """
|
|
94
|
-
def test_4(self):
|
|
95
|
-
""" use docstring """
|
|
96
|
-
assert 1 == 1
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
The following test sample:
|
|
100
|
-
|
|
101
|
-
```python
|
|
102
|
-
import pytest
|
|
103
|
-
|
|
104
|
-
class TestHouse:
|
|
105
|
-
"a House"
|
|
106
|
-
|
|
107
|
-
def test_door(self):
|
|
108
|
-
"has door"
|
|
109
|
-
assert 1 == 1
|
|
110
|
-
|
|
111
|
-
class TestTwoFloors:
|
|
112
|
-
"""with two floors
|
|
113
|
-
|
|
114
|
-
A house with two floor has stairs
|
|
115
|
-
"""
|
|
116
|
-
def test_stairs(self):
|
|
117
|
-
"has stairs"
|
|
118
|
-
assert 1 == 1
|
|
119
|
-
|
|
120
|
-
def test_second_floor(self):
|
|
121
|
-
"has second floor"
|
|
122
|
-
assert 1 == 1
|
|
123
|
-
|
|
124
|
-
def test_third_floor(self):
|
|
125
|
-
"has third floor"
|
|
126
|
-
assert 1 == 2
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
Generates the following output:
|
|
130
|
-
|
|
131
|
-
```
|
|
132
|
-
test/test_sample.py
|
|
133
|
-
|
|
134
|
-
A house
|
|
135
|
-
✓ Has door
|
|
136
|
-
|
|
137
|
-
A house
|
|
138
|
-
With two floors
|
|
139
|
-
✓ Has stairs
|
|
140
|
-
✓ Has second floor
|
|
141
|
-
✗ Has third floor
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
## Installing and running **pySpec**
|
|
145
|
-
|
|
146
|
-
```bash
|
|
147
|
-
pip install pytest pytest-pyspec
|
|
148
|
-
pytest --pyspec
|
|
149
|
-
```
|
|
150
|
-
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
pytest_pyspec/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
pytest_pyspec/item.py,sha256=r_Yr8s224s5IJCDTsnS5FzhOWhtHe4vJwpV7GHyUH40,4160
|
|
3
|
-
pytest_pyspec/output.py,sha256=e5qUSfxlf6cseaFimSeCKetcVdAtQaAFxnE00Vy5klM,788
|
|
4
|
-
pytest_pyspec/plugin.py,sha256=BAMdVU4XqwcThOiBrJC6JX0LsM6vjfLjmf1JGQMJNTE,2625
|
|
5
|
-
pytest_pyspec-0.8.1.dist-info/LICENSE,sha256=PXKqh3rDWRsfhWsjBQ27qqYzE5E05n-eFRH95LsKRT4,1094
|
|
6
|
-
pytest_pyspec-0.8.1.dist-info/METADATA,sha256=hZPZy_By329kkOQPdP_KAHTTejrU45anj9feKGIzdDc,3775
|
|
7
|
-
pytest_pyspec-0.8.1.dist-info/WHEEL,sha256=FMvqSimYX_P7y0a7UY-_Mc83r5zkBZsCYPm7Lr0Bsq4,88
|
|
8
|
-
pytest_pyspec-0.8.1.dist-info/entry_points.txt,sha256=GObsuy5lIVED-9EDmeyFXzvZF3BLoZT7Ll58sTW2pnc,47
|
|
9
|
-
pytest_pyspec-0.8.1.dist-info/RECORD,,
|
|
File without changes
|