pytest-pyspec 0.5.3__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
4
17
 
5
18
 
6
- def pytest_addoption(parser: pytest.Parser,
7
- pluginmanager: pytest.PytestPluginManager):
19
+ def pytest_addoption(
20
+ parser: pytest.Parser,
21
+ pluginmanager: pytest.PytestPluginManager
22
+ ) -> None:
23
+ """
24
+ Register the ``--pyspec`` command-line flag.
25
+
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,60 +36,146 @@ 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
 
54
+ if config.getoption('pyspec'):
55
+ # Extend discovery to match RSpec-like naming.
23
56
  python_functions = config.getini("python_functions")
24
57
  python_functions.append('it_')
25
58
  config.option.python_functions = python_functions
26
59
 
27
60
  python_classes = config.getini("python_classes")
28
- python_classes.append('describe_')
29
- python_classes.append('with_')
61
+ python_classes.append('Describe')
62
+ python_classes.append('With')
63
+ python_classes.append('Without')
64
+ python_classes.append('When')
30
65
  config.option.python_classes = python_classes
31
66
 
32
67
 
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.
33
80
 
34
- test_key = pytest.StashKey[Test]()
35
- prev_test_key = pytest.StashKey[Test]()
36
- def pytest_collection_modifyitems(session, config, items):
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)
37
85
  if enabled:
38
- factory = ItemFactory()
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, 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
- report.test = item.stash[test_key]
55
- 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]
56
129
 
57
130
 
58
- def pytest_report_teststatus(report: pytest.TestReport, config: pytest.Config):
59
- if enabled:
131
+
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)
145
+ if enabled and hasattr(report, 'test'):
60
146
  test = report.test
61
147
  prev_test = report.prev_test
62
148
 
63
149
  if report.when == 'setup':
64
- if not prev_test \
65
- or test.container != prev_test.container:
66
- # Show container
67
- 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)
68
154
  return '', output, ('', {'white': True})
69
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
+
70
165
  if report.when == 'call':
71
166
  test.outcome = report.outcome
72
- 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'
73
173
  return report.outcome, output, ''
174
+
175
+ if report.when == 'setup' and report.skipped:
176
+ test.outcome = report.outcome
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