Skip to content

Commit 2903beb

Browse files
authored
Merge pull request #2277 from thseiler/bugfix/reader-python-fix-decorated-methods
python_reader: correctly handle decorators and nested functions
2 parents aeabd29 + a5233b8 commit 2903beb

File tree

10 files changed

+275
-34
lines changed

10 files changed

+275
-34
lines changed

strictdoc/backend/sdoc_source_code/reader_python.py

Lines changed: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ def read(
7676
functions_stack.append(function)
7777
map_function_to_node[function] = node_
7878
if len(node_.children) > 0:
79-
# look for the docstring within the first 30 children (arbitrary chosen limit)
80-
# so that we dont miss it if the file starts with comments (#!, encoding marker, etc...)
79+
# Look for the docstring within the first 30 children (arbitrary chosen limit)
80+
# so that we dont miss it if the file starts with comments (#!, encoding marker, etc...).
8181
first_match = next(
8282
(
8383
child
@@ -92,7 +92,7 @@ def read(
9292
if first_match is not None:
9393
block_comment = first_match.children[0]
9494

95-
# string contains of three parts:
95+
# String contains of three parts:
9696
# string_start string_content string_end
9797
string_content = block_comment.children[1]
9898
assert string_content.text is not None
@@ -152,7 +152,7 @@ def read(
152152
block_comment = function_block.children[0].children[
153153
0
154154
]
155-
# string contains of three parts:
155+
# String contains of three parts:
156156
# string_start string_content string_end
157157
string_content = block_comment.children[1]
158158
assert string_content.text is not None
@@ -277,29 +277,27 @@ def read_from_file(self, file_path: str) -> SourceFileTraceabilityInfo:
277277
@staticmethod
278278
def get_node_ns(node: Node) -> Sequence[str]:
279279
"""
280-
Walk up the tree and find parent classes.
280+
Walk up the tree to collect enclosing function and class names (identifier) for full qualification.
281+
Handles nested functions, methods, and classes.
281282
"""
282-
parent_scopes = []
283+
parent_scopes: List[str] = []
283284
cursor: Optional[Node] = node
284-
while cursor:
285-
if (block_node := cursor.parent) is not None and (
286-
class_node_or_node := block_node.parent
287-
) is not None:
288-
cursor = class_node_or_node
289-
if (
290-
class_node_or_node.type == "class_definition"
291-
and len(class_node_or_node.children) > 1
292-
):
293-
second_node_or_none = class_node_or_node.children[1]
294-
if (
295-
second_node_or_none.type == "identifier"
296-
and second_node_or_none.text is not None
297-
):
298-
parent_class_name = second_node_or_none.text.decode(
299-
"utf8"
300-
)
301-
parent_scopes.append(parent_class_name)
302-
else:
303-
cursor = None
304-
parent_scopes.reverse()
305-
return parent_scopes
285+
286+
while cursor is not None:
287+
if cursor.type in ("class_definition", "function_definition"):
288+
# Look for the identifier child (i.e., the name).
289+
name_node = next(
290+
(
291+
child
292+
for child in cursor.children
293+
if child.type == "identifier"
294+
),
295+
None,
296+
)
297+
if name_node and name_node.text:
298+
parent_scopes.insert(0, name_node.text.decode("utf-8"))
299+
cursor = cursor.parent
300+
301+
# The array now contains the "fully qualified" node name,
302+
# we want to return the namespace, so don't return the last part.
303+
return parent_scopes[:-1]
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
#
2+
# Some No-Op Decorators.
3+
#
4+
def decorator_1(func):
5+
return func
6+
7+
def decorator_2(func):
8+
return func
9+
10+
def decorator_3(func):
11+
return func
12+
13+
def decorator_4(func):
14+
return func
15+
16+
class Foo:
17+
18+
def hello_world(self):
19+
"""
20+
@relation(REQ-1, scope=function)
21+
"""
22+
print("hello world") # noqa: T201
23+
print("hello world") # noqa: T201
24+
print("hello world") # noqa: T201
25+
26+
@decorator_1
27+
def hello_world_decorated_once(self):
28+
"""
29+
@relation(REQ-2, scope=function)
30+
"""
31+
print("hello world") # noqa: T201
32+
print("hello world") # noqa: T201
33+
print("hello world") # noqa: T201
34+
35+
@decorator_1
36+
@decorator_2
37+
def hello_world_decorated_twice(self):
38+
"""
39+
@relation(REQ-3, scope=function)
40+
"""
41+
print("hello world") # noqa: T201
42+
print("hello world") # noqa: T201
43+
print("hello world") # noqa: T201
44+
45+
@decorator_1
46+
@decorator_2
47+
@decorator_3
48+
def hello_world_decorated_three_times(self):
49+
"""
50+
@relation(REQ-4, scope=function)
51+
"""
52+
print("hello world") # noqa: T201
53+
print("hello world") # noqa: T201
54+
print("hello world") # noqa: T201
55+
56+
@decorator_1
57+
@decorator_2
58+
@decorator_3
59+
@decorator_4
60+
def hello_world_decorated_four_times(self):
61+
"""
62+
@relation(REQ-5, scope=function)
63+
"""
64+
print("hello world") # noqa: T201
65+
print("hello world") # noqa: T201
66+
print("hello world") # noqa: T201
67+
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
[DOCUMENT]
2+
TITLE: Hello world doc
3+
4+
[REQUIREMENT]
5+
UID: REQ-1
6+
TITLE: Requirement Title
7+
STATEMENT: Requirement Statement
8+
9+
[REQUIREMENT]
10+
UID: REQ-2
11+
TITLE: Requirement Title
12+
STATEMENT: Requirement Statement
13+
14+
[REQUIREMENT]
15+
UID: REQ-3
16+
TITLE: Requirement Title
17+
STATEMENT: Requirement Statement
18+
19+
[REQUIREMENT]
20+
UID: REQ-4
21+
TITLE: Requirement Title
22+
STATEMENT: Requirement Statement
23+
24+
[REQUIREMENT]
25+
UID: REQ-5
26+
TITLE: Requirement Title
27+
STATEMENT: Requirement Statement
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[project]
2+
3+
features = [
4+
"REQUIREMENT_TO_SOURCE_TRACEABILITY",
5+
"SOURCE_FILE_LANGUAGE_PARSERS",
6+
"PROJECT_STATISTICS_SCREEN"
7+
]
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
REQUIRES: PYTHON_39_OR_HIGHER
2+
3+
RUN: %strictdoc export %S --output-dir Output | filecheck %s --dump-input=fail
4+
CHECK: Published: Hello world doc
5+
6+
RUN: %check_exists --file "%S/Output/html/_source_files/file.py.html"
7+
8+
RUN: %cat %S/Output/html/%THIS_TEST_FOLDER/input.html | filecheck %s --check-prefix CHECK-HTML
9+
CHECK-HTML: <a{{.*}}href="../_source_files/file.py.html#REQ-1#18#24">
10+
11+
RUN: %cat %S/Output/html/_source_files/file.py.html | filecheck %s --check-prefix CHECK-SOURCE-FILE
12+
CHECK-SOURCE-FILE: href="../_source_files/file.py.html#REQ-1#18#24"
13+
CHECK-SOURCE-FILE: <b>[ 18-24 ]</b>
14+
CHECK-SOURCE-FILE: file.py, function Foo.hello_world
15+
16+
CHECK-SOURCE-FILE: href="../_source_files/file.py.html#REQ-2#27#33"
17+
CHECK-SOURCE-FILE: <b>[ 27-33 ]</b>
18+
CHECK-SOURCE-FILE: file.py, function Foo.hello_world_decorated_once
19+
20+
CHECK-SOURCE-FILE: href="../_source_files/file.py.html#REQ-3#37#43"
21+
CHECK-SOURCE-FILE: <b>[ 37-43 ]</b>
22+
CHECK-SOURCE-FILE: file.py, function Foo.hello_world_decorated_twice
23+
24+
CHECK-SOURCE-FILE: href="../_source_files/file.py.html#REQ-4#48#54"
25+
CHECK-SOURCE-FILE: <b>[ 48-54 ]</b>
26+
CHECK-SOURCE-FILE: file.py, function Foo.hello_world_decorated_three_times
27+
28+
CHECK-SOURCE-FILE: href="../_source_files/file.py.html#REQ-5#60#66"
29+
CHECK-SOURCE-FILE: <b>[ 60-66 ]</b>
30+
CHECK-SOURCE-FILE: file.py, function Foo.hello_world_decorated_four_times
31+
32+
RUN: %cat %S/Output/html/source_coverage.html | filecheck %s --check-prefix CHECK-SOURCE-COVERAGE
33+
CHECK-SOURCE-COVERAGE: 50.0
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
2+
class Foo:
3+
4+
def hello_world(self):
5+
"""
6+
@relation(REQ-1, scope=function)
7+
"""
8+
print("hello world") # noqa: T201
9+
print("hello world") # noqa: T201
10+
print("hello world") # noqa: T201
11+
12+
def nested_function():
13+
"""
14+
@relation(REQ-2, scope=function)
15+
"""
16+
print("hello world") # noqa: T201
17+
18+
nested_function()
19+
20+
def hello_world_2():
21+
"""
22+
@relation(REQ-3, scope=function)
23+
"""
24+
print("hello world") # noqa: T201
25+
print("hello world") # noqa: T201
26+
print("hello world") # noqa: T201
27+
28+
def nested_function():
29+
"""
30+
@relation(REQ-4, scope=function)
31+
"""
32+
print("hello world") # noqa: T201
33+
34+
nested_function()
35+
36+
class Outer:
37+
class Inner:
38+
def hello_world(self):
39+
"""
40+
@relation(REQ-5, scope=function)
41+
"""
42+
print("hello world") # noqa: T201
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
[DOCUMENT]
2+
TITLE: Hello world doc
3+
4+
[REQUIREMENT]
5+
UID: REQ-1
6+
TITLE: Requirement Title
7+
STATEMENT: Requirement Statement
8+
9+
[REQUIREMENT]
10+
UID: REQ-2
11+
TITLE: Requirement Title
12+
STATEMENT: Requirement Statement
13+
14+
[REQUIREMENT]
15+
UID: REQ-3
16+
TITLE: Requirement Title
17+
STATEMENT: Requirement Statement
18+
19+
[REQUIREMENT]
20+
UID: REQ-4
21+
TITLE: Requirement Title
22+
STATEMENT: Requirement Statement
23+
24+
[REQUIREMENT]
25+
UID: REQ-5
26+
TITLE: Requirement Title
27+
STATEMENT: Requirement Statement
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[project]
2+
3+
features = [
4+
"REQUIREMENT_TO_SOURCE_TRACEABILITY",
5+
"SOURCE_FILE_LANGUAGE_PARSERS",
6+
"PROJECT_STATISTICS_SCREEN"
7+
]
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
REQUIRES: PYTHON_39_OR_HIGHER
2+
3+
RUN: %strictdoc export %S --output-dir Output | filecheck %s --dump-input=fail
4+
CHECK: Published: Hello world doc
5+
6+
RUN: %check_exists --file "%S/Output/html/_source_files/file.py.html"
7+
8+
RUN: %cat %S/Output/html/%THIS_TEST_FOLDER/input.html | filecheck %s --check-prefix CHECK-HTML
9+
CHECK-HTML: <a{{.*}}href="../_source_files/file.py.html#REQ-1#4#18">
10+
11+
RUN: %cat %S/Output/html/_source_files/file.py.html | filecheck %s --check-prefix CHECK-SOURCE-FILE
12+
CHECK-SOURCE-FILE: href="../_source_files/file.py.html#REQ-1#4#18"
13+
CHECK-SOURCE-FILE: <b>[ 4-18 ]</b>
14+
CHECK-SOURCE-FILE: file.py, function Foo.hello_world
15+
16+
CHECK-SOURCE-FILE: href="../_source_files/file.py.html#REQ-2#12#16"
17+
CHECK-SOURCE-FILE: <b>[ 12-16 ]</b>
18+
CHECK-SOURCE-FILE: file.py, function Foo.hello_world.nested_function
19+
20+
CHECK-SOURCE-FILE: href="../_source_files/file.py.html#REQ-3#20#34"
21+
CHECK-SOURCE-FILE: <b>[ 20-34 ]</b>
22+
CHECK-SOURCE-FILE: file.py, function hello_world_2
23+
24+
CHECK-SOURCE-FILE: href="../_source_files/file.py.html#REQ-4#28#32"
25+
CHECK-SOURCE-FILE: <b>[ 28-32 ]</b>
26+
CHECK-SOURCE-FILE: file.py, function hello_world_2.nested_function
27+
28+
CHECK-SOURCE-FILE: href="../_source_files/file.py.html#REQ-5#38#42"
29+
CHECK-SOURCE-FILE: <b>[ 38-42 ]</b>
30+
CHECK-SOURCE-FILE: file.py, function Outer.Inner.hello_world
31+
32+
RUN: %cat %S/Output/html/source_coverage.html | filecheck %s --check-prefix CHECK-SOURCE-COVERAGE
33+
CHECK-SOURCE-COVERAGE: 62.5

tests/unit/strictdoc/backend/sdoc_source_code/readers/test_reader_python.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,12 @@ def hello_3_1_1():
9090

9191
function_1_1 = function_1.child_functions[0]
9292
assert isinstance(function_1_1, Function)
93-
assert function_1_1.name == "hello_1_1"
93+
assert function_1_1.name == "hello_1.hello_1_1"
9494
assert len(function_1_1.child_functions) == 1
9595

9696
function_1_1_1 = function_1_1.child_functions[0]
9797
assert isinstance(function_1_1_1, Function)
98-
assert function_1_1_1.name == "hello_1_1_1"
98+
assert function_1_1_1.name == "hello_1.hello_1_1.hello_1_1_1"
9999
assert len(function_1_1_1.child_functions) == 0
100100

101101
function_2 = info.functions[3]
@@ -105,12 +105,12 @@ def hello_3_1_1():
105105

106106
function_2_1 = function_2.child_functions[0]
107107
assert isinstance(function_2_1, Function)
108-
assert function_2_1.name == "hello_2_1"
108+
assert function_2_1.name == "hello_2.hello_2_1"
109109
assert len(function_2_1.child_functions) == 1
110110

111111
function_2_1_1 = function_2_1.child_functions[0]
112112
assert isinstance(function_2_1_1, Function)
113-
assert function_2_1_1.name == "hello_2_1_1"
113+
assert function_2_1_1.name == "hello_2.hello_2_1.hello_2_1_1"
114114
assert len(function_2_1_1.child_functions) == 0
115115

116116
function_3 = info.functions[6]
@@ -120,12 +120,12 @@ def hello_3_1_1():
120120

121121
function_3_1 = function_3.child_functions[0]
122122
assert isinstance(function_3_1, Function)
123-
assert function_3_1.name == "hello_3_1"
123+
assert function_3_1.name == "hello_3.hello_3_1"
124124
assert len(function_3_1.child_functions) == 1
125125

126126
function_3_1_1 = function_3_1.child_functions[0]
127127
assert isinstance(function_3_1_1, Function)
128-
assert function_3_1_1.name == "hello_3_1_1"
128+
assert function_3_1_1.name == "hello_3.hello_3_1.hello_3_1_1"
129129
assert len(function_3_1_1.child_functions) == 0
130130

131131

0 commit comments

Comments
 (0)