From a5233b80e17828b6c1fa4adfc3469cbca05f8794 Mon Sep 17 00:00:00 2001 From: Thomas Seiler Date: Mon, 26 May 2025 22:52:35 +0200 Subject: [PATCH] python_reader: correctly handle decorators and nested functions --- .../backend/sdoc_source_code/reader_python.py | 54 +++++++-------- .../python/07_python_decorated/file.py | 67 +++++++++++++++++++ .../python/07_python_decorated/input.sdoc | 27 ++++++++ .../python/07_python_decorated/strictdoc.toml | 7 ++ .../python/07_python_decorated/test.itest | 33 +++++++++ .../python/08_python_nested/file.py | 42 ++++++++++++ .../python/08_python_nested/input.sdoc | 27 ++++++++ .../python/08_python_nested/strictdoc.toml | 7 ++ .../python/08_python_nested/test.itest | 33 +++++++++ .../readers/test_reader_python.py | 12 ++-- 10 files changed, 275 insertions(+), 34 deletions(-) create mode 100644 tests/integration/features/file_traceability/_language_parsers/python/07_python_decorated/file.py create mode 100644 tests/integration/features/file_traceability/_language_parsers/python/07_python_decorated/input.sdoc create mode 100644 tests/integration/features/file_traceability/_language_parsers/python/07_python_decorated/strictdoc.toml create mode 100644 tests/integration/features/file_traceability/_language_parsers/python/07_python_decorated/test.itest create mode 100644 tests/integration/features/file_traceability/_language_parsers/python/08_python_nested/file.py create mode 100644 tests/integration/features/file_traceability/_language_parsers/python/08_python_nested/input.sdoc create mode 100644 tests/integration/features/file_traceability/_language_parsers/python/08_python_nested/strictdoc.toml create mode 100644 tests/integration/features/file_traceability/_language_parsers/python/08_python_nested/test.itest diff --git a/strictdoc/backend/sdoc_source_code/reader_python.py b/strictdoc/backend/sdoc_source_code/reader_python.py index 13b4f9ce7..5ac8c5009 100644 --- a/strictdoc/backend/sdoc_source_code/reader_python.py +++ b/strictdoc/backend/sdoc_source_code/reader_python.py @@ -76,8 +76,8 @@ def read( functions_stack.append(function) map_function_to_node[function] = node_ if len(node_.children) > 0: - # look for the docstring within the first 30 children (arbitrary chosen limit) - # so that we dont miss it if the file starts with comments (#!, encoding marker, etc...) + # Look for the docstring within the first 30 children (arbitrary chosen limit) + # so that we dont miss it if the file starts with comments (#!, encoding marker, etc...). first_match = next( ( child @@ -92,7 +92,7 @@ def read( if first_match is not None: block_comment = first_match.children[0] - # string contains of three parts: + # String contains of three parts: # string_start string_content string_end string_content = block_comment.children[1] assert string_content.text is not None @@ -152,7 +152,7 @@ def read( block_comment = function_block.children[0].children[ 0 ] - # string contains of three parts: + # String contains of three parts: # string_start string_content string_end string_content = block_comment.children[1] assert string_content.text is not None @@ -277,29 +277,27 @@ def read_from_file(self, file_path: str) -> SourceFileTraceabilityInfo: @staticmethod def get_node_ns(node: Node) -> Sequence[str]: """ - Walk up the tree and find parent classes. + Walk up the tree to collect enclosing function and class names (identifier) for full qualification. + Handles nested functions, methods, and classes. """ - parent_scopes = [] + parent_scopes: List[str] = [] cursor: Optional[Node] = node - while cursor: - if (block_node := cursor.parent) is not None and ( - class_node_or_node := block_node.parent - ) is not None: - cursor = class_node_or_node - if ( - class_node_or_node.type == "class_definition" - and len(class_node_or_node.children) > 1 - ): - second_node_or_none = class_node_or_node.children[1] - if ( - second_node_or_none.type == "identifier" - and second_node_or_none.text is not None - ): - parent_class_name = second_node_or_none.text.decode( - "utf8" - ) - parent_scopes.append(parent_class_name) - else: - cursor = None - parent_scopes.reverse() - return parent_scopes + + while cursor is not None: + if cursor.type in ("class_definition", "function_definition"): + # Look for the identifier child (i.e., the name). + name_node = next( + ( + child + for child in cursor.children + if child.type == "identifier" + ), + None, + ) + if name_node and name_node.text: + parent_scopes.insert(0, name_node.text.decode("utf-8")) + cursor = cursor.parent + + # The array now contains the "fully qualified" node name, + # we want to return the namespace, so don't return the last part. + return parent_scopes[:-1] diff --git a/tests/integration/features/file_traceability/_language_parsers/python/07_python_decorated/file.py b/tests/integration/features/file_traceability/_language_parsers/python/07_python_decorated/file.py new file mode 100644 index 000000000..68d089811 --- /dev/null +++ b/tests/integration/features/file_traceability/_language_parsers/python/07_python_decorated/file.py @@ -0,0 +1,67 @@ +# +# Some No-Op Decorators. +# +def decorator_1(func): + return func + +def decorator_2(func): + return func + +def decorator_3(func): + return func + +def decorator_4(func): + return func + +class Foo: + + def hello_world(self): + """ + @relation(REQ-1, scope=function) + """ + print("hello world") # noqa: T201 + print("hello world") # noqa: T201 + print("hello world") # noqa: T201 + + @decorator_1 + def hello_world_decorated_once(self): + """ + @relation(REQ-2, scope=function) + """ + print("hello world") # noqa: T201 + print("hello world") # noqa: T201 + print("hello world") # noqa: T201 + + @decorator_1 + @decorator_2 + def hello_world_decorated_twice(self): + """ + @relation(REQ-3, scope=function) + """ + print("hello world") # noqa: T201 + print("hello world") # noqa: T201 + print("hello world") # noqa: T201 + + @decorator_1 + @decorator_2 + @decorator_3 + def hello_world_decorated_three_times(self): + """ + @relation(REQ-4, scope=function) + """ + print("hello world") # noqa: T201 + print("hello world") # noqa: T201 + print("hello world") # noqa: T201 + + @decorator_1 + @decorator_2 + @decorator_3 + @decorator_4 + def hello_world_decorated_four_times(self): + """ + @relation(REQ-5, scope=function) + """ + print("hello world") # noqa: T201 + print("hello world") # noqa: T201 + print("hello world") # noqa: T201 + diff --git a/tests/integration/features/file_traceability/_language_parsers/python/07_python_decorated/input.sdoc b/tests/integration/features/file_traceability/_language_parsers/python/07_python_decorated/input.sdoc new file mode 100644 index 000000000..faf966db2 --- /dev/null +++ b/tests/integration/features/file_traceability/_language_parsers/python/07_python_decorated/input.sdoc @@ -0,0 +1,27 @@ +[DOCUMENT] +TITLE: Hello world doc + +[REQUIREMENT] +UID: REQ-1 +TITLE: Requirement Title +STATEMENT: Requirement Statement + +[REQUIREMENT] +UID: REQ-2 +TITLE: Requirement Title +STATEMENT: Requirement Statement + +[REQUIREMENT] +UID: REQ-3 +TITLE: Requirement Title +STATEMENT: Requirement Statement + +[REQUIREMENT] +UID: REQ-4 +TITLE: Requirement Title +STATEMENT: Requirement Statement + +[REQUIREMENT] +UID: REQ-5 +TITLE: Requirement Title +STATEMENT: Requirement Statement diff --git a/tests/integration/features/file_traceability/_language_parsers/python/07_python_decorated/strictdoc.toml b/tests/integration/features/file_traceability/_language_parsers/python/07_python_decorated/strictdoc.toml new file mode 100644 index 000000000..f5502ad57 --- /dev/null +++ b/tests/integration/features/file_traceability/_language_parsers/python/07_python_decorated/strictdoc.toml @@ -0,0 +1,7 @@ +[project] + +features = [ + "REQUIREMENT_TO_SOURCE_TRACEABILITY", + "SOURCE_FILE_LANGUAGE_PARSERS", + "PROJECT_STATISTICS_SCREEN" +] diff --git a/tests/integration/features/file_traceability/_language_parsers/python/07_python_decorated/test.itest b/tests/integration/features/file_traceability/_language_parsers/python/07_python_decorated/test.itest new file mode 100644 index 000000000..55091a4d8 --- /dev/null +++ b/tests/integration/features/file_traceability/_language_parsers/python/07_python_decorated/test.itest @@ -0,0 +1,33 @@ +REQUIRES: PYTHON_39_OR_HIGHER + +RUN: %strictdoc export %S --output-dir Output | filecheck %s --dump-input=fail +CHECK: Published: Hello world doc + +RUN: %check_exists --file "%S/Output/html/_source_files/file.py.html" + +RUN: %cat %S/Output/html/%THIS_TEST_FOLDER/input.html | filecheck %s --check-prefix CHECK-HTML +CHECK-HTML: + +RUN: %cat %S/Output/html/_source_files/file.py.html | filecheck %s --check-prefix CHECK-SOURCE-FILE +CHECK-SOURCE-FILE: href="../_source_files/file.py.html#REQ-1#18#24" +CHECK-SOURCE-FILE: [ 18-24 ] +CHECK-SOURCE-FILE: file.py, function Foo.hello_world + +CHECK-SOURCE-FILE: href="../_source_files/file.py.html#REQ-2#27#33" +CHECK-SOURCE-FILE: [ 27-33 ] +CHECK-SOURCE-FILE: file.py, function Foo.hello_world_decorated_once + +CHECK-SOURCE-FILE: href="../_source_files/file.py.html#REQ-3#37#43" +CHECK-SOURCE-FILE: [ 37-43 ] +CHECK-SOURCE-FILE: file.py, function Foo.hello_world_decorated_twice + +CHECK-SOURCE-FILE: href="../_source_files/file.py.html#REQ-4#48#54" +CHECK-SOURCE-FILE: [ 48-54 ] +CHECK-SOURCE-FILE: file.py, function Foo.hello_world_decorated_three_times + +CHECK-SOURCE-FILE: href="../_source_files/file.py.html#REQ-5#60#66" +CHECK-SOURCE-FILE: [ 60-66 ] +CHECK-SOURCE-FILE: file.py, function Foo.hello_world_decorated_four_times + +RUN: %cat %S/Output/html/source_coverage.html | filecheck %s --check-prefix CHECK-SOURCE-COVERAGE +CHECK-SOURCE-COVERAGE: 50.0 diff --git a/tests/integration/features/file_traceability/_language_parsers/python/08_python_nested/file.py b/tests/integration/features/file_traceability/_language_parsers/python/08_python_nested/file.py new file mode 100644 index 000000000..313c3857f --- /dev/null +++ b/tests/integration/features/file_traceability/_language_parsers/python/08_python_nested/file.py @@ -0,0 +1,42 @@ + +class Foo: + + def hello_world(self): + """ + @relation(REQ-1, scope=function) + """ + print("hello world") # noqa: T201 + print("hello world") # noqa: T201 + print("hello world") # noqa: T201 + + def nested_function(): + """ + @relation(REQ-2, scope=function) + """ + print("hello world") # noqa: T201 + + nested_function() + +def hello_world_2(): + """ + @relation(REQ-3, scope=function) + """ + print("hello world") # noqa: T201 + print("hello world") # noqa: T201 + print("hello world") # noqa: T201 + + def nested_function(): + """ + @relation(REQ-4, scope=function) + """ + print("hello world") # noqa: T201 + + nested_function() + +class Outer: + class Inner: + def hello_world(self): + """ + @relation(REQ-5, scope=function) + """ + print("hello world") # noqa: T201 diff --git a/tests/integration/features/file_traceability/_language_parsers/python/08_python_nested/input.sdoc b/tests/integration/features/file_traceability/_language_parsers/python/08_python_nested/input.sdoc new file mode 100644 index 000000000..faf966db2 --- /dev/null +++ b/tests/integration/features/file_traceability/_language_parsers/python/08_python_nested/input.sdoc @@ -0,0 +1,27 @@ +[DOCUMENT] +TITLE: Hello world doc + +[REQUIREMENT] +UID: REQ-1 +TITLE: Requirement Title +STATEMENT: Requirement Statement + +[REQUIREMENT] +UID: REQ-2 +TITLE: Requirement Title +STATEMENT: Requirement Statement + +[REQUIREMENT] +UID: REQ-3 +TITLE: Requirement Title +STATEMENT: Requirement Statement + +[REQUIREMENT] +UID: REQ-4 +TITLE: Requirement Title +STATEMENT: Requirement Statement + +[REQUIREMENT] +UID: REQ-5 +TITLE: Requirement Title +STATEMENT: Requirement Statement diff --git a/tests/integration/features/file_traceability/_language_parsers/python/08_python_nested/strictdoc.toml b/tests/integration/features/file_traceability/_language_parsers/python/08_python_nested/strictdoc.toml new file mode 100644 index 000000000..f5502ad57 --- /dev/null +++ b/tests/integration/features/file_traceability/_language_parsers/python/08_python_nested/strictdoc.toml @@ -0,0 +1,7 @@ +[project] + +features = [ + "REQUIREMENT_TO_SOURCE_TRACEABILITY", + "SOURCE_FILE_LANGUAGE_PARSERS", + "PROJECT_STATISTICS_SCREEN" +] diff --git a/tests/integration/features/file_traceability/_language_parsers/python/08_python_nested/test.itest b/tests/integration/features/file_traceability/_language_parsers/python/08_python_nested/test.itest new file mode 100644 index 000000000..ec1c27b3f --- /dev/null +++ b/tests/integration/features/file_traceability/_language_parsers/python/08_python_nested/test.itest @@ -0,0 +1,33 @@ +REQUIRES: PYTHON_39_OR_HIGHER + +RUN: %strictdoc export %S --output-dir Output | filecheck %s --dump-input=fail +CHECK: Published: Hello world doc + +RUN: %check_exists --file "%S/Output/html/_source_files/file.py.html" + +RUN: %cat %S/Output/html/%THIS_TEST_FOLDER/input.html | filecheck %s --check-prefix CHECK-HTML +CHECK-HTML: + +RUN: %cat %S/Output/html/_source_files/file.py.html | filecheck %s --check-prefix CHECK-SOURCE-FILE +CHECK-SOURCE-FILE: href="../_source_files/file.py.html#REQ-1#4#18" +CHECK-SOURCE-FILE: [ 4-18 ] +CHECK-SOURCE-FILE: file.py, function Foo.hello_world + +CHECK-SOURCE-FILE: href="../_source_files/file.py.html#REQ-2#12#16" +CHECK-SOURCE-FILE: [ 12-16 ] +CHECK-SOURCE-FILE: file.py, function Foo.hello_world.nested_function + +CHECK-SOURCE-FILE: href="../_source_files/file.py.html#REQ-3#20#34" +CHECK-SOURCE-FILE: [ 20-34 ] +CHECK-SOURCE-FILE: file.py, function hello_world_2 + +CHECK-SOURCE-FILE: href="../_source_files/file.py.html#REQ-4#28#32" +CHECK-SOURCE-FILE: [ 28-32 ] +CHECK-SOURCE-FILE: file.py, function hello_world_2.nested_function + +CHECK-SOURCE-FILE: href="../_source_files/file.py.html#REQ-5#38#42" +CHECK-SOURCE-FILE: [ 38-42 ] +CHECK-SOURCE-FILE: file.py, function Outer.Inner.hello_world + +RUN: %cat %S/Output/html/source_coverage.html | filecheck %s --check-prefix CHECK-SOURCE-COVERAGE +CHECK-SOURCE-COVERAGE: 62.5 diff --git a/tests/unit/strictdoc/backend/sdoc_source_code/readers/test_reader_python.py b/tests/unit/strictdoc/backend/sdoc_source_code/readers/test_reader_python.py index 21b0bf8f6..636369d88 100644 --- a/tests/unit/strictdoc/backend/sdoc_source_code/readers/test_reader_python.py +++ b/tests/unit/strictdoc/backend/sdoc_source_code/readers/test_reader_python.py @@ -90,12 +90,12 @@ def hello_3_1_1(): function_1_1 = function_1.child_functions[0] assert isinstance(function_1_1, Function) - assert function_1_1.name == "hello_1_1" + assert function_1_1.name == "hello_1.hello_1_1" assert len(function_1_1.child_functions) == 1 function_1_1_1 = function_1_1.child_functions[0] assert isinstance(function_1_1_1, Function) - assert function_1_1_1.name == "hello_1_1_1" + assert function_1_1_1.name == "hello_1.hello_1_1.hello_1_1_1" assert len(function_1_1_1.child_functions) == 0 function_2 = info.functions[3] @@ -105,12 +105,12 @@ def hello_3_1_1(): function_2_1 = function_2.child_functions[0] assert isinstance(function_2_1, Function) - assert function_2_1.name == "hello_2_1" + assert function_2_1.name == "hello_2.hello_2_1" assert len(function_2_1.child_functions) == 1 function_2_1_1 = function_2_1.child_functions[0] assert isinstance(function_2_1_1, Function) - assert function_2_1_1.name == "hello_2_1_1" + assert function_2_1_1.name == "hello_2.hello_2_1.hello_2_1_1" assert len(function_2_1_1.child_functions) == 0 function_3 = info.functions[6] @@ -120,12 +120,12 @@ def hello_3_1_1(): function_3_1 = function_3.child_functions[0] assert isinstance(function_3_1, Function) - assert function_3_1.name == "hello_3_1" + assert function_3_1.name == "hello_3.hello_3_1" assert len(function_3_1.child_functions) == 1 function_3_1_1 = function_3_1.child_functions[0] assert isinstance(function_3_1_1, Function) - assert function_3_1_1.name == "hello_3_1_1" + assert function_3_1_1.name == "hello_3.hello_3_1.hello_3_1_1" assert len(function_3_1_1.child_functions) == 0