pytest-codeblock 0.5.4__tar.gz → 0.5.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pytest_codeblock-0.5.4/src/pytest_codeblock.egg-info → pytest_codeblock-0.5.6}/PKG-INFO +3 -3
- {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/README.rst +2 -2
- {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/pyproject.toml +1 -1
- {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/__init__.py +1 -1
- pytest_codeblock-0.5.6/src/pytest_codeblock/collector.py +104 -0
- {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/md.py +4 -4
- {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/rst.py +8 -6
- {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/tests/test_integration.py +84 -0
- {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/tests/test_pytest_codeblock.py +15 -0
- pytest_codeblock-0.5.6/src/pytest_codeblock/tests/test_pytestrun_marker.py +315 -0
- {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6/src/pytest_codeblock.egg-info}/PKG-INFO +3 -3
- pytest_codeblock-0.5.4/src/pytest_codeblock/collector.py +0 -52
- pytest_codeblock-0.5.4/src/pytest_codeblock/tests/test_pytestrun_marker.py +0 -0
- {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/LICENSE +0 -0
- {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/setup.cfg +0 -0
- {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/config.py +0 -0
- {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/constants.py +0 -0
- {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/helpers.py +0 -0
- {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/pytestrun.py +0 -0
- {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/tests/__init__.py +0 -0
- {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/tests/test_customisation.py +0 -0
- {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/tests/test_nameless_codeblocks.py +0 -0
- {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock.egg-info/SOURCES.txt +0 -0
- {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock.egg-info/dependency_links.txt +0 -0
- {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock.egg-info/entry_points.txt +0 -0
- {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock.egg-info/requires.txt +0 -0
- {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-codeblock
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.6
|
|
4
4
|
Summary: Pytest plugin to collect and test code blocks in reStructuredText and Markdown files.
|
|
5
5
|
Author-email: Artur Barseghyan <artur.barseghyan@gmail.com>
|
|
6
6
|
Maintainer-email: Artur Barseghyan <artur.barseghyan@gmail.com>
|
|
@@ -146,8 +146,8 @@ Prerequisites
|
|
|
146
146
|
Documentation
|
|
147
147
|
=============
|
|
148
148
|
- Documentation is available on `Read the Docs`_.
|
|
149
|
-
- For `reStructuredText
|
|
150
|
-
- For `Markdown
|
|
149
|
+
- For `reStructuredText`, see a dedicated `reStructuredText docs`_.
|
|
150
|
+
- For `Markdown`, see a dedicated `Markdown docs`_.
|
|
151
151
|
- Both `reStructuredText docs`_ and `Markdown docs`_ have extensive
|
|
152
152
|
documentation on `pytest`_ markers and corresponding ``conftest.py`` hooks.
|
|
153
153
|
- For guidelines on contributing check the `Contributor guidelines`_.
|
|
@@ -83,8 +83,8 @@ Prerequisites
|
|
|
83
83
|
Documentation
|
|
84
84
|
=============
|
|
85
85
|
- Documentation is available on `Read the Docs`_.
|
|
86
|
-
- For `reStructuredText
|
|
87
|
-
- For `Markdown
|
|
86
|
+
- For `reStructuredText`, see a dedicated `reStructuredText docs`_.
|
|
87
|
+
- For `Markdown`, see a dedicated `Markdown docs`_.
|
|
88
88
|
- Both `reStructuredText docs`_ and `Markdown docs`_ have extensive
|
|
89
89
|
documentation on `pytest`_ markers and corresponding ``conftest.py`` hooks.
|
|
90
90
|
- For guidelines on contributing check the `Contributor guidelines`_.
|
|
@@ -6,7 +6,7 @@ from .md import MarkdownFile
|
|
|
6
6
|
from .rst import RSTFile
|
|
7
7
|
|
|
8
8
|
__title__ = "pytest-codeblock"
|
|
9
|
-
__version__ = "0.5.
|
|
9
|
+
__version__ = "0.5.6"
|
|
10
10
|
__author__ = "Artur Barseghyan <artur.barseghyan@gmail.com>"
|
|
11
11
|
__copyright__ = "2025-2026 Artur Barseghyan"
|
|
12
12
|
__license__ = "MIT"
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
__author__ = "Artur Barseghyan <artur.barseghyan@gmail.com>"
|
|
5
|
+
__copyright__ = "2025-2026 Artur Barseghyan"
|
|
6
|
+
__license__ = "MIT"
|
|
7
|
+
__all__ = (
|
|
8
|
+
"CodeSnippet",
|
|
9
|
+
"group_snippets",
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class CodeSnippet:
|
|
15
|
+
"""Data container for an extracted code snippet."""
|
|
16
|
+
code: str # The code content
|
|
17
|
+
line: int # Starting line number in the source
|
|
18
|
+
name: Optional[str] = None # Identifier for grouping (None if anonymous)
|
|
19
|
+
marks: list[str] = field(default_factory=list)
|
|
20
|
+
# Collected pytest marks (e.g. ['django_db']), parsed from doc comments
|
|
21
|
+
fixtures: list[str] = field(default_factory=list)
|
|
22
|
+
# Collected pytest fixtures (e.g. ['tmp_path']), parsed from doc comments
|
|
23
|
+
group: Optional[str] = None
|
|
24
|
+
# Set by ``continue:`` directives; names the group this snippet belongs to
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def group_snippets(snippets: list[CodeSnippet]) -> list[CodeSnippet]:
|
|
28
|
+
"""
|
|
29
|
+
Combine snippets that share a group key, using one of two modes:
|
|
30
|
+
|
|
31
|
+
- **Merge mode** (default): snippets sharing the same name (no ``group``
|
|
32
|
+
set, or nameless/same-name continuations) are concatenated into a single
|
|
33
|
+
test, accumulating marks and fixtures. This is the original behaviour.
|
|
34
|
+
- **Incremental mode**: when every continuation snippet (``group`` set) in
|
|
35
|
+
a group also carries its own distinct name, emit one test per snippet.
|
|
36
|
+
Each test's code is the cumulative concatenation of all preceding
|
|
37
|
+
snippets plus itself, so each step is exercised in isolation.
|
|
38
|
+
|
|
39
|
+
Unnamed snippets receive unique auto-keys so they are never merged.
|
|
40
|
+
"""
|
|
41
|
+
# Pass 1: bucket each snippet by its group key, preserving insertion order.
|
|
42
|
+
buckets: dict[str, list[CodeSnippet]] = {}
|
|
43
|
+
order: list[str] = []
|
|
44
|
+
anon_count = 0
|
|
45
|
+
|
|
46
|
+
for sn in snippets:
|
|
47
|
+
if sn.group:
|
|
48
|
+
key = sn.group
|
|
49
|
+
elif sn.name:
|
|
50
|
+
key = sn.name
|
|
51
|
+
else:
|
|
52
|
+
anon_count += 1
|
|
53
|
+
key = f"codeblock{anon_count}"
|
|
54
|
+
|
|
55
|
+
if key not in buckets:
|
|
56
|
+
buckets[key] = []
|
|
57
|
+
order.append(key)
|
|
58
|
+
buckets[key].append(sn)
|
|
59
|
+
|
|
60
|
+
# Pass 2: emit merged or incremental snippets per bucket.
|
|
61
|
+
combined: list[CodeSnippet] = []
|
|
62
|
+
|
|
63
|
+
for key in order:
|
|
64
|
+
members = buckets[key]
|
|
65
|
+
continuations = [sn for sn in members if sn.group]
|
|
66
|
+
# Incremental only when every continuation has a distinct own name.
|
|
67
|
+
incremental = continuations and all(
|
|
68
|
+
sn.name and sn.name != key for sn in continuations
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if incremental:
|
|
72
|
+
acc_code = ""
|
|
73
|
+
acc_marks: list[str] = []
|
|
74
|
+
acc_fixtures: list[str] = []
|
|
75
|
+
for sn in members:
|
|
76
|
+
acc_code = acc_code + "\n" + sn.code if acc_code else sn.code
|
|
77
|
+
acc_marks.extend(sn.marks)
|
|
78
|
+
acc_fixtures.extend(sn.fixtures)
|
|
79
|
+
combined.append(CodeSnippet(
|
|
80
|
+
name=sn.name,
|
|
81
|
+
code=acc_code,
|
|
82
|
+
line=sn.line,
|
|
83
|
+
marks=list(acc_marks),
|
|
84
|
+
fixtures=list(acc_fixtures),
|
|
85
|
+
))
|
|
86
|
+
else:
|
|
87
|
+
# Merge mode (original behaviour).
|
|
88
|
+
first = members[0]
|
|
89
|
+
merged_marks = list(first.marks)
|
|
90
|
+
merged_fixtures = list(first.fixtures)
|
|
91
|
+
merged_code = first.code
|
|
92
|
+
for sn in members[1:]:
|
|
93
|
+
merged_code += "\n" + sn.code
|
|
94
|
+
merged_marks.extend(sn.marks)
|
|
95
|
+
merged_fixtures.extend(sn.fixtures)
|
|
96
|
+
combined.append(CodeSnippet(
|
|
97
|
+
name=first.name,
|
|
98
|
+
code=merged_code,
|
|
99
|
+
line=first.line,
|
|
100
|
+
marks=merged_marks,
|
|
101
|
+
fixtures=merged_fixtures,
|
|
102
|
+
))
|
|
103
|
+
|
|
104
|
+
return combined
|
|
@@ -128,18 +128,18 @@ def parse_markdown(text: str) -> list[CodeSnippet]:
|
|
|
128
128
|
# end of block
|
|
129
129
|
in_block = False
|
|
130
130
|
code_text = "\n".join(code_buffer)
|
|
131
|
+
snippet_group = None
|
|
131
132
|
# continue overrides snippet_name for grouping
|
|
132
133
|
if pending_continue:
|
|
133
|
-
|
|
134
|
+
snippet_group = pending_continue
|
|
134
135
|
pending_continue = None
|
|
135
|
-
else:
|
|
136
|
-
final_name = snippet_name
|
|
137
136
|
snippets.append(CodeSnippet(
|
|
138
|
-
name=
|
|
137
|
+
name=snippet_name,
|
|
139
138
|
code=code_text,
|
|
140
139
|
line=start_line,
|
|
141
140
|
marks=pending_marks.copy(),
|
|
142
141
|
fixtures=pending_fixtures.copy(),
|
|
142
|
+
group=snippet_group,
|
|
143
143
|
))
|
|
144
144
|
# reset pending marks after collecting
|
|
145
145
|
pending_marks = [CODEBLOCK_MARK] # Reset to default
|
|
@@ -204,12 +204,12 @@ def parse_rst(text: str, base_dir: Path) -> list[CodeSnippet]:
|
|
|
204
204
|
k += 1
|
|
205
205
|
else:
|
|
206
206
|
break
|
|
207
|
+
sn_group = None
|
|
207
208
|
# Decide snippet name: continue overrides name_val/pending_name
|
|
208
209
|
if pending_continue:
|
|
209
|
-
|
|
210
|
+
sn_group = pending_continue
|
|
210
211
|
pending_continue = None
|
|
211
|
-
|
|
212
|
-
sn_name = name_val or pending_name
|
|
212
|
+
sn_name = name_val or pending_name
|
|
213
213
|
sn_marks = pending_marks.copy()
|
|
214
214
|
sn_fixtures = pending_fixtures.copy()
|
|
215
215
|
pending_name = None
|
|
@@ -222,6 +222,7 @@ def parse_rst(text: str, base_dir: Path) -> list[CodeSnippet]:
|
|
|
222
222
|
line=j + 1,
|
|
223
223
|
marks=sn_marks,
|
|
224
224
|
fixtures=sn_fixtures,
|
|
225
|
+
group=sn_group,
|
|
225
226
|
))
|
|
226
227
|
|
|
227
228
|
i = k
|
|
@@ -235,11 +236,11 @@ def parse_rst(text: str, base_dir: Path) -> list[CodeSnippet]:
|
|
|
235
236
|
# --------------------------------------------------------------------
|
|
236
237
|
if line.rstrip().endswith("::") and pending_name:
|
|
237
238
|
# Similar override logic
|
|
239
|
+
sn_group = None
|
|
238
240
|
if pending_continue:
|
|
239
|
-
|
|
241
|
+
sn_group = pending_continue
|
|
240
242
|
pending_continue = None
|
|
241
|
-
|
|
242
|
-
sn_name = pending_name
|
|
243
|
+
sn_name = pending_name
|
|
243
244
|
sn_marks = pending_marks.copy()
|
|
244
245
|
sn_fixtures = pending_fixtures.copy()
|
|
245
246
|
pending_name = None
|
|
@@ -273,6 +274,7 @@ def parse_rst(text: str, base_dir: Path) -> list[CodeSnippet]:
|
|
|
273
274
|
line=j + 1,
|
|
274
275
|
marks=sn_marks,
|
|
275
276
|
fixtures=sn_fixtures,
|
|
277
|
+
group=sn_group,
|
|
276
278
|
))
|
|
277
279
|
i = k
|
|
278
280
|
continue
|
{pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/tests/test_integration.py
RENAMED
|
@@ -368,6 +368,38 @@ assert y == 2
|
|
|
368
368
|
|
|
369
369
|
# -------------------------------------------------------------------------
|
|
370
370
|
|
|
371
|
+
def test_parse_incremental_continuation(self):
|
|
372
|
+
"""Named continuation blocks produce N cumulative tests."""
|
|
373
|
+
text = """
|
|
374
|
+
```python name=test_something
|
|
375
|
+
something = 1
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
<!-- continue: test_something -->
|
|
379
|
+
```python name=test_something_2
|
|
380
|
+
something = "a"
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
<!-- continue: test_something -->
|
|
384
|
+
```python name=test_something_3
|
|
385
|
+
something = Exception("")
|
|
386
|
+
```
|
|
387
|
+
"""
|
|
388
|
+
snippets = parse_markdown(text)
|
|
389
|
+
grouped = group_snippets(snippets)
|
|
390
|
+
assert len(grouped) == 3
|
|
391
|
+
assert grouped[0].name == "test_something"
|
|
392
|
+
assert grouped[0].code.strip() == "something = 1"
|
|
393
|
+
assert grouped[1].name == "test_something_2"
|
|
394
|
+
assert "something = 1" in grouped[1].code
|
|
395
|
+
assert 'something = "a"' in grouped[1].code
|
|
396
|
+
assert grouped[2].name == "test_something_3"
|
|
397
|
+
assert "something = 1" in grouped[2].code
|
|
398
|
+
assert 'something = "a"' in grouped[2].code
|
|
399
|
+
assert 'something = Exception("")' in grouped[2].code
|
|
400
|
+
|
|
401
|
+
# -------------------------------------------------------------------------
|
|
402
|
+
|
|
371
403
|
def test_parse_codeblock_name_directive(self):
|
|
372
404
|
"""Test the <!-- codeblock-name: name --> directive."""
|
|
373
405
|
text = """
|
|
@@ -844,6 +876,8 @@ assert x == 1
|
|
|
844
876
|
result.assert_outcomes(passed=1)
|
|
845
877
|
assert "test_basic" in result.stdout.str()
|
|
846
878
|
|
|
879
|
+
# -------------------------------------------------------------------------
|
|
880
|
+
|
|
847
881
|
def test_collect_with_fixture(self, pytester_subprocess):
|
|
848
882
|
"""Test that fixtures are properly injected."""
|
|
849
883
|
pytester_subprocess.makefile(
|
|
@@ -858,6 +892,8 @@ assert tmp_path.exists()
|
|
|
858
892
|
result = pytester_subprocess.runpytest("-v", "-p", "no:django")
|
|
859
893
|
result.assert_outcomes(passed=1)
|
|
860
894
|
|
|
895
|
+
# -------------------------------------------------------------------------
|
|
896
|
+
|
|
861
897
|
def test_collect_async_code(self, pytester_subprocess):
|
|
862
898
|
"""Test that async code is automatically wrapped."""
|
|
863
899
|
pytester_subprocess.makefile(
|
|
@@ -872,6 +908,8 @@ await asyncio.sleep(0)
|
|
|
872
908
|
result = pytester_subprocess.runpytest("-v", "-p", "no:django")
|
|
873
909
|
result.assert_outcomes(passed=1)
|
|
874
910
|
|
|
911
|
+
# -------------------------------------------------------------------------
|
|
912
|
+
|
|
875
913
|
def test_syntax_error_reporting(self, pytester_subprocess):
|
|
876
914
|
"""Test that syntax errors in snippets are properly reported."""
|
|
877
915
|
pytester_subprocess.makefile(
|
|
@@ -890,6 +928,8 @@ def broken(:
|
|
|
890
928
|
or "syntax" in result.stdout.str().lower()
|
|
891
929
|
)
|
|
892
930
|
|
|
931
|
+
# -------------------------------------------------------------------------
|
|
932
|
+
|
|
893
933
|
def test_runtime_error_reporting(self, pytester_subprocess):
|
|
894
934
|
"""Test that runtime errors in snippets are properly reported."""
|
|
895
935
|
pytester_subprocess.makefile(
|
|
@@ -904,6 +944,40 @@ raise ValueError("intentional error")
|
|
|
904
944
|
result.assert_outcomes(failed=1)
|
|
905
945
|
assert "ValueError" in result.stdout.str()
|
|
906
946
|
|
|
947
|
+
# -------------------------------------------------------------------------
|
|
948
|
+
|
|
949
|
+
def test_incremental_continue_collects_separate_tests(
|
|
950
|
+
self, pytester_subprocess
|
|
951
|
+
):
|
|
952
|
+
"""Each named continuation block becomes its own cumulative test."""
|
|
953
|
+
pytester_subprocess.makefile(
|
|
954
|
+
".md",
|
|
955
|
+
test_incremental="""
|
|
956
|
+
```python name=test_step_one
|
|
957
|
+
something = 1
|
|
958
|
+
assert something == 1
|
|
959
|
+
```
|
|
960
|
+
|
|
961
|
+
<!-- continue: test_step_one -->
|
|
962
|
+
```python name=test_step_two
|
|
963
|
+
something = "a"
|
|
964
|
+
assert something == "a"
|
|
965
|
+
```
|
|
966
|
+
|
|
967
|
+
<!-- continue: test_step_one -->
|
|
968
|
+
```python name=test_step_three
|
|
969
|
+
something = Exception("")
|
|
970
|
+
assert isinstance(something, Exception)
|
|
971
|
+
```
|
|
972
|
+
""",
|
|
973
|
+
)
|
|
974
|
+
result = pytester_subprocess.runpytest("-v", "-p", "no:django")
|
|
975
|
+
result.assert_outcomes(passed=3)
|
|
976
|
+
stdout = result.stdout.str()
|
|
977
|
+
assert "test_step_one" in stdout
|
|
978
|
+
assert "test_step_two" in stdout
|
|
979
|
+
assert "test_step_three" in stdout
|
|
980
|
+
|
|
907
981
|
|
|
908
982
|
# -----------------------------------------------------------------------------
|
|
909
983
|
# Test RSTFile.collect() method
|
|
@@ -931,6 +1005,8 @@ Test File
|
|
|
931
1005
|
result.assert_outcomes(passed=1)
|
|
932
1006
|
assert "test_rst_basic" in result.stdout.str()
|
|
933
1007
|
|
|
1008
|
+
# -------------------------------------------------------------------------
|
|
1009
|
+
|
|
934
1010
|
def test_collect_with_fixture(self, pytester_subprocess):
|
|
935
1011
|
"""Test that RST fixtures are properly injected."""
|
|
936
1012
|
pytester_subprocess.makefile(
|
|
@@ -947,6 +1023,8 @@ Test File
|
|
|
947
1023
|
result = pytester_subprocess.runpytest("-v", "-p", "no:django")
|
|
948
1024
|
result.assert_outcomes(passed=1)
|
|
949
1025
|
|
|
1026
|
+
# -------------------------------------------------------------------------
|
|
1027
|
+
|
|
950
1028
|
def test_collect_async_code(self, pytester_subprocess):
|
|
951
1029
|
"""Test that RST async code is automatically wrapped."""
|
|
952
1030
|
pytester_subprocess.makefile(
|
|
@@ -962,6 +1040,8 @@ Test File
|
|
|
962
1040
|
result = pytester_subprocess.runpytest("-v", "-p", "no:django")
|
|
963
1041
|
result.assert_outcomes(passed=1)
|
|
964
1042
|
|
|
1043
|
+
# -------------------------------------------------------------------------
|
|
1044
|
+
|
|
965
1045
|
def test_syntax_error_reporting(self, pytester_subprocess):
|
|
966
1046
|
"""Test that syntax errors in RST snippets are reported."""
|
|
967
1047
|
pytester_subprocess.makefile(
|
|
@@ -1001,6 +1081,8 @@ assert True
|
|
|
1001
1081
|
)
|
|
1002
1082
|
assert "test_md_hook" in result.stdout.str()
|
|
1003
1083
|
|
|
1084
|
+
# -------------------------------------------------------------------------
|
|
1085
|
+
|
|
1004
1086
|
def test_hook_dispatches_rst(self, pytester_subprocess):
|
|
1005
1087
|
"""Test that .rst files are dispatched to RSTFile."""
|
|
1006
1088
|
pytester_subprocess.makefile(
|
|
@@ -1017,6 +1099,8 @@ assert True
|
|
|
1017
1099
|
)
|
|
1018
1100
|
assert "test_rst_hook" in result.stdout.str()
|
|
1019
1101
|
|
|
1102
|
+
# -------------------------------------------------------------------------
|
|
1103
|
+
|
|
1020
1104
|
def test_hook_ignores_other_files(self, pytester_subprocess):
|
|
1021
1105
|
"""Test that non-.md/.rst files are ignored."""
|
|
1022
1106
|
pytester_subprocess.makefile(".txt", notes="Some notes")
|
|
@@ -36,6 +36,21 @@ def test_group_snippets_merges_named():
|
|
|
36
36
|
assert "m" in cs.marks
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
def test_group_snippets_incremental():
|
|
40
|
+
# Continuation snippets with distinct own names produce N cumulative tests.
|
|
41
|
+
sn1 = CodeSnippet(name="test_root", code="a=1", line=1)
|
|
42
|
+
sn2 = CodeSnippet(name="test_step2", code="b=2", line=5, group="test_root")
|
|
43
|
+
sn3 = CodeSnippet(name="test_step3", code="c=3", line=9, group="test_root")
|
|
44
|
+
combined = group_snippets([sn1, sn2, sn3])
|
|
45
|
+
assert len(combined) == 3
|
|
46
|
+
assert combined[0].name == "test_root"
|
|
47
|
+
assert combined[1].name == "test_step2"
|
|
48
|
+
assert combined[2].name == "test_step3"
|
|
49
|
+
assert combined[0].code == "a=1"
|
|
50
|
+
assert combined[1].code == "a=1\nb=2"
|
|
51
|
+
assert combined[2].code == "a=1\nb=2\nc=3"
|
|
52
|
+
|
|
53
|
+
|
|
39
54
|
def test_group_snippets_different_names():
|
|
40
55
|
# Snippets with different names are not grouped
|
|
41
56
|
sn1 = CodeSnippet(name="foo", code="x=1", line=1)
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for the `pytestrun` marker functionality.
|
|
3
|
+
|
|
4
|
+
When a code block is marked with ``pytestrun``, the plugin writes the block to
|
|
5
|
+
a temporary file and executes it via pytest as a subprocess, so that
|
|
6
|
+
``Test*`` classes, ``test_*`` functions, fixtures, markers, and
|
|
7
|
+
setup/teardown all behave exactly as they would in a normal pytest run.
|
|
8
|
+
"""
|
|
9
|
+
import textwrap
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from ..constants import CODEBLOCK_MARK, PYTESTRUN_MARK
|
|
14
|
+
from ..md import parse_markdown
|
|
15
|
+
from ..pytestrun import run_pytest_style_code
|
|
16
|
+
from ..rst import parse_rst
|
|
17
|
+
|
|
18
|
+
__author__ = "Artur Barseghyan <artur.barseghyan@gmail.com>"
|
|
19
|
+
__copyright__ = "2025-2026 Artur Barseghyan"
|
|
20
|
+
__license__ = "MIT"
|
|
21
|
+
__all__ = (
|
|
22
|
+
"TestPytestrunMarkParsing",
|
|
23
|
+
"TestRunPytestStyleCode",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ============================================================================
|
|
28
|
+
# Test that pytestrun mark is parsed correctly from MD and RST sources
|
|
29
|
+
# ============================================================================
|
|
30
|
+
class TestPytestrunMarkParsing:
|
|
31
|
+
"""Test that the pytestrun mark is captured during parsing."""
|
|
32
|
+
|
|
33
|
+
def test_md_pytestmark_pytestrun_captured(self):
|
|
34
|
+
"""The pytestrun mark is present after parsing a marked MD block."""
|
|
35
|
+
text = textwrap.dedent("""\
|
|
36
|
+
<!-- pytestmark: pytestrun -->
|
|
37
|
+
```python name=test_pytestrun_parse
|
|
38
|
+
def test_ok():
|
|
39
|
+
assert True
|
|
40
|
+
```
|
|
41
|
+
""")
|
|
42
|
+
snippets = parse_markdown(text)
|
|
43
|
+
assert len(snippets) == 1
|
|
44
|
+
assert PYTESTRUN_MARK in snippets[0].marks
|
|
45
|
+
|
|
46
|
+
def test_md_both_codeblock_and_pytestrun_marks(self):
|
|
47
|
+
"""Both codeblock and pytestrun marks are present simultaneously."""
|
|
48
|
+
text = textwrap.dedent("""\
|
|
49
|
+
<!-- pytestmark: pytestrun -->
|
|
50
|
+
```python name=test_marks_coexist
|
|
51
|
+
def test_x():
|
|
52
|
+
pass
|
|
53
|
+
```
|
|
54
|
+
""")
|
|
55
|
+
snippets = parse_markdown(text)
|
|
56
|
+
assert CODEBLOCK_MARK in snippets[0].marks
|
|
57
|
+
assert PYTESTRUN_MARK in snippets[0].marks
|
|
58
|
+
|
|
59
|
+
def test_md_no_pytestrun_mark_by_default(self):
|
|
60
|
+
"""A plain code block does NOT carry the pytestrun mark."""
|
|
61
|
+
text = textwrap.dedent("""\
|
|
62
|
+
```python name=test_plain
|
|
63
|
+
x = 1
|
|
64
|
+
```
|
|
65
|
+
""")
|
|
66
|
+
snippets = parse_markdown(text)
|
|
67
|
+
assert PYTESTRUN_MARK not in snippets[0].marks
|
|
68
|
+
|
|
69
|
+
def test_rst_pytestmark_pytestrun_captured(self, tmp_path):
|
|
70
|
+
"""The pytestrun mark is present after parsing a marked RST block."""
|
|
71
|
+
rst = textwrap.dedent("""\
|
|
72
|
+
.. pytestmark: pytestrun
|
|
73
|
+
|
|
74
|
+
.. code-block:: python
|
|
75
|
+
:name: test_pytestrun_rst
|
|
76
|
+
|
|
77
|
+
def test_ok():
|
|
78
|
+
assert True
|
|
79
|
+
""")
|
|
80
|
+
snippets = parse_rst(rst, tmp_path)
|
|
81
|
+
assert len(snippets) == 1
|
|
82
|
+
assert PYTESTRUN_MARK in snippets[0].marks
|
|
83
|
+
|
|
84
|
+
def test_rst_no_pytestrun_mark_by_default(self, tmp_path):
|
|
85
|
+
"""A plain RST code block does NOT carry the pytestrun mark."""
|
|
86
|
+
rst = textwrap.dedent("""\
|
|
87
|
+
.. code-block:: python
|
|
88
|
+
:name: test_plain_rst
|
|
89
|
+
|
|
90
|
+
x = 1
|
|
91
|
+
""")
|
|
92
|
+
snippets = parse_rst(rst, tmp_path)
|
|
93
|
+
assert PYTESTRUN_MARK not in snippets[0].marks
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ============================================================================
|
|
97
|
+
# Test run_pytest_style_code directly
|
|
98
|
+
# ============================================================================
|
|
99
|
+
class TestRunPytestStyleCode:
|
|
100
|
+
"""Unit tests for run_pytest_style_code helper."""
|
|
101
|
+
|
|
102
|
+
def test_passing_code_does_not_raise(self, tmp_path):
|
|
103
|
+
"""A block with a passing test must not raise."""
|
|
104
|
+
code = textwrap.dedent("""\
|
|
105
|
+
def test_simple():
|
|
106
|
+
assert 1 + 1 == 2
|
|
107
|
+
""")
|
|
108
|
+
run_pytest_style_code(
|
|
109
|
+
code=code,
|
|
110
|
+
snippet_name="test_simple",
|
|
111
|
+
path=str(tmp_path / "dummy.md"),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def test_failing_code_raises_assertion_error(self, tmp_path):
|
|
115
|
+
"""A block with a failing test must raise AssertionError."""
|
|
116
|
+
code = textwrap.dedent("""\
|
|
117
|
+
def test_fail():
|
|
118
|
+
assert False, "intentional failure"
|
|
119
|
+
""")
|
|
120
|
+
with pytest.raises(AssertionError, match="test_fail"):
|
|
121
|
+
run_pytest_style_code(
|
|
122
|
+
code=code,
|
|
123
|
+
snippet_name="test_fail",
|
|
124
|
+
path=str(tmp_path / "dummy.md"),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def test_error_message_contains_snippet_name(self, tmp_path):
|
|
128
|
+
"""The AssertionError message should reference the snippet name."""
|
|
129
|
+
code = textwrap.dedent("""\
|
|
130
|
+
def test_broken():
|
|
131
|
+
raise ValueError("boom")
|
|
132
|
+
""")
|
|
133
|
+
with pytest.raises(AssertionError) as exc_info:
|
|
134
|
+
run_pytest_style_code(
|
|
135
|
+
code=code,
|
|
136
|
+
snippet_name="test_broken_snippet",
|
|
137
|
+
path=str(tmp_path / "dummy.md"),
|
|
138
|
+
)
|
|
139
|
+
assert "test_broken_snippet" in str(exc_info.value)
|
|
140
|
+
|
|
141
|
+
def test_class_based_tests_pass(self, tmp_path):
|
|
142
|
+
"""A block containing a Test* class should execute correctly."""
|
|
143
|
+
code = textwrap.dedent("""\
|
|
144
|
+
class TestMath:
|
|
145
|
+
def test_addition(self):
|
|
146
|
+
assert 2 + 2 == 4
|
|
147
|
+
|
|
148
|
+
def test_subtraction(self):
|
|
149
|
+
assert 5 - 3 == 2
|
|
150
|
+
""")
|
|
151
|
+
run_pytest_style_code(
|
|
152
|
+
code=code,
|
|
153
|
+
snippet_name="test_math_class",
|
|
154
|
+
path=str(tmp_path / "dummy.md"),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def test_class_based_tests_fail(self, tmp_path):
|
|
158
|
+
"""A block with a failing Test* class must raise AssertionError."""
|
|
159
|
+
code = textwrap.dedent("""\
|
|
160
|
+
class TestBroken:
|
|
161
|
+
def test_bad(self):
|
|
162
|
+
assert 1 == 2
|
|
163
|
+
""")
|
|
164
|
+
with pytest.raises(AssertionError):
|
|
165
|
+
run_pytest_style_code(
|
|
166
|
+
code=code,
|
|
167
|
+
snippet_name="test_broken_class",
|
|
168
|
+
path=str(tmp_path / "dummy.md"),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def test_class_with_fixture_passes(self, tmp_path):
|
|
172
|
+
"""A block using a class-level fixture should pass."""
|
|
173
|
+
code = textwrap.dedent("""\
|
|
174
|
+
import pytest
|
|
175
|
+
|
|
176
|
+
class TestFixture:
|
|
177
|
+
@pytest.fixture
|
|
178
|
+
def greeting(self):
|
|
179
|
+
return "hello"
|
|
180
|
+
|
|
181
|
+
def test_greeting(self, greeting):
|
|
182
|
+
assert greeting == "hello"
|
|
183
|
+
""")
|
|
184
|
+
run_pytest_style_code(
|
|
185
|
+
code=code,
|
|
186
|
+
snippet_name="test_class_fixture",
|
|
187
|
+
path=str(tmp_path / "dummy.md"),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def test_parametrize_passes(self, tmp_path):
|
|
191
|
+
"""A block using @pytest.mark.parametrize should pass."""
|
|
192
|
+
code = textwrap.dedent("""\
|
|
193
|
+
import pytest
|
|
194
|
+
|
|
195
|
+
@pytest.mark.parametrize("n,expected", [(1, 2), (2, 4), (3, 6)])
|
|
196
|
+
def test_double(n, expected):
|
|
197
|
+
assert n * 2 == expected
|
|
198
|
+
""")
|
|
199
|
+
run_pytest_style_code(
|
|
200
|
+
code=code,
|
|
201
|
+
snippet_name="test_parametrize",
|
|
202
|
+
path=str(tmp_path / "dummy.md"),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def test_parametrize_failure_raises(self, tmp_path):
|
|
206
|
+
"""A parametrized block with a bad case must raise AssertionError."""
|
|
207
|
+
code = textwrap.dedent("""\
|
|
208
|
+
import pytest
|
|
209
|
+
|
|
210
|
+
@pytest.mark.parametrize("n,expected", [(1, 99)])
|
|
211
|
+
def test_wrong(n, expected):
|
|
212
|
+
assert n * 2 == expected
|
|
213
|
+
""")
|
|
214
|
+
with pytest.raises(AssertionError):
|
|
215
|
+
run_pytest_style_code(
|
|
216
|
+
code=code,
|
|
217
|
+
snippet_name="test_bad_parametrize",
|
|
218
|
+
path=str(tmp_path / "dummy.md"),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def test_setup_teardown_runs(self, tmp_path):
|
|
222
|
+
"""setup_method / teardown_method hooks should execute correctly."""
|
|
223
|
+
code = textwrap.dedent("""\
|
|
224
|
+
class TestSetup:
|
|
225
|
+
def setup_method(self):
|
|
226
|
+
self.value = 42
|
|
227
|
+
|
|
228
|
+
def test_value(self):
|
|
229
|
+
assert self.value == 42
|
|
230
|
+
|
|
231
|
+
def teardown_method(self):
|
|
232
|
+
self.value = None
|
|
233
|
+
""")
|
|
234
|
+
run_pytest_style_code(
|
|
235
|
+
code=code,
|
|
236
|
+
snippet_name="test_setup_teardown",
|
|
237
|
+
path=str(tmp_path / "dummy.md"),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def test_nested_fixtures_pass(self, tmp_path):
|
|
241
|
+
"""Nested fixture dependencies should be resolved correctly."""
|
|
242
|
+
code = textwrap.dedent("""\
|
|
243
|
+
import pytest
|
|
244
|
+
|
|
245
|
+
class TestNested:
|
|
246
|
+
@pytest.fixture
|
|
247
|
+
def base(self):
|
|
248
|
+
return 10
|
|
249
|
+
|
|
250
|
+
@pytest.fixture
|
|
251
|
+
def derived(self, base):
|
|
252
|
+
return base * 3
|
|
253
|
+
|
|
254
|
+
def test_derived(self, derived):
|
|
255
|
+
assert derived == 30
|
|
256
|
+
""")
|
|
257
|
+
run_pytest_style_code(
|
|
258
|
+
code=code,
|
|
259
|
+
snippet_name="test_nested_fixtures",
|
|
260
|
+
path=str(tmp_path / "dummy.md"),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def test_multiple_test_functions_all_pass(self, tmp_path):
|
|
264
|
+
"""Multiple top-level test functions in one block should all run."""
|
|
265
|
+
code = textwrap.dedent("""\
|
|
266
|
+
def test_one():
|
|
267
|
+
assert "a" == "a"
|
|
268
|
+
|
|
269
|
+
def test_two():
|
|
270
|
+
assert [1, 2, 3][0] == 1
|
|
271
|
+
|
|
272
|
+
def test_three():
|
|
273
|
+
assert {"k": "v"}["k"] == "v"
|
|
274
|
+
""")
|
|
275
|
+
run_pytest_style_code(
|
|
276
|
+
code=code,
|
|
277
|
+
snippet_name="test_multiple_fns",
|
|
278
|
+
path=str(tmp_path / "dummy.md"),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def test_multiple_test_functions_one_fails(self, tmp_path):
|
|
282
|
+
"""If any test function fails, AssertionError must be raised."""
|
|
283
|
+
code = textwrap.dedent("""\
|
|
284
|
+
def test_good():
|
|
285
|
+
assert True
|
|
286
|
+
|
|
287
|
+
def test_bad():
|
|
288
|
+
assert False
|
|
289
|
+
""")
|
|
290
|
+
with pytest.raises(AssertionError):
|
|
291
|
+
run_pytest_style_code(
|
|
292
|
+
code=code,
|
|
293
|
+
snippet_name="test_multi_one_fails",
|
|
294
|
+
path=str(tmp_path / "dummy.md"),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
def test_empty_code_raises_no_tests_collected(self, tmp_path):
|
|
298
|
+
"""Empty block (no test functions) should fail with non-zero exit."""
|
|
299
|
+
code = "# no tests here\nx = 1\n"
|
|
300
|
+
with pytest.raises(AssertionError):
|
|
301
|
+
run_pytest_style_code(
|
|
302
|
+
code=code,
|
|
303
|
+
snippet_name="test_empty_block",
|
|
304
|
+
path=str(tmp_path / "dummy.md"),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def test_syntax_error_in_code_raises(self, tmp_path):
|
|
308
|
+
"""A block with a syntax error should cause a non-zero pytest exit."""
|
|
309
|
+
code = "def broken(:\n pass\n"
|
|
310
|
+
with pytest.raises(AssertionError):
|
|
311
|
+
run_pytest_style_code(
|
|
312
|
+
code=code,
|
|
313
|
+
snippet_name="test_syntax_err",
|
|
314
|
+
path=str(tmp_path / "dummy.md"),
|
|
315
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-codeblock
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.6
|
|
4
4
|
Summary: Pytest plugin to collect and test code blocks in reStructuredText and Markdown files.
|
|
5
5
|
Author-email: Artur Barseghyan <artur.barseghyan@gmail.com>
|
|
6
6
|
Maintainer-email: Artur Barseghyan <artur.barseghyan@gmail.com>
|
|
@@ -146,8 +146,8 @@ Prerequisites
|
|
|
146
146
|
Documentation
|
|
147
147
|
=============
|
|
148
148
|
- Documentation is available on `Read the Docs`_.
|
|
149
|
-
- For `reStructuredText
|
|
150
|
-
- For `Markdown
|
|
149
|
+
- For `reStructuredText`, see a dedicated `reStructuredText docs`_.
|
|
150
|
+
- For `Markdown`, see a dedicated `Markdown docs`_.
|
|
151
151
|
- Both `reStructuredText docs`_ and `Markdown docs`_ have extensive
|
|
152
152
|
documentation on `pytest`_ markers and corresponding ``conftest.py`` hooks.
|
|
153
153
|
- For guidelines on contributing check the `Contributor guidelines`_.
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
from dataclasses import dataclass, field
|
|
2
|
-
from typing import Optional
|
|
3
|
-
|
|
4
|
-
__author__ = "Artur Barseghyan <artur.barseghyan@gmail.com>"
|
|
5
|
-
__copyright__ = "2025-2026 Artur Barseghyan"
|
|
6
|
-
__license__ = "MIT"
|
|
7
|
-
__all__ = (
|
|
8
|
-
"CodeSnippet",
|
|
9
|
-
"group_snippets",
|
|
10
|
-
)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
@dataclass
|
|
14
|
-
class CodeSnippet:
|
|
15
|
-
"""Data container for an extracted code snippet."""
|
|
16
|
-
code: str # The code content
|
|
17
|
-
line: int # Starting line number in the source
|
|
18
|
-
name: Optional[str] = None # Identifier for grouping (None if anonymous)
|
|
19
|
-
marks: list[str] = field(default_factory=list)
|
|
20
|
-
# Collected pytest marks (e.g. ['django_db']), parsed from doc comments
|
|
21
|
-
fixtures: list[str] = field(default_factory=list)
|
|
22
|
-
# Collected pytest fixtures (e.g. ['tmp_path']), parsed from doc comments
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def group_snippets(snippets: list[CodeSnippet]) -> list[CodeSnippet]:
|
|
26
|
-
"""
|
|
27
|
-
Merge snippets with the same name into one CodeSnippet,
|
|
28
|
-
concatenating their code and accumulating marks.
|
|
29
|
-
Unnamed snippets get unique auto-names.
|
|
30
|
-
"""
|
|
31
|
-
combined: list[CodeSnippet] = []
|
|
32
|
-
seen: dict[str, CodeSnippet] = {}
|
|
33
|
-
anon_count = 0
|
|
34
|
-
|
|
35
|
-
for sn in snippets:
|
|
36
|
-
key = sn.name
|
|
37
|
-
if not key:
|
|
38
|
-
anon_count += 1
|
|
39
|
-
key = f"codeblock{anon_count}"
|
|
40
|
-
|
|
41
|
-
if key in seen:
|
|
42
|
-
seen_sn = seen[key]
|
|
43
|
-
seen_sn.code += "\n" + sn.code
|
|
44
|
-
seen_sn.marks.extend(sn.marks)
|
|
45
|
-
seen_sn.fixtures.extend(sn.fixtures)
|
|
46
|
-
else:
|
|
47
|
-
sn.marks = list(sn.marks) # copy
|
|
48
|
-
sn.fixtures = list(sn.fixtures) # copy
|
|
49
|
-
seen[key] = sn
|
|
50
|
-
combined.append(sn)
|
|
51
|
-
|
|
52
|
-
return combined
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/tests/test_customisation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock.egg-info/requires.txt
RENAMED
|
File without changes
|
{pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock.egg-info/top_level.txt
RENAMED
|
File without changes
|