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 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.item import ItemFactory, Test
3
- from pytest_pyspec.output import print_container, print_test
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
- def pytest_addoption(parser: pytest.Parser,
7
- pluginmanager: pytest.PytestPluginManager):
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
- enabled = False
18
- def pytest_configure(config: pytest.Config):
19
- global enabled
20
- if config.getoption('pyspec') and not config.getoption('verbose'):
21
- enabled = True
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
- test_key = pytest.StashKey[Test]()
35
- prev_test_key = pytest.StashKey[Test]()
36
- def pytest_collection_modifyitems(session, config, items):
37
- if enabled:
38
- factory = ItemFactory()
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 = factory.create(item)
42
- item.stash[test_key] = test
43
- item.stash[prev_test_key] = prev_test
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(item: pytest.Item, call):
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 test_key in item.stash:
55
- report.test = item.stash[test_key]
56
- report.prev_test = item.stash[prev_test_key]
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(report: pytest.TestReport, config: pytest.Config):
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 not prev_test \
66
- or test.container != prev_test.container:
67
- # Show container
68
- output = print_container(test.container)
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 = print_test(test)
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 = print_test(test)
79
- return report.outcome, output, ''
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/badge.svg)](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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 1.8.1
2
+ Generator: hatchling 1.27.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ pytest_pyspec = pytest_pyspec.plugin
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/badge.svg)](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,,
@@ -1,3 +0,0 @@
1
- [pytest11]
2
- pytest_pyspec=pytest_pyspec.plugin
3
-