pytest-markdown-docs 0.7.0__tar.gz → 0.8.0__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_markdown_docs-0.7.0 → pytest_markdown_docs-0.8.0}/.github/workflows/ci.yml +1 -1
  2. {pytest_markdown_docs-0.7.0 → pytest_markdown_docs-0.8.0}/.pre-commit-config.yaml +1 -1
  3. {pytest_markdown_docs-0.7.0 → pytest_markdown_docs-0.8.0}/PKG-INFO +5 -4
  4. pytest_markdown_docs-0.8.0/playground/conftest.py +17 -0
  5. pytest_markdown_docs-0.8.0/playground/foo.md +6 -0
  6. pytest_markdown_docs-0.8.0/playground/foo.py +3 -0
  7. {pytest_markdown_docs-0.7.0 → pytest_markdown_docs-0.8.0}/pyproject.toml +3 -3
  8. pytest_markdown_docs-0.8.0/src/pytest_markdown_docs/_runners.py +133 -0
  9. pytest_markdown_docs-0.8.0/src/pytest_markdown_docs/definitions.py +19 -0
  10. {pytest_markdown_docs-0.7.0 → pytest_markdown_docs-0.8.0}/src/pytest_markdown_docs/plugin.py +96 -122
  11. {pytest_markdown_docs-0.7.0 → pytest_markdown_docs-0.8.0}/tests/plugin_test.py +42 -3
  12. pytest_markdown_docs-0.8.0/uv.lock +379 -0
  13. pytest_markdown_docs-0.7.0/uv.lock +0 -356
  14. {pytest_markdown_docs-0.7.0 → pytest_markdown_docs-0.8.0}/.github/pull_request_template.md +0 -0
  15. {pytest_markdown_docs-0.7.0 → pytest_markdown_docs-0.8.0}/.github/workflows/check.yml +0 -0
  16. {pytest_markdown_docs-0.7.0 → pytest_markdown_docs-0.8.0}/.github/workflows/codeql.yml +0 -0
  17. {pytest_markdown_docs-0.7.0 → pytest_markdown_docs-0.8.0}/.gitignore +0 -0
  18. {pytest_markdown_docs-0.7.0 → pytest_markdown_docs-0.8.0}/LICENSE +0 -0
  19. {pytest_markdown_docs-0.7.0 → pytest_markdown_docs-0.8.0}/Makefile +0 -0
  20. {pytest_markdown_docs-0.7.0 → pytest_markdown_docs-0.8.0}/README.md +0 -0
  21. {pytest_markdown_docs-0.7.0 → pytest_markdown_docs-0.8.0}/pytest-markdown-docs.iml +0 -0
  22. {pytest_markdown_docs-0.7.0 → pytest_markdown_docs-0.8.0}/src/pytest_markdown_docs/__init__.py +0 -0
  23. {pytest_markdown_docs-0.7.0 → pytest_markdown_docs-0.8.0}/src/pytest_markdown_docs/hooks.py +0 -0
  24. {pytest_markdown_docs-0.7.0 → pytest_markdown_docs-0.8.0}/src/pytest_markdown_docs/py.typed +0 -0
  25. {pytest_markdown_docs-0.7.0 → pytest_markdown_docs-0.8.0}/tests/conftest.py +0 -0
  26. {pytest_markdown_docs-0.7.0 → pytest_markdown_docs-0.8.0}/tests/support/docstring_error_after.py +0 -0
  27. {pytest_markdown_docs-0.7.0 → pytest_markdown_docs-0.8.0}/tests/support/docstring_error_before.py +0 -0
@@ -6,7 +6,7 @@ jobs:
6
6
  runs-on: ubuntu-latest
7
7
  strategy:
8
8
  matrix:
9
- python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
9
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
10
10
  steps:
11
11
  - uses: actions/checkout@v4
12
12
 
@@ -1,6 +1,6 @@
1
1
  repos:
2
2
  - repo: https://github.com/charliermarsh/ruff-pre-commit
3
- rev: "v0.2.1"
3
+ rev: "v0.9.10"
4
4
  hooks:
5
5
  - id: ruff
6
6
  # Autofix, and respect `exclude` and `extend-exclude` settings.
@@ -1,11 +1,12 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: pytest-markdown-docs
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Run markdown code fences through pytest
5
5
  Author: Modal Labs
6
6
  Author-email: Elias Freider <elias@modal.com>
7
- License: MIT
8
- Requires-Python: >=3.8
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.9
9
10
  Requires-Dist: markdown-it-py<4.0,>=2.2.0
10
11
  Requires-Dist: pytest>=7.0.0
11
12
  Description-Content-Type: text/markdown
@@ -0,0 +1,17 @@
1
+ import time
2
+
3
+ import pytest
4
+ import pytest_asyncio
5
+ import asyncio
6
+
7
+
8
+ @pytest_asyncio.fixture()
9
+ async def async_fix():
10
+ await asyncio.sleep(1)
11
+ yield "ahello"
12
+
13
+
14
+ @pytest.fixture()
15
+ def non_async_fix():
16
+ time.sleep(1)
17
+ yield "hello"
@@ -0,0 +1,6 @@
1
+ from playground.conftest import non_async_fix
2
+
3
+ ```py fixture:async_fix fixture:non_async_fix
4
+ print(async_fix)
5
+ print(non_async_fix)
6
+ ```
@@ -0,0 +1,3 @@
1
+ def test_foo(async_fix, non_async_fix):
2
+ print(async_fix)
3
+ print(non_async_fix)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pytest-markdown-docs"
3
- version = "0.7.0"
3
+ version = "0.8.0"
4
4
  description = "Run markdown code fences through pytest"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -8,7 +8,7 @@ authors = [
8
8
  { name = "Elias Freider", email = "elias@modal.com" }
9
9
  ]
10
10
  license = "MIT"
11
- requires-python = ">=3.8"
11
+ requires-python = ">=3.9"
12
12
  dependencies = [
13
13
  "markdown-it-py>=2.2.0,<4.0",
14
14
  "pytest>=7.0.0",
@@ -28,5 +28,5 @@ dev-dependencies = [
28
28
  "mypy>=1.12.1",
29
29
  "pre-commit>=3.5.0",
30
30
  "pytest~=8.1.0",
31
- "ruff>=0.7.0",
31
+ "ruff~=0.9.10",
32
32
  ]
@@ -0,0 +1,133 @@
1
+ import abc
2
+ import ast
3
+ import traceback
4
+ import typing
5
+ from abc import abstractmethod
6
+
7
+ import pytest
8
+
9
+ from pytest_markdown_docs.definitions import FenceTestDefinition
10
+
11
+ _default_runner: typing.Optional["_Runner"] = None
12
+ _registered_runners = {}
13
+
14
+
15
+ class _Runner(metaclass=abc.ABCMeta):
16
+ @abstractmethod
17
+ def runtest(self, test: FenceTestDefinition, args: dict[str, typing.Any]): ...
18
+
19
+ @abstractmethod
20
+ def repr_failure(
21
+ self,
22
+ test: FenceTestDefinition,
23
+ excinfo: pytest.ExceptionInfo[BaseException],
24
+ style=None,
25
+ ): ...
26
+
27
+
28
+ RUNNER_TYPE = typing.TypeVar("RUNNER_TYPE", bound=type[_Runner])
29
+
30
+
31
+ def register_runner(*, default: bool = False):
32
+ """Decorator for adding custom runners
33
+
34
+ e.g.
35
+ @register_runner()
36
+ def my_runner(src):
37
+ exec(src)
38
+ """
39
+
40
+ def decorator(r: RUNNER_TYPE) -> RUNNER_TYPE:
41
+ global _default_runner
42
+ runner = r()
43
+ _registered_runners[r.__name__] = runner
44
+ if default:
45
+ _default_runner = runner
46
+ return r
47
+
48
+ return decorator
49
+
50
+
51
+ @register_runner(default=True)
52
+ class DefaultRunner(_Runner):
53
+ def runtest(self, test: FenceTestDefinition, args):
54
+ try:
55
+ tree = ast.parse(test.source, filename=test.source_path)
56
+ except SyntaxError:
57
+ raise
58
+
59
+ try:
60
+ # if we don't compile the code, it seems we get name lookup errors
61
+ # for functions etc. when doing cross-calls across inline functions
62
+ compiled = compile(
63
+ tree, filename=test.source_path, mode="exec", dont_inherit=True
64
+ )
65
+ except SyntaxError:
66
+ raise
67
+
68
+ exec(compiled, args)
69
+
70
+ def repr_failure(
71
+ self,
72
+ test: FenceTestDefinition,
73
+ excinfo: pytest.ExceptionInfo[BaseException],
74
+ style=None,
75
+ ):
76
+ """This renders a traceback starting at the stack from of the code fence
77
+
78
+ Also displays a line-numbered excerpt of the code fence that ran.
79
+ """
80
+
81
+ rawlines = test.source.rstrip("\n").split("\n")
82
+
83
+ # custom formatted traceback to translate line numbers and markdown files
84
+ traceback_lines = []
85
+ stack_summary = traceback.StackSummary.extract(traceback.walk_tb(excinfo.tb))
86
+ start_capture = False
87
+
88
+ start_line = test.start_line
89
+
90
+ for frame_summary in stack_summary:
91
+ if frame_summary.filename == str(test.source_path):
92
+ # start capturing frames the first time we enter user code
93
+ start_capture = True
94
+
95
+ if start_capture:
96
+ lineno = frame_summary.lineno
97
+ line = frame_summary.line or ""
98
+ linespec = f"line {lineno}"
99
+ traceback_lines.append(
100
+ f""" File "{frame_summary.filename}", {linespec}, in {frame_summary.name}"""
101
+ )
102
+ traceback_lines.append(f" {line.lstrip()}")
103
+
104
+ maxdigits = len(str(len(rawlines)))
105
+ code_margin = " "
106
+ numbered_code = "\n".join(
107
+ [
108
+ f"{i:>{maxdigits}}{code_margin}{line}"
109
+ for i, line in enumerate(rawlines[start_line:], start_line + 1)
110
+ ]
111
+ )
112
+
113
+ pretty_traceback = "\n".join(traceback_lines)
114
+ pt = f"""Traceback (most recent call last):
115
+ {pretty_traceback}
116
+ {excinfo.exconly()}"""
117
+
118
+ return f"""Error in code block:
119
+ {maxdigits * " "}{code_margin}```
120
+ {numbered_code}
121
+ {maxdigits * " "}{code_margin}```
122
+ {pt}
123
+ """
124
+
125
+
126
+ def get_runner(name: typing.Optional[str]) -> _Runner:
127
+ if name is None:
128
+ assert _default_runner is not None
129
+ return _default_runner
130
+
131
+ if name not in _registered_runners:
132
+ raise Exception(f"No such pytest-markdown-docs runner: {name}")
133
+ return _registered_runners[name]
@@ -0,0 +1,19 @@
1
+ import pathlib
2
+ import typing
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class FenceTestDefinition:
8
+ source: str
9
+ fixture_names: typing.Sequence[str]
10
+ start_line: int
11
+ source_path: pathlib.Path
12
+ runner_name: typing.Optional[str]
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ObjectTestDefinition:
17
+ intra_object_index: int
18
+ object_name: str
19
+ fence_test: FenceTestDefinition
@@ -1,9 +1,6 @@
1
- import ast
2
1
  import inspect
3
- import traceback
4
2
  import types
5
3
  import pathlib
6
- from dataclasses import dataclass
7
4
 
8
5
  import pytest
9
6
  import typing
@@ -15,7 +12,8 @@ from _pytest.pathlib import import_path
15
12
  import logging
16
13
 
17
14
  from pytest_markdown_docs import hooks
18
-
15
+ from pytest_markdown_docs.definitions import FenceTestDefinition, ObjectTestDefinition
16
+ from pytest_markdown_docs._runners import get_runner
19
17
 
20
18
  if pytest.version_tuple >= (8, 0, 0):
21
19
  from _pytest.fixtures import TopRequest
@@ -37,23 +35,12 @@ class FenceSyntax(Enum):
37
35
  superfences = "superfences"
38
36
 
39
37
 
40
- @dataclass
41
- class FenceTest:
42
- source: str
43
- fixture_names: typing.List[str]
44
- start_line: int
45
-
46
-
47
- @dataclass
48
- class ObjectTest:
49
- intra_object_index: int
50
- object_name: str
51
- fence_test: FenceTest
52
-
53
-
54
38
  def get_docstring_start_line(obj) -> typing.Optional[int]:
55
39
  # Get the source lines and the starting line number of the object
56
- source_lines, start_line = inspect.getsourcelines(obj)
40
+ try:
41
+ source_lines, start_line = inspect.getsourcelines(obj)
42
+ except OSError:
43
+ return None
57
44
 
58
45
  # Find the line in the source code that starts with triple quotes (""" or ''')
59
46
  for idx, line in enumerate(source_lines):
@@ -69,18 +56,18 @@ class MarkdownInlinePythonItem(pytest.Item):
69
56
  self,
70
57
  name: str,
71
58
  parent: typing.Union["MarkdownDocstringCodeModule", "MarkdownTextFile"],
72
- code: str,
73
- fixture_names: typing.List[str],
74
- start_line: int,
59
+ test_definition: FenceTestDefinition,
75
60
  ) -> None:
76
61
  super().__init__(name, parent)
77
62
  self.add_marker(MARKER_NAME)
78
- self.code = code
63
+ self.code = test_definition.source
79
64
  self.obj = None
80
- self.user_properties.append(("code", code))
81
- self.start_line = start_line
82
- self.fixturenames = fixture_names
65
+ self.test_definition = test_definition
66
+ self.user_properties.append(("code", test_definition.source))
67
+ self.start_line = test_definition.start_line
68
+ self.fixturenames = test_definition.fixture_names
83
69
  self.nofuncargs = True
70
+ self.runner_name = test_definition.runner_name
84
71
 
85
72
  def setup(self):
86
73
  def func() -> None:
@@ -92,6 +79,7 @@ class MarkdownInlinePythonItem(pytest.Item):
92
79
  )
93
80
  self.fixture_request = TopRequest(self, _ispytest=True)
94
81
  self.fixture_request._fillfixtures()
82
+ self.runner = get_runner(self.runner_name)
95
83
 
96
84
  def runtest(self):
97
85
  global_sets = self.parent.config.hook.pytest_markdown_docs_globals()
@@ -111,79 +99,36 @@ class MarkdownInlinePythonItem(pytest.Item):
111
99
  for argname, value in self.funcargs.items():
112
100
  all_globals[argname] = value
113
101
 
114
- try:
115
- tree = ast.parse(self.code, filename=self.path)
116
- except SyntaxError:
117
- raise
118
-
119
- try:
120
- # if we don't compile the code, it seems we get name lookup errors
121
- # for functions etc. when doing cross-calls across inline functions
122
- compiled = compile(tree, filename=self.path, mode="exec", dont_inherit=True)
123
- except SyntaxError:
124
- raise
125
-
126
- exec(compiled, all_globals)
102
+ # this ensures that pytest's stdout/stderr capture works during the test:
103
+ capman = self.config.pluginmanager.getplugin("capturemanager")
104
+ with capman.global_and_fixture_disabled():
105
+ self.runner.runtest(self.test_definition, all_globals)
127
106
 
128
107
  def repr_failure(
129
108
  self,
130
109
  excinfo: ExceptionInfo[BaseException],
131
110
  style=None,
132
- ):
133
- rawlines = self.code.rstrip("\n").split("\n")
134
-
135
- # custom formatted traceback to translate line numbers and markdown files
136
- traceback_lines = []
137
- stack_summary = traceback.StackSummary.extract(traceback.walk_tb(excinfo.tb))
138
- start_capture = False
139
-
140
- start_line = self.start_line
141
-
142
- for frame_summary in stack_summary:
143
- if frame_summary.filename == str(self.path):
144
- # start capturing frames the first time we enter user code
145
- start_capture = True
146
-
147
- if start_capture:
148
- lineno = frame_summary.lineno
149
- line = frame_summary.line or ""
150
- linespec = f"line {lineno}"
151
- traceback_lines.append(
152
- f""" File "{frame_summary.filename}", {linespec}, in {frame_summary.name}"""
153
- )
154
- traceback_lines.append(f" {line.lstrip()}")
155
-
156
- maxdigits = len(str(len(rawlines)))
157
- code_margin = " "
158
- numbered_code = "\n".join(
159
- [
160
- f"{i:>{maxdigits}}{code_margin}{line}"
161
- for i, line in enumerate(rawlines[start_line:], start_line + 1)
162
- ]
163
- )
111
+ ) -> str:
112
+ return self.runner.repr_failure(self.test_definition, excinfo, style)
164
113
 
165
- pretty_traceback = "\n".join(traceback_lines)
166
- pt = f"""Traceback (most recent call last):
167
- {pretty_traceback}
168
- {excinfo.exconly()}"""
114
+ def reportinfo(self):
115
+ return self.path, self.start_line, self.name
169
116
 
170
- return f"""Error in code block:
171
- {maxdigits * " "}{code_margin}```
172
- {numbered_code}
173
- {maxdigits * " "}{code_margin}```
174
- {pt}
175
- """
176
117
 
177
- def reportinfo(self):
178
- return self.name, 0, self.nodeid
118
+ def get_prefixed_strings(
119
+ seq: typing.Collection[str], prefix: str
120
+ ) -> typing.Sequence[str]:
121
+ # return strings matching a prefix, with the prefix stripped
122
+ return tuple(s[len(prefix) :] for s in seq if s.startswith(prefix))
179
123
 
180
124
 
181
125
  def extract_fence_tests(
182
126
  markdown_string: str,
183
127
  start_line_offset: int,
128
+ source_path: pathlib.Path,
184
129
  markdown_type: str = "md",
185
130
  fence_syntax: FenceSyntax = FenceSyntax.default,
186
- ) -> typing.Generator[FenceTest, None, None]:
131
+ ) -> typing.Generator[FenceTestDefinition, None, None]:
187
132
  import markdown_it
188
133
 
189
134
  mi = markdown_it.MarkdownIt(config="commonmark")
@@ -226,10 +171,23 @@ def extract_fence_tests(
226
171
  add_blank_lines = start_line - prev.count("\n")
227
172
  code_block = prev + ("\n" * add_blank_lines) + block.content
228
173
 
229
- fixture_names = [
230
- f[len("fixture:") :] for f in code_options if f.startswith("fixture:")
231
- ]
232
- yield FenceTest(code_block, fixture_names, start_line)
174
+ fixture_names = get_prefixed_strings(code_options, "fixture:")
175
+ runner_names = get_prefixed_strings(code_options, "runner:")
176
+ if len(runner_names) == 0:
177
+ runner_name = None
178
+ elif len(runner_names) > 1:
179
+ raise Exception(
180
+ f"Multiple runners are not supported, use a single one instead: {runner_names}"
181
+ )
182
+ else:
183
+ runner_name = runner_names[0]
184
+ yield FenceTestDefinition(
185
+ code_block,
186
+ fixture_names,
187
+ start_line,
188
+ source_path=source_path,
189
+ runner_name=runner_name,
190
+ )
233
191
  prev = code_block
234
192
 
235
193
 
@@ -291,50 +249,67 @@ class MarkdownDocstringCodeModule(pytest.Module):
291
249
  # but unsupported before pytest 8.1...
292
250
  module = import_path(self.path, root=self.config.rootpath)
293
251
 
294
- for object_test in self.find_object_tests_recursive(module.__name__, module):
252
+ for object_test in self.find_object_tests_recursive(
253
+ module.__name__, module, set(), set()
254
+ ):
295
255
  fence_test = object_test.fence_test
296
256
  yield MarkdownInlinePythonItem.from_parent(
297
257
  self,
298
- name=f"{object_test.object_name}[CodeFence#{object_test.intra_object_index+1}][line:{fence_test.start_line}]",
299
- code=fence_test.source,
300
- fixture_names=fence_test.fixture_names,
301
- start_line=fence_test.start_line,
258
+ name=f"{object_test.object_name}[CodeFence#{object_test.intra_object_index + 1}][line:{fence_test.start_line}]",
259
+ test_definition=fence_test,
302
260
  )
303
261
 
304
262
  def find_object_tests_recursive(
305
- self, module_name: str, object: typing.Any
306
- ) -> typing.Generator[ObjectTest, None, None]:
263
+ self,
264
+ module_name: str,
265
+ object: typing.Any,
266
+ _visited_objects: typing.Set[int],
267
+ _found_tests: typing.Set[typing.Tuple[str, int]],
268
+ ) -> typing.Generator[ObjectTestDefinition, None, None]:
269
+ if id(object) in _visited_objects:
270
+ return
271
+ _visited_objects.add(id(object))
307
272
  docstr = inspect.getdoc(object)
308
273
 
309
- if docstr:
310
- docstring_offset = get_docstring_start_line(object)
311
- if docstring_offset is None:
312
- logger.warning(
313
- "Could not find line number offset for docstring: {docstr}"
314
- )
315
- docstring_offset = 0
316
-
317
- obj_name = (
318
- getattr(object, "__qualname__", None)
319
- or getattr(object, "__name__", None)
320
- or "<Unnamed obj>"
321
- )
322
- fence_syntax = FenceSyntax(self.config.option.markdowndocs_syntax)
323
- for i, fence_test in enumerate(
324
- extract_fence_tests(docstr, docstring_offset, fence_syntax=fence_syntax)
325
- ):
326
- yield ObjectTest(i, obj_name, fence_test)
327
-
328
274
  for member_name, member in inspect.getmembers(object):
329
- if member_name.startswith("_"):
330
- continue
331
-
332
275
  if (
333
276
  inspect.isclass(member)
334
277
  or inspect.isfunction(member)
335
278
  or inspect.ismethod(member)
336
279
  ) and member.__module__ == module_name:
337
- yield from self.find_object_tests_recursive(module_name, member)
280
+ yield from self.find_object_tests_recursive(
281
+ module_name, member, _visited_objects, _found_tests
282
+ )
283
+
284
+ if docstr:
285
+ docstring_offset = get_docstring_start_line(object)
286
+ if docstring_offset is None:
287
+ logger.warning(
288
+ f"Could not find line number offset for docstring: {docstr}"
289
+ )
290
+ else:
291
+ obj_name = (
292
+ getattr(object, "__qualname__", None)
293
+ or getattr(object, "__name__", None)
294
+ or "<Unnamed obj>"
295
+ )
296
+ fence_syntax = FenceSyntax(self.config.option.markdowndocs_syntax)
297
+ for i, fence_test in enumerate(
298
+ extract_fence_tests(
299
+ docstr,
300
+ docstring_offset,
301
+ source_path=self.path,
302
+ fence_syntax=fence_syntax,
303
+ )
304
+ ):
305
+ found_test = ObjectTestDefinition(i, obj_name, fence_test)
306
+ found_test_location = (
307
+ module_name,
308
+ found_test.fence_test.start_line,
309
+ )
310
+ if found_test_location not in _found_tests:
311
+ _found_tests.add(found_test_location)
312
+ yield found_test
338
313
 
339
314
 
340
315
  class MarkdownTextFile(pytest.File):
@@ -345,6 +320,7 @@ class MarkdownTextFile(pytest.File):
345
320
  for i, fence_test in enumerate(
346
321
  extract_fence_tests(
347
322
  markdown_content,
323
+ source_path=self.path,
348
324
  start_line_offset=0,
349
325
  markdown_type=self.path.suffix.replace(".", ""),
350
326
  fence_syntax=fence_syntax,
@@ -352,10 +328,8 @@ class MarkdownTextFile(pytest.File):
352
328
  ):
353
329
  yield MarkdownInlinePythonItem.from_parent(
354
330
  self,
355
- name=f"[CodeFence#{i+1}][line:{fence_test.start_line}]",
356
- code=fence_test.source,
357
- fixture_names=fence_test.fixture_names,
358
- start_line=fence_test.start_line,
331
+ name=f"[CodeFence#{i + 1}][line:{fence_test.start_line}]",
332
+ test_definition=fence_test,
359
333
  )
360
334
 
361
335
 
@@ -142,9 +142,9 @@ Traceback \(most recent call last\):
142
142
  Exception: doh
143
143
  """.strip()
144
144
  pytest_output = "\n".join(line.rstrip() for line in result.outlines).strip()
145
- assert (
146
- re.search(expected_output_pattern, pytest_output) is not None
147
- ), "Output traceback doesn't match expected value"
145
+ assert re.search(expected_output_pattern, pytest_output) is not None, (
146
+ "Output traceback doesn't match expected value"
147
+ )
148
148
 
149
149
 
150
150
  def test_autouse_fixtures(testdir):
@@ -431,3 +431,42 @@ def test_error_origin_before_docstring_traceback(testdir, support_dir):
431
431
  ],
432
432
  consecutive=True,
433
433
  )
434
+
435
+
436
+ def test_custom_runner(testdir):
437
+ testdir.makeconftest(
438
+ """
439
+ import pytest_markdown_docs._runners
440
+
441
+ @pytest_markdown_docs._runners.register_runner()
442
+ class LinesAreAllFoo(pytest_markdown_docs._runners.DefaultRunner):
443
+ def runtest(self, test, args):
444
+ lines = test.source.strip().split("\\n")
445
+ for line in lines:
446
+ assert line == "foo"
447
+ """
448
+ )
449
+ testdir.makefile(
450
+ ".md",
451
+ """
452
+ ```python runner:LinesAreAllFoo
453
+ foo
454
+ foo
455
+ ```
456
+
457
+ ```python runner:LinesAreAllFoo
458
+ foo
459
+ bar
460
+ ```
461
+ """,
462
+ )
463
+
464
+ result = testdir.runpytest("-v", "--markdown-docs")
465
+ result.assert_outcomes(passed=1, failed=1)
466
+ result.stdout.re_match_lines(
467
+ [
468
+ r".*\[CodeFence#1\]\[line:1\].*PASSED.*",
469
+ r".*\[CodeFence#2\]\[line:6\].*FAILED.*",
470
+ ],
471
+ consecutive=True,
472
+ )