Skip to content

Commit d9baa80

Browse files
authored
Fix object traversal (#41)
Change object traversal logic * Prevent infinite recursion * Prevent duplicate tests * Include underscore-prefixed objects
1 parent e486dc6 commit d9baa80

File tree

2 files changed

+50
-387
lines changed

2 files changed

+50
-387
lines changed

src/pytest_markdown_docs/plugin.py

Lines changed: 50 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,14 @@ class FenceSyntax(Enum):
3737
superfences = "superfences"
3838

3939

40-
@dataclass
40+
@dataclass(frozen=True)
4141
class FenceTest:
4242
source: str
43-
fixture_names: typing.List[str]
43+
fixture_names: typing.Tuple[str, ...]
4444
start_line: int
4545

4646

47-
@dataclass
47+
@dataclass(frozen=True)
4848
class ObjectTest:
4949
intra_object_index: int
5050
object_name: str
@@ -53,7 +53,10 @@ class ObjectTest:
5353

5454
def get_docstring_start_line(obj) -> typing.Optional[int]:
5555
# Get the source lines and the starting line number of the object
56-
source_lines, start_line = inspect.getsourcelines(obj)
56+
try:
57+
source_lines, start_line = inspect.getsourcelines(obj)
58+
except OSError:
59+
return None
5760

5861
# Find the line in the source code that starts with triple quotes (""" or ''')
5962
for idx, line in enumerate(source_lines):
@@ -226,9 +229,9 @@ def extract_fence_tests(
226229
add_blank_lines = start_line - prev.count("\n")
227230
code_block = prev + ("\n" * add_blank_lines) + block.content
228231

229-
fixture_names = [
232+
fixture_names = tuple(
230233
f[len("fixture:") :] for f in code_options if f.startswith("fixture:")
231-
]
234+
)
232235
yield FenceTest(code_block, fixture_names, start_line)
233236
prev = code_block
234237

@@ -291,7 +294,9 @@ def collect(self):
291294
# but unsupported before pytest 8.1...
292295
module = import_path(self.path, root=self.config.rootpath)
293296

294-
for object_test in self.find_object_tests_recursive(module.__name__, module):
297+
for object_test in self.find_object_tests_recursive(
298+
module.__name__, module, set(), set()
299+
):
295300
fence_test = object_test.fence_test
296301
yield MarkdownInlinePythonItem.from_parent(
297302
self,
@@ -302,39 +307,53 @@ def collect(self):
302307
)
303308

304309
def find_object_tests_recursive(
305-
self, module_name: str, object: typing.Any
310+
self,
311+
module_name: str,
312+
object: typing.Any,
313+
_visited_objects: typing.Set[int],
314+
_found_tests: typing.Set[typing.Tuple[str, int]],
306315
) -> typing.Generator[ObjectTest, None, None]:
316+
if id(object) in _visited_objects:
317+
return
318+
_visited_objects.add(id(object))
307319
docstr = inspect.getdoc(object)
308320

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-
328321
for member_name, member in inspect.getmembers(object):
329-
if member_name.startswith("_"):
330-
continue
331-
332322
if (
333323
inspect.isclass(member)
334324
or inspect.isfunction(member)
335325
or inspect.ismethod(member)
336326
) and member.__module__ == module_name:
337-
yield from self.find_object_tests_recursive(module_name, member)
327+
yield from self.find_object_tests_recursive(
328+
module_name, member, _visited_objects, _found_tests
329+
)
330+
331+
if docstr:
332+
docstring_offset = get_docstring_start_line(object)
333+
if docstring_offset is None:
334+
logger.warning(
335+
f"Could not find line number offset for docstring: {docstr}"
336+
)
337+
else:
338+
obj_name = (
339+
getattr(object, "__qualname__", None)
340+
or getattr(object, "__name__", None)
341+
or "<Unnamed obj>"
342+
)
343+
fence_syntax = FenceSyntax(self.config.option.markdowndocs_syntax)
344+
for i, fence_test in enumerate(
345+
extract_fence_tests(
346+
docstr, docstring_offset, fence_syntax=fence_syntax
347+
)
348+
):
349+
found_test = ObjectTest(i, obj_name, fence_test)
350+
found_test_location = (
351+
module_name,
352+
found_test.fence_test.start_line,
353+
)
354+
if found_test_location not in _found_tests:
355+
_found_tests.add(found_test_location)
356+
yield found_test
338357

339358

340359
class MarkdownTextFile(pytest.File):

0 commit comments

Comments
 (0)