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.
Files changed (27) hide show
  1. {pytest_codeblock-0.5.4/src/pytest_codeblock.egg-info → pytest_codeblock-0.5.6}/PKG-INFO +3 -3
  2. {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/README.rst +2 -2
  3. {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/pyproject.toml +1 -1
  4. {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/__init__.py +1 -1
  5. pytest_codeblock-0.5.6/src/pytest_codeblock/collector.py +104 -0
  6. {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/md.py +4 -4
  7. {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/rst.py +8 -6
  8. {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/tests/test_integration.py +84 -0
  9. {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/tests/test_pytest_codeblock.py +15 -0
  10. pytest_codeblock-0.5.6/src/pytest_codeblock/tests/test_pytestrun_marker.py +315 -0
  11. {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6/src/pytest_codeblock.egg-info}/PKG-INFO +3 -3
  12. pytest_codeblock-0.5.4/src/pytest_codeblock/collector.py +0 -52
  13. pytest_codeblock-0.5.4/src/pytest_codeblock/tests/test_pytestrun_marker.py +0 -0
  14. {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/LICENSE +0 -0
  15. {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/setup.cfg +0 -0
  16. {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/config.py +0 -0
  17. {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/constants.py +0 -0
  18. {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/helpers.py +0 -0
  19. {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/pytestrun.py +0 -0
  20. {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/tests/__init__.py +0 -0
  21. {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/tests/test_customisation.py +0 -0
  22. {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock/tests/test_nameless_codeblocks.py +0 -0
  23. {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock.egg-info/SOURCES.txt +0 -0
  24. {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock.egg-info/dependency_links.txt +0 -0
  25. {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock.egg-info/entry_points.txt +0 -0
  26. {pytest_codeblock-0.5.4 → pytest_codeblock-0.5.6}/src/pytest_codeblock.egg-info/requires.txt +0 -0
  27. {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.4
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`_, see a dedicated `reStructuredText docs`_.
150
- - For `Markdown`_, see a dedicated `Markdown docs`_.
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`_, see a dedicated `reStructuredText docs`_.
87
- - For `Markdown`_, see a dedicated `Markdown docs`_.
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`_.
@@ -2,7 +2,7 @@
2
2
  name = "pytest-codeblock"
3
3
  description = "Pytest plugin to collect and test code blocks in reStructuredText and Markdown files."
4
4
  readme = "README.rst"
5
- version = "0.5.4"
5
+ version = "0.5.6"
6
6
  requires-python = ">=3.10"
7
7
  dependencies = [
8
8
  "pytest",
@@ -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.4"
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
- final_name = pending_continue
134
+ snippet_group = pending_continue
134
135
  pending_continue = None
135
- else:
136
- final_name = snippet_name
137
136
  snippets.append(CodeSnippet(
138
- name=final_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
- sn_name = pending_continue
210
+ sn_group = pending_continue
210
211
  pending_continue = None
211
- else:
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
- sn_name = pending_continue
241
+ sn_group = pending_continue
240
242
  pending_continue = None
241
- else:
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
@@ -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.4
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`_, see a dedicated `reStructuredText docs`_.
150
- - For `Markdown`_, see a dedicated `Markdown docs`_.
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