pytest-codeblock 0.3.1__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.
@@ -0,0 +1,26 @@
1
+ from pathlib import Path
2
+
3
+ from .md import MarkdownFile
4
+ from .rst import RSTFile
5
+
6
+ __title__ = "pytest-codeblock"
7
+ __version__ = "0.3.1"
8
+ __author__ = "Artur Barseghyan <artur.barseghyan@gmail.com>"
9
+ __copyright__ = "2025 Artur Barseghyan"
10
+ __license__ = "MIT"
11
+ __all__ = (
12
+ "pytest_collect_file",
13
+ )
14
+
15
+
16
+ def pytest_collect_file(parent, path):
17
+ """Collect .md and .rst files for codeblock tests."""
18
+ # Determine file extension (works for py.path or pathlib.Path)
19
+ file_name = str(path).lower()
20
+ if file_name.endswith((".md", ".markdown")):
21
+ # Use the MarkdownFile collector for Markdown files
22
+ return MarkdownFile.from_parent(parent=parent, path=Path(path))
23
+ if file_name.endswith(".rst"):
24
+ # Use the RSTFile collector for reStructuredText files
25
+ return RSTFile.from_parent(parent=parent, path=Path(path))
26
+ return None
@@ -0,0 +1,52 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Optional
3
+
4
+ __author__ = "Artur Barseghyan <artur.barseghyan@gmail.com>"
5
+ __copyright__ = "2025 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
@@ -0,0 +1,18 @@
1
+ __author__ = "Artur Barseghyan <artur.barseghyan@gmail.com>"
2
+ __copyright__ = "2025 Artur Barseghyan"
3
+ __license__ = "MIT"
4
+ __all__ = (
5
+ "CODEBLOCK_MARK",
6
+ "DJANGO_DB_MARKS",
7
+ "TEST_PREFIX",
8
+ )
9
+
10
+ DJANGO_DB_MARKS = {
11
+ "django_db",
12
+ "db",
13
+ "transactional_db",
14
+ }
15
+
16
+ TEST_PREFIX = "test_"
17
+
18
+ CODEBLOCK_MARK = "codeblock"
pytest_codeblock/md.py ADDED
@@ -0,0 +1,213 @@
1
+ import inspect
2
+ import re
3
+ import textwrap
4
+ import traceback
5
+ from typing import Optional
6
+
7
+ import pytest
8
+
9
+ from .collector import CodeSnippet, group_snippets
10
+ from .constants import CODEBLOCK_MARK, DJANGO_DB_MARKS, TEST_PREFIX
11
+
12
+ __author__ = "Artur Barseghyan <artur.barseghyan@gmail.com>"
13
+ __copyright__ = "2025 Artur Barseghyan"
14
+ __license__ = "MIT"
15
+ __all__ = (
16
+ "MarkdownFile",
17
+ "parse_markdown",
18
+ )
19
+
20
+
21
+ def parse_markdown(text: str) -> list[CodeSnippet]:
22
+ """
23
+ Parse Markdown text and extract Python code snippets as CodeSnippet
24
+ objects.
25
+
26
+ Supports:
27
+ - <!-- pytestmark: <mark> --> comments immediately before a code fence
28
+ - <!-- codeblock-name: <name> --> comments for naming
29
+ - Fenced code blocks with ```python (and optional name=<name> in the
30
+ info string)
31
+
32
+ Captures each snippet’s name, code, starting line, and any pytest marks.
33
+ """
34
+ snippets: list[CodeSnippet] = []
35
+ lines = text.splitlines()
36
+ pending_name: Optional[str] = None
37
+ pending_marks: list[str] = [CODEBLOCK_MARK]
38
+ pending_fixtures: list[str] = []
39
+ in_block = False
40
+ fence = ""
41
+ block_indent = 0
42
+ code_buffer: list[str] = []
43
+ snippet_name: Optional[str] = None
44
+ start_line = 0
45
+
46
+ for idx, line in enumerate(lines, start=1):
47
+ stripped = line.strip()
48
+
49
+ if not in_block:
50
+ # Check for pytest mark comment
51
+ if stripped.startswith("<!--") and "pytestmark:" in stripped:
52
+ m = re.match(r"<!--\s*pytestmark:\s*(\w+)\s*-->", stripped)
53
+ if m:
54
+ pending_marks.append(m.group(1))
55
+ continue
56
+
57
+ # Check for pytest fixture comment
58
+ if stripped.startswith("<!--") and "pytestfixture:" in stripped:
59
+ m = re.match(r"<!--\s*pytestfixture:\s*(\w+)\s*-->", stripped)
60
+ if m:
61
+ pending_fixtures.append(m.group(1))
62
+ continue
63
+
64
+ # Check for name comment
65
+ if stripped.startswith("<!--") and "codeblock-name:" in stripped:
66
+ m = re.match(
67
+ r"<!--\s*codeblock-name:\s*([^ >]+)\s*-->", stripped
68
+ )
69
+ if m:
70
+ pending_name = m.group(1)
71
+ continue
72
+
73
+ # Start of fenced code block?
74
+ if line.lstrip().startswith("```"):
75
+ indent = len(line) - len(line.lstrip())
76
+ m = re.match(r"^`{3,}", line.lstrip())
77
+ if not m:
78
+ continue
79
+ fence = m.group(0)
80
+ info = line.lstrip()[len(fence):].strip()
81
+ parts = info.split(None, 1)
82
+ lang = parts[0].lower() if parts else ""
83
+ extra = parts[1] if len(parts) > 1 else ""
84
+ if lang in ("python", "py", "python3"):
85
+ in_block = True
86
+ block_indent = indent
87
+ start_line = idx + 1
88
+ code_buffer = []
89
+ # determine name from info string or pending comment
90
+ snippet_name = None
91
+ for token in extra.split():
92
+ if (
93
+ token.startswith("name=")
94
+ or token.startswith("name:")
95
+ ):
96
+ snippet_name = (
97
+ token.split("=", 1)[-1]
98
+ if "=" in token
99
+ else token.split(":", 1)[-1]
100
+ )
101
+ break
102
+ if snippet_name is None:
103
+ snippet_name = pending_name
104
+ # reset pending_name; marks stay until block closes
105
+ pending_name = None
106
+ continue
107
+
108
+ else:
109
+ # inside a fenced code block
110
+ if line.lstrip().startswith(fence):
111
+ # end of block
112
+ in_block = False
113
+ code_text = "\n".join(code_buffer)
114
+ snippets.append(CodeSnippet(
115
+ name=snippet_name,
116
+ code=code_text,
117
+ line=start_line,
118
+ marks=pending_marks.copy(),
119
+ fixtures=pending_fixtures.copy(),
120
+ ))
121
+ # reset pending marks after collecting
122
+ pending_marks.clear()
123
+ snippet_name = None
124
+ else:
125
+ # collect code lines (dedent by block_indent)
126
+ if line.strip() == "":
127
+ code_buffer.append("")
128
+ else:
129
+ if len(line) >= block_indent:
130
+ code_buffer.append(line[block_indent:])
131
+ else:
132
+ code_buffer.append(line.lstrip())
133
+ continue
134
+
135
+ return snippets
136
+
137
+
138
+ class MarkdownFile(pytest.File):
139
+ """
140
+ Collector for Markdown files, extracting only `test_`-prefixed code
141
+ snippets.
142
+ """
143
+ def collect(self):
144
+ text = self.path.read_text(encoding="utf-8")
145
+ raw = parse_markdown(text)
146
+ # keep only snippets named test_*
147
+ tests = [
148
+ sn for sn in raw if sn.name and sn.name.startswith(TEST_PREFIX)
149
+ ]
150
+ combined = group_snippets(tests)
151
+
152
+ for sn in combined:
153
+ # Bind the values we need so we don't close over `sn` itself
154
+ _sn_name = sn.name
155
+ _fpath = str(self.path)
156
+
157
+ # Build list of fixture names requested by this snippet
158
+ _fixture_names: list[str] = list(sn.fixtures)
159
+
160
+ # If snippet is marked as needing DB, also request the `db`
161
+ # fixture, unless user already added it explicitly.
162
+ if (
163
+ DJANGO_DB_MARKS.intersection(sn.marks)
164
+ and "db" not in _fixture_names
165
+ ):
166
+ _fixture_names.append("db")
167
+
168
+ # Generate a real pytest Function so fixtures work
169
+ def make_func(
170
+ code,
171
+ sn_name=_sn_name,
172
+ fpath=_fpath,
173
+ fixture_names=_fixture_names,
174
+ ):
175
+ # This inner function *actually* has a **fixtures signature,
176
+ # but we override __signature__ so pytest passes the right
177
+ # fixtures and names.
178
+ def test_block(**fixtures):
179
+ compiled = compile(code, fpath, "exec")
180
+ try:
181
+ # Make fixtures available as top-level names
182
+ # inside the executed snippet.
183
+ exec(compiled, dict(fixtures))
184
+ except Exception as err:
185
+ raise Exception(
186
+ f"Error in "
187
+ f"codeblock `{sn_name}` in {fpath}:\n"
188
+ f"\n{textwrap.indent(code, prefix=' ')}\n\n"
189
+ f"{traceback.format_exc()}"
190
+ ) from err
191
+
192
+ # Tell pytest which fixture arguments this test has:
193
+ test_block.__signature__ = inspect.Signature(
194
+ [
195
+ inspect.Parameter(
196
+ name,
197
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
198
+ )
199
+ for name in fixture_names
200
+ ]
201
+ )
202
+ return test_block
203
+
204
+ callobj = make_func(sn.code)
205
+ fn = pytest.Function.from_parent(
206
+ parent=self,
207
+ name=sn.name,
208
+ callobj=callobj,
209
+ )
210
+ # apply any marks (e.g. django_db)
211
+ for m in sn.marks:
212
+ fn.add_marker(getattr(pytest.mark, m))
213
+ yield fn
@@ -0,0 +1,352 @@
1
+ import inspect
2
+ import re
3
+ import textwrap
4
+ import traceback
5
+ from pathlib import Path
6
+ from typing import Optional, Union
7
+
8
+ import pytest
9
+
10
+ from .collector import CodeSnippet, group_snippets
11
+ from .constants import CODEBLOCK_MARK, DJANGO_DB_MARKS, TEST_PREFIX
12
+
13
+ __author__ = "Artur Barseghyan <artur.barseghyan@gmail.com>"
14
+ __copyright__ = "2025 Artur Barseghyan"
15
+ __license__ = "MIT"
16
+ __all__ = (
17
+ "RSTFile",
18
+ "parse_rst",
19
+ "resolve_literalinclude_path",
20
+ "get_literalinclude_content",
21
+ )
22
+
23
+
24
+ def resolve_literalinclude_path(
25
+ base_dir: Union[str, Path],
26
+ include_path: str,
27
+ ) -> Optional[str]:
28
+ """
29
+ Resolve the full path for a literalinclude directive.
30
+ Returns None if the file doesn't exist.
31
+ """
32
+ _include_path = Path(include_path)
33
+
34
+ # If `include_path` is already absolute or relative and exists, done
35
+ if _include_path.exists():
36
+ return str(_include_path.resolve())
37
+
38
+ # If base_path is a file, switch to its parent directory
39
+ _base_path = Path(base_dir)
40
+ if _base_path.is_file():
41
+ _base_path = _base_path.parent
42
+
43
+ try:
44
+ full_path = _base_path / include_path
45
+ if full_path.exists():
46
+ return str(full_path.resolve())
47
+ except Exception:
48
+ pass
49
+ return None
50
+
51
+
52
+ def get_literalinclude_content(path):
53
+ try:
54
+ with open(path) as f:
55
+ return f.read()
56
+ except Exception as e:
57
+ raise RuntimeError(
58
+ f"Failed to read literalinclude file {path}: {e}"
59
+ ) from e
60
+
61
+
62
+ def parse_rst(text: str, base_dir: Path) -> list[CodeSnippet]:
63
+ """
64
+ Parse an RST document into CodeSnippet objects, capturing:
65
+ - .. pytestmark: <mark>
66
+ - .. continue: <name>
67
+ - .. codeblock-name: <name>
68
+ - .. code-block:: python
69
+ """
70
+ snippets: list[CodeSnippet] = []
71
+ lines = text.splitlines()
72
+ n = len(lines)
73
+
74
+ pending_name: Optional[str] = None
75
+ pending_marks: list[str] = [CODEBLOCK_MARK]
76
+ pending_fixtures: list[str] = []
77
+ pending_continue: Optional[str] = None
78
+ i = 0
79
+
80
+ while i < n:
81
+ line = lines[i]
82
+
83
+ # --------------------------------------------------------------------
84
+ # Collect `.. pytestmark: xyz`
85
+ # --------------------------------------------------------------------
86
+ m = re.match(r"^\s*\.\.\s*pytestmark:\s*(\w+)\s*$", line)
87
+ if m:
88
+ pending_marks.append(m.group(1))
89
+ i += 1
90
+ continue
91
+
92
+ # --------------------------------------------------------------------
93
+ # Collect `.. pytestfixture: foo`
94
+ # --------------------------------------------------------------------
95
+ m = re.match(r"^\s*\.\.\s*pytestfixture:\s*(\w+)\s*$", line)
96
+ if m:
97
+ pending_fixtures.append(m.group(1))
98
+ i += 1
99
+ continue
100
+
101
+ # --------------------------------------------------------------------
102
+ # The `.. literalinclude` directive
103
+ # --------------------------------------------------------------------
104
+ if line.strip().startswith(".. literalinclude::"):
105
+ path = line.split(".. literalinclude::", 1)[1].strip()
106
+ name = None
107
+
108
+ # Look ahead for name
109
+ j = i + 1
110
+ while j < len(lines) and lines[j].strip():
111
+ if ":name:" in lines[j]:
112
+ name = lines[j].split(":name:", 1)[1].strip()
113
+ break
114
+ j += 1
115
+
116
+ if name and name.startswith("test_"):
117
+ full_path = resolve_literalinclude_path(base_dir, path)
118
+ if full_path:
119
+ snippet = CodeSnippet(
120
+ code=get_literalinclude_content(full_path),
121
+ line=i + 1,
122
+ name=name,
123
+ marks=pending_marks.copy(),
124
+ fixtures=pending_fixtures.copy(),
125
+ )
126
+ snippets.append(snippet)
127
+
128
+ # TODO: Is this needed?
129
+ # pending_marks.clear()
130
+ # pending_fixtures.clear()
131
+
132
+ i = j + 1
133
+ continue
134
+
135
+ # --------------------------------------------------------------------
136
+ # Collect `.. continue: foo`
137
+ # --------------------------------------------------------------------
138
+ m = re.match(r"^\s*\.\.\s*continue:\s*(\S+)\s*$", line)
139
+ if m:
140
+ pending_continue = m.group(1)
141
+ i += 1
142
+ continue
143
+
144
+ # --------------------------------------------------------------------
145
+ # Collect `.. codeblock-name: foo`
146
+ # --------------------------------------------------------------------
147
+ m = re.match(r"^\s*\.\.\s*codeblock-name:\s*(\S+)\s*$", line)
148
+ if m:
149
+ pending_name = m.group(1)
150
+ i += 1
151
+ continue
152
+
153
+ # --------------------------------------------------------------------
154
+ # The `.. code-block` directive
155
+ # --------------------------------------------------------------------
156
+ m = re.match(r"^(\s*)\.\. (?:code-block|code)::\s*(\w+)", line)
157
+ if m:
158
+ base_indent = len(m.group(1))
159
+ lang = m.group(2).lower()
160
+ if lang in ("python", "py", "python3"):
161
+ # Parse :name: option
162
+ name_val: Optional[str] = None
163
+ j = i + 1
164
+ while j < n:
165
+ ln = lines[j]
166
+ if not ln.strip():
167
+ j += 1
168
+ continue
169
+ indent = len(ln) - len(ln.lstrip())
170
+ if ln.lstrip().startswith(":") and indent > base_indent:
171
+ opt = ln.lstrip()
172
+ if opt.lower().startswith(":name:"):
173
+ name_val = opt.split(":", 2)[2].strip().split()[0]
174
+ j += 1
175
+ continue
176
+ break
177
+ # The j is first code line
178
+ if j >= n:
179
+ i = j
180
+ continue
181
+ first = lines[j]
182
+ content_indent = len(first) - len(first.lstrip())
183
+ if content_indent <= base_indent:
184
+ i = j
185
+ continue
186
+ # Collect code
187
+ buf: list[str] = []
188
+ k = j
189
+ while k < n:
190
+ ln = lines[k]
191
+ if not ln.strip():
192
+ buf.append("")
193
+ k += 1
194
+ continue
195
+ ind = len(ln) - len(ln.lstrip())
196
+ if ind >= content_indent:
197
+ buf.append(ln[content_indent:])
198
+ k += 1
199
+ else:
200
+ break
201
+ # Decide snippet name: continue overrides name_val/pending_name
202
+ if pending_continue:
203
+ sn_name = pending_continue
204
+ pending_continue = None
205
+ else:
206
+ sn_name = name_val or pending_name
207
+ sn_marks = pending_marks.copy()
208
+ sn_fixtures = pending_fixtures.copy()
209
+ pending_name = None
210
+ pending_marks.clear()
211
+ pending_fixtures.clear()
212
+
213
+ snippets.append(CodeSnippet(
214
+ name=sn_name,
215
+ code="\n".join(buf),
216
+ line=j + 1,
217
+ marks=sn_marks,
218
+ fixtures=sn_fixtures,
219
+ ))
220
+
221
+ i = k
222
+ continue
223
+ else:
224
+ i += 1
225
+ continue
226
+
227
+ # --------------------------------------------------------------------
228
+ # The literal-block via "::"
229
+ # --------------------------------------------------------------------
230
+ if line.rstrip().endswith("::") and pending_name:
231
+ # Similar override logic
232
+ if pending_continue:
233
+ sn_name = pending_continue
234
+ pending_continue = None
235
+ else:
236
+ sn_name = pending_name
237
+ sn_marks = pending_marks.copy()
238
+ sn_fixtures = pending_fixtures.copy()
239
+ pending_name = None
240
+ pending_marks.clear()
241
+ pending_fixtures.clear()
242
+ j = i + 1
243
+ if j < n and not lines[j].strip():
244
+ j += 1
245
+ if j >= n:
246
+ i = j
247
+ continue
248
+ first = lines[j]
249
+ content_indent = len(first) - len(first.lstrip())
250
+ buf: list[str] = []
251
+ k = j
252
+ while k < n:
253
+ ln = lines[k]
254
+ if not ln.strip():
255
+ buf.append("")
256
+ k += 1
257
+ continue
258
+ ind = len(ln) - len(ln.lstrip())
259
+ if ind >= content_indent:
260
+ buf.append(ln[content_indent:])
261
+ k += 1
262
+ else:
263
+ break
264
+ snippets.append(CodeSnippet(
265
+ name=sn_name,
266
+ code="\n".join(buf),
267
+ line=j + 1,
268
+ marks=sn_marks,
269
+ fixtures=sn_fixtures,
270
+ ))
271
+ i = k
272
+ continue
273
+
274
+ i += 1
275
+
276
+ return snippets
277
+
278
+
279
+ class RSTFile(pytest.File):
280
+ """Collect RST code-block tests as real test functions."""
281
+ def collect(self):
282
+ text = self.path.read_text(encoding="utf-8")
283
+ raw = parse_rst(text, self.path)
284
+
285
+ # Only keep test_* snippets
286
+ tests = [
287
+ sn for sn in raw if sn.name and sn.name.startswith(TEST_PREFIX)
288
+ ]
289
+ combined = group_snippets(tests)
290
+
291
+ for sn in combined:
292
+ # Bind the values we need so we don't close over `sn` itself
293
+ _sn_name = sn.name
294
+ _fpath = str(self.path)
295
+
296
+ # Build list of fixture names requested by this snippet
297
+ _fixture_names: list[str] = list(sn.fixtures)
298
+
299
+ # If snippet is marked as needing DB, also request the `db`
300
+ # fixture, unless user already added it explicitly.
301
+ if (
302
+ DJANGO_DB_MARKS.intersection(sn.marks)
303
+ and "db" not in _fixture_names
304
+ ):
305
+ _fixture_names.append("db")
306
+
307
+ def make_func(
308
+ code,
309
+ sn_name=_sn_name,
310
+ fpath=_fpath,
311
+ fixture_names=_fixture_names,
312
+ ):
313
+ # This inner function *actually* has a **fixtures signature,
314
+ # but we override __signature__ so pytest passes the right
315
+ # fixtures and names.
316
+ def test_block(**fixtures):
317
+ compiled = compile(code, fpath, "exec")
318
+ try:
319
+ # Make fixtures available as top-level names
320
+ # inside the executed snippet.
321
+ exec(compiled, dict(fixtures))
322
+ except Exception as err:
323
+ raise Exception(
324
+ f"Error in "
325
+ f"codeblock `{sn_name}` in {fpath}:\n"
326
+ f"\n{textwrap.indent(code, prefix=' ')}\n\n"
327
+ f"{traceback.format_exc()}"
328
+ ) from err
329
+
330
+ # Tell pytest which fixture arguments this test has:
331
+ test_block.__signature__ = inspect.Signature(
332
+ [
333
+ inspect.Parameter(
334
+ name,
335
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
336
+ )
337
+ for name in fixture_names
338
+ ]
339
+ )
340
+ return test_block
341
+
342
+ callobj = make_func(sn.code)
343
+
344
+ fn = pytest.Function.from_parent(
345
+ parent=self,
346
+ name=sn.name,
347
+ callobj=callobj
348
+ )
349
+ # Re-apply any pytest.mark.<foo> markers
350
+ for m in sn.marks:
351
+ fn.add_marker(getattr(pytest.mark, m))
352
+ yield fn
File without changes
@@ -0,0 +1,121 @@
1
+ from pytest_codeblock.collector import CodeSnippet, group_snippets
2
+ from pytest_codeblock.md import parse_markdown
3
+ from pytest_codeblock.rst import (
4
+ get_literalinclude_content,
5
+ parse_rst,
6
+ resolve_literalinclude_path,
7
+ )
8
+
9
+ __author__ = "Artur Barseghyan <artur.barseghyan@gmail.com>"
10
+ __copyright__ = "2025 Artur Barseghyan"
11
+ __license__ = "MIT"
12
+ __all__ = (
13
+ "test_group_snippets_different_names",
14
+ "test_group_snippets_merges_named",
15
+ "test_parse_markdown_simple",
16
+ "test_parse_markdown_with_pytestmark",
17
+ "test_parse_rst_literalinclude",
18
+ "test_parse_rst_simple",
19
+ "test_resolve_literalinclude_and_content",
20
+ )
21
+
22
+
23
+ def test_group_snippets_merges_named():
24
+ # Two snippets with the same name should be combined
25
+ sn1 = CodeSnippet(name="foo", code="a=1", line=1, marks=["codeblock"])
26
+ sn2 = CodeSnippet(name="foo", code="b=2", line=2, marks=["codeblock", "m"])
27
+ combined = group_snippets([sn1, sn2])
28
+ assert len(combined) == 1
29
+ cs = combined[0]
30
+ assert cs.name == "foo"
31
+ # Both code parts should appear
32
+ assert "a=1" in cs.code
33
+ assert "b=2" in cs.code
34
+ # Marks should accumulate
35
+ assert "m" in cs.marks
36
+
37
+
38
+ def test_group_snippets_different_names():
39
+ # Snippets with different names are not grouped
40
+ sn1 = CodeSnippet(name="foo", code="x=1", line=1)
41
+ sn2 = CodeSnippet(name="bar", code="y=2", line=2)
42
+ combined = group_snippets([sn1, sn2])
43
+ assert len(combined) == 2
44
+ assert combined[0].name.startswith("foo")
45
+ assert combined[1].name.startswith("bar")
46
+
47
+
48
+ def test_parse_markdown_simple():
49
+ text = """
50
+ ```python name=test_example
51
+ x=1
52
+ assert x==1
53
+ ```"""
54
+ snippets = parse_markdown(text)
55
+ assert len(snippets) == 1
56
+ sn = snippets[0]
57
+ assert sn.name == "test_example"
58
+ assert "x=1" in sn.code
59
+
60
+
61
+ def test_parse_markdown_with_pytestmark():
62
+ text = """
63
+ <!-- pytestmark: django_db -->
64
+ ```python name=test_db
65
+ from django.db import models
66
+ ```"""
67
+ snippets = parse_markdown(text)
68
+ assert len(snippets) == 1
69
+ sn = snippets[0]
70
+ # Should include both default and django_db marks
71
+ assert "django_db" in sn.marks
72
+ assert "codeblock" in sn.marks
73
+
74
+
75
+ def test_resolve_literalinclude_and_content(tmp_path):
76
+ base = tmp_path / "dir"
77
+ base.mkdir()
78
+ file = base / "a.py"
79
+ file.write_text("print('hello')")
80
+ # Absolute path resolution
81
+ abs_path = resolve_literalinclude_path(base, str(file))
82
+ assert abs_path == str(file.resolve())
83
+ # Relative path resolution
84
+ rel_path = resolve_literalinclude_path(base, "a.py")
85
+ assert rel_path == str(file.resolve())
86
+ # Content read
87
+ content = get_literalinclude_content(str(file))
88
+ assert content == "print('hello')"
89
+
90
+
91
+ def test_parse_rst_simple(tmp_path):
92
+ # Basic code-block directive
93
+ rst = """
94
+ .. code-block:: python
95
+ :name: test_simple
96
+
97
+ a=2
98
+ assert a==2
99
+ """
100
+ snippets = parse_rst(rst, tmp_path)
101
+ assert len(snippets) == 1
102
+ sn = snippets[0]
103
+ assert sn.name == "test_simple"
104
+ assert "a=2" in sn.code
105
+
106
+
107
+ def test_parse_rst_literalinclude(tmp_path):
108
+ # Create an external file to include
109
+ include_dir = tmp_path / "inc"
110
+ include_dir.mkdir()
111
+ target = include_dir / "foo.py"
112
+ target.write_text("z=3\nassert z==3")
113
+ rst = f"""
114
+ .. literalinclude:: {target.name}
115
+ :name: test_li
116
+ """
117
+ snippets = parse_rst(rst, include_dir)
118
+ assert len(snippets) == 1
119
+ sn = snippets[0]
120
+ assert sn.name == "test_li"
121
+ assert "z=3" in sn.code
@@ -0,0 +1,301 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytest-codeblock
3
+ Version: 0.3.1
4
+ Summary: Pytest plugin to collect and test code blocks in reStructuredText and Markdown files.
5
+ Author-email: Artur Barseghyan <artur.barseghyan@gmail.com>
6
+ Maintainer-email: Artur Barseghyan <artur.barseghyan@gmail.com>
7
+ License-Expression: MIT
8
+ Project-URL: Homepage, https://github.com/barseghyanartur/pytest-codeblock/
9
+ Project-URL: Repository, https://github.com/barseghyanartur/pytest-codeblock/
10
+ Project-URL: Issues, https://github.com/barseghyanartur/pytest-codeblock/issues
11
+ Project-URL: Documentation, https://pytest-codeblock.readthedocs.io/
12
+ Project-URL: Changelog, https://pytest-codeblock.readthedocs.io/en/latest/changelog.html
13
+ Keywords: pytest,plugin,documentation,code blocks,markdown,rst
14
+ Classifier: Framework :: Pytest
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Programming Language :: Python
25
+ Classifier: Topic :: Software Development :: Testing
26
+ Classifier: Topic :: Software Development
27
+ Requires-Python: >=3.9
28
+ Description-Content-Type: text/x-rst
29
+ License-File: LICENSE
30
+ Requires-Dist: pytest
31
+ Provides-Extra: all
32
+ Requires-Dist: pytest-codeblock[build,dev,docs,test]; extra == "all"
33
+ Provides-Extra: dev
34
+ Requires-Dist: detect-secrets; extra == "dev"
35
+ Requires-Dist: doc8; extra == "dev"
36
+ Requires-Dist: ipython; extra == "dev"
37
+ Requires-Dist: mypy; extra == "dev"
38
+ Requires-Dist: pydoclint; extra == "dev"
39
+ Requires-Dist: ruff; extra == "dev"
40
+ Requires-Dist: twine; extra == "dev"
41
+ Requires-Dist: uv; extra == "dev"
42
+ Provides-Extra: test
43
+ Requires-Dist: django; extra == "test"
44
+ Requires-Dist: fake.py; extra == "test"
45
+ Requires-Dist: moto[s3]; extra == "test"
46
+ Requires-Dist: openai; extra == "test"
47
+ Requires-Dist: pytest; extra == "test"
48
+ Requires-Dist: pytest-cov; extra == "test"
49
+ Requires-Dist: pytest-django; extra == "test"
50
+ Provides-Extra: docs
51
+ Requires-Dist: sphinx<6.0; extra == "docs"
52
+ Requires-Dist: sphinx-autobuild; extra == "docs"
53
+ Requires-Dist: sphinx-rtd-theme>=1.3.0; extra == "docs"
54
+ Requires-Dist: sphinx-no-pragma; extra == "docs"
55
+ Requires-Dist: sphinx-llms-txt-link; extra == "docs"
56
+ Provides-Extra: build
57
+ Requires-Dist: build; extra == "build"
58
+ Requires-Dist: twine; extra == "build"
59
+ Requires-Dist: wheel; extra == "build"
60
+ Dynamic: license-file
61
+
62
+ ================
63
+ pytest-codeblock
64
+ ================
65
+
66
+ .. External references
67
+ .. _reStructuredText: https://docutils.sourceforge.io/rst.html
68
+ .. _Markdown: https://daringfireball.net/projects/markdown/
69
+ .. _pytest: https://docs.pytest.org
70
+ .. _Django: https://www.djangoproject.com
71
+ .. _pip: https://pypi.org/project/pip/
72
+ .. _uv: https://pypi.org/project/uv/
73
+ .. _fake.py: https://github.com/barseghyanartur/fake.py
74
+ .. _boto3: https://github.com/boto/boto3
75
+ .. _moto: https://github.com/getmoto/moto
76
+ .. _openai: https://github.com/openai/openai-python
77
+ .. _Ollama: https://github.com/ollama/ollama
78
+
79
+ .. Internal references
80
+
81
+ .. _pytest-codeblock: https://github.com/barseghyanartur/pytest-codeblock/
82
+ .. _Read the Docs: http://pytest-codeblock.readthedocs.io/
83
+ .. _Examples: https://github.com/barseghyanartur/pytest-codeblock/tree/main/examples
84
+ .. _Contributor guidelines: https://pytest-codeblock.readthedocs.io/en/latest/contributor_guidelines.html
85
+ .. _reStructuredText docs: https://pytest-codeblock.readthedocs.io/en/latest/restructured_text.html
86
+ .. _Markdown docs: https://pytest-codeblock.readthedocs.io/en/latest/markdown.html
87
+ .. _llms.txt: https://barseghyanartur.github.io/pytest-codeblock/llms.txt
88
+
89
+ Test your documentation code blocks.
90
+
91
+ .. image:: https://img.shields.io/pypi/v/pytest-codeblock.svg
92
+ :target: https://pypi.python.org/pypi/pytest-codeblock
93
+ :alt: PyPI Version
94
+
95
+ .. image:: https://img.shields.io/pypi/pyversions/pytest-codeblock.svg
96
+ :target: https://pypi.python.org/pypi/pytest-codeblock/
97
+ :alt: Supported Python versions
98
+
99
+ .. image:: https://github.com/barseghyanartur/pytest-codeblock/actions/workflows/test.yml/badge.svg?branch=main
100
+ :target: https://github.com/barseghyanartur/pytest-codeblock/actions
101
+ :alt: Build Status
102
+
103
+ .. image:: https://readthedocs.org/projects/pytest-codeblock/badge/?version=latest
104
+ :target: http://pytest-codeblock.readthedocs.io
105
+ :alt: Documentation Status
106
+
107
+ .. image:: https://img.shields.io/badge/docs-llms.txt-blue
108
+ :target: http://pytest-codeblock.readthedocs.io/en/latest/llms.txt
109
+ :alt: llms.txt - documentation for LLMs
110
+
111
+ .. image:: https://img.shields.io/badge/license-MIT-blue.svg
112
+ :target: https://github.com/barseghyanartur/pytest-codeblock/#License
113
+ :alt: MIT
114
+
115
+ .. image:: https://coveralls.io/repos/github/barseghyanartur/pytest-codeblock/badge.svg?branch=main&service=github
116
+ :target: https://coveralls.io/github/barseghyanartur/pytest-codeblock?branch=main
117
+ :alt: Coverage
118
+
119
+ `pytest-codeblock`_ is a `Pytest`_ plugin that discovers Python code examples
120
+ in your `reStructuredText`_ and `Markdown`_ documentation files and runs them
121
+ as part of your test suite. This ensures your docs stay correct and up-to-date.
122
+
123
+ Features
124
+ ========
125
+
126
+ - **reStructuredText and Markdown support**: Automatically find and test code
127
+ blocks in `reStructuredText`_ (``.rst``) and `Markdown`_ (``.md``) files.
128
+ The only requirement here is that your code blocks shall
129
+ have a name starting with ``test_``.
130
+ - **Grouping by name**: Split a single example across multiple code blocks;
131
+ the plugin concatenates them into one test.
132
+ - **Pytest markers support**: Add existing or custom `pytest`_ markers
133
+ to the code blocks and hook into the tests life-cycle using ``conftest.py``.
134
+ - **Pytest fixtures support**: Request existing or custom `pytest`_ fixtures
135
+ for the code blocks.
136
+
137
+ Prerequisites
138
+ =============
139
+ - Python 3.9+
140
+ - `pytest`_ is the only required dependency
141
+
142
+ Documentation
143
+ =============
144
+ - Documentation is available on `Read the Docs`_.
145
+ - For `reStructuredText`_, see a dedicated `reStructuredText docs`_.
146
+ - For `Markdown`_, see a dedicated `Markdown docs`_.
147
+ - Both `reStructuredText docs`_ and `Markdown docs`_ have extensive
148
+ documentation on `pytest`_ markers and corresponding ``conftest.py`` hooks.
149
+ - For guidelines on contributing check the `Contributor guidelines`_.
150
+
151
+ Installation
152
+ ============
153
+
154
+ Install with `pip`_:
155
+
156
+ .. code-block:: sh
157
+
158
+ pip install pytest-codeblock
159
+
160
+ Or install with `uv`_:
161
+
162
+ .. code-block:: sh
163
+
164
+ uv pip install pytest-codeblock
165
+
166
+ .. _configuration:
167
+
168
+ Configuration
169
+ =============
170
+ No configuration needed. All your `.rst` and `.md` files shall be picked
171
+ automatically.
172
+
173
+ Usage
174
+ =====
175
+ reStructruredText usage
176
+ -----------------------
177
+ Any code directive, such as ``.. code-block:: python``, ``.. code:: python``,
178
+ or literal blocks with a preceding ``.. codeblock-name: <name>``, will be
179
+ collected and executed automatically by `pytest`_.
180
+
181
+ ``code-block`` directive example
182
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
183
+
184
+ .. note:: Note that ``:name:`` value has a ``test_`` prefix.
185
+
186
+ *Filename: README.rst*
187
+
188
+ .. code-block:: rst
189
+
190
+ .. code-block:: python
191
+ :name: test_basic_example
192
+
193
+ import math
194
+
195
+ result = math.pow(3, 2)
196
+ assert result == 9
197
+
198
+
199
+ ``literalinclude`` directive example
200
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
201
+
202
+ .. note:: Note that ``:name:`` value has a ``test_`` prefix.
203
+
204
+ *Filename: README.rst*
205
+
206
+ .. code-block:: rst
207
+
208
+ .. literalinclude:: examples/python/basic_example.py
209
+ :name: test_li_basic_example
210
+
211
+ See a dedicated `reStructuredText docs`_ for more.
212
+
213
+ Markdown usage
214
+ --------------
215
+
216
+ Any fenced code block with a recognized Python language tag (e.g., ``python``,
217
+ ``py``) will be collected and executed automatically by `pytest`_.
218
+
219
+ .. note:: Note that ``name`` value has a ``test_`` prefix.
220
+
221
+ *Filename: README.md*
222
+
223
+ .. code-block:: markdown
224
+
225
+ ```python name=test_basic_example
226
+ import math
227
+
228
+ result = math.pow(3, 2)
229
+ assert result == 9
230
+ ```
231
+
232
+ See a dedicated `Markdown docs`_ for more.
233
+
234
+ Tests
235
+ =====
236
+
237
+ Run the tests with `pytest`_:
238
+
239
+ .. code-block:: sh
240
+
241
+ pytest
242
+
243
+ Troubleshooting
244
+ ===============
245
+ If something doesn't work, try to add this to your pyproject.toml:
246
+
247
+ *Filename: pyproject.toml*
248
+
249
+ .. code-block:: text
250
+
251
+ [tool.pytest.ini_options]
252
+ testpaths = [
253
+ "**/*.rst",
254
+ "**/*.md",
255
+ ]
256
+
257
+ Writing documentation
258
+ =====================
259
+
260
+ Keep the following hierarchy.
261
+
262
+ .. code-block:: text
263
+
264
+ =====
265
+ title
266
+ =====
267
+
268
+ header
269
+ ======
270
+
271
+ sub-header
272
+ ----------
273
+
274
+ sub-sub-header
275
+ ~~~~~~~~~~~~~~
276
+
277
+ sub-sub-sub-header
278
+ ^^^^^^^^^^^^^^^^^^
279
+
280
+ sub-sub-sub-sub-header
281
+ ++++++++++++++++++++++
282
+
283
+ sub-sub-sub-sub-sub-header
284
+ **************************
285
+
286
+ License
287
+ =======
288
+
289
+ MIT
290
+
291
+ Support
292
+ =======
293
+ For security issues contact me at the e-mail given in the `Author`_ section.
294
+
295
+ For overall issues, go
296
+ to `GitHub <https://github.com/barseghyanartur/pytest-codeblock/issues>`_.
297
+
298
+ Author
299
+ ======
300
+
301
+ Artur Barseghyan <artur.barseghyan@gmail.com>
@@ -0,0 +1,13 @@
1
+ pytest_codeblock/__init__.py,sha256=pkjBUtLIFIZ5Nj_xIT36AG1-GdScfD78BBv94MtQ5wo,858
2
+ pytest_codeblock/collector.py,sha256=uepTklmwM-zhH4cKCaOHr63Y7oyg_tjFdoQVsy3oSxg,1618
3
+ pytest_codeblock/constants.py,sha256=Bpr0B0uHAJOaOmoSO9v_xr1FkV_8KJ0-TfP3hEmmdbw,326
4
+ pytest_codeblock/md.py,sha256=8P3fqqzKDjcfW5Zx7-zYmtSRG9BMUOzHSQuBMjr-F9s,7807
5
+ pytest_codeblock/rst.py,sha256=WbnofvWASEhPHq6wO1Qfa4fYFhOh4Hq3zs1tMU7T5rQ,12163
6
+ pytest_codeblock/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ pytest_codeblock/tests/test_pytest_codeblock.py,sha256=JmrV6rD4kppzunBSAp3FoweoiA0zOUtneYTFXdwYj90,3451
8
+ pytest_codeblock-0.3.1.dist-info/licenses/LICENSE,sha256=dOWhRtenNzp0haV2LO9YRYb_-GCmPdwVJeTZ4MarauY,1073
9
+ pytest_codeblock-0.3.1.dist-info/METADATA,sha256=tcASyLxRDME3qY9lWkmkaUMEzQh0NzeNDi7bQHRbdTA,9131
10
+ pytest_codeblock-0.3.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
+ pytest_codeblock-0.3.1.dist-info/entry_points.txt,sha256=jWxHxGWLzT4XZBSUMZ0aUnyDzOaDyjf-KnAIsYgObm0,47
12
+ pytest_codeblock-0.3.1.dist-info/top_level.txt,sha256=H2HyfKDFEj_5vWkbB6qITEjB-YgKfDxCPpIt_983K4g,17
13
+ pytest_codeblock-0.3.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ pytest_codeblock = pytest_codeblock
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Artur Barseghyan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ pytest_codeblock