From a79b538de8e3bb1f6a1ff097dc7fceb3daa25e58 Mon Sep 17 00:00:00 2001 From: Jakob Scheid Date: Mon, 13 Apr 2026 09:24:36 +0200 Subject: [PATCH] Add class for Python modules --- docs/CHANGELOG.md | 3 +- src/jcloud_docsgen/core/python/_core.py | 60 ++++++-- .../python/_core/test__collect_definitions.py | 138 ++++++++++++++++++ 3 files changed, 189 insertions(+), 12 deletions(-) create mode 100644 tests/unit/core/python/_core/test__collect_definitions.py diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c0cfed6..e149bb3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -17,4 +17,5 @@ - Add `PythonDocumentationGenerator` method to collect all namespaces - Add class for existing files - Add classes for Python function arguments -- Add classes for Python definitions \ No newline at end of file +- Add classes for Python definitions +- Add class for Python modules \ No newline at end of file diff --git a/src/jcloud_docsgen/core/python/_core.py b/src/jcloud_docsgen/core/python/_core.py index 56b9a4e..209954a 100644 --- a/src/jcloud_docsgen/core/python/_core.py +++ b/src/jcloud_docsgen/core/python/_core.py @@ -13,23 +13,61 @@ # limitations under the License. from __future__ import annotations -from ...utils import ExistingDirectory, assert_that_is_instance -from .namespaces import PythonModuleNamespace, PythonPackageNamespace, PythonNamespace -from typing import List +from ...utils import ExistingDirectory, assert_that_is_instance, ExistingFile +from .namespaces import PythonModuleNamespace, PythonPackageNamespace +from .arguments import PythonASTArgumentsListParser +from .definitions import PythonDefinition, PythonFunctionDefinition, PythonAsyncFunctionDefinition, PythonClassDefinition import pathlib +import ast +from collections.abc import Iterator __all__ = [ - 'PythonDocumentationGenerator' + 'PythonModuleDocumentationGenerator', + 'PythonDocumentationGenerator', ] -# class PythonModuleDocumentationGenerator: -# ''' -# A documentation generator for a Python module. +def _collect_definitions(tree_or_node) -> Iterator[PythonDefinition]: + ''' + Collects all definitions of the tree or node. + :param tree_or_node: The tree or node. -# ''' + :return: The definitions. + :rtype: Iterator[PythonDefinition] + ''' -# def __init__(self, module_path: ) + for node in tree_or_node.body: + if isinstance(node, ast.FunctionDef): + yield PythonFunctionDefinition.from_node(node) + if isinstance(node, ast.AsyncFunctionDef): + yield PythonAsyncFunctionDefinition.from_node(node) + if isinstance(node, ast.ClassDef): + yield PythonClassDefinition.from_node(node) + +class PythonModuleDocumentationGenerator: + ''' + A documentation generator for a Python module. + + :param module_path: The path of the module file. + :type module_path: ExistingFile + ''' + + def __init__(self, module_path: ExistingFile) -> None: + assert_that_is_instance(module_path, ExistingFile) + + self.module_path = pathlib.Path(str(module_path)) + + def collect_definitions(self) -> Iterator[PythonDefinition]: + ''' + Collects all function and class definitions of the module. + + :return: All definitions of the module + :rtype: Iterator[PythonDefinition] + ''' + + tree = ast.parse(self.module_path.read_text()) + + return _collect_definitions(tree) class PythonDocumentationGenerator: ''' @@ -59,12 +97,12 @@ class PythonDocumentationGenerator: return namespace - def namespace(self) -> List[PythonPackageNamespace]: + def namespace(self) -> list[PythonPackageNamespace]: ''' Returns the project as a namespace. :return: The project as a namespace. - :rtype: PythonPackageNamespace + :rtype: list[PythonPackageNamespace] ''' src_dir = self.project_directory / 'src' diff --git a/tests/unit/core/python/_core/test__collect_definitions.py b/tests/unit/core/python/_core/test__collect_definitions.py new file mode 100644 index 0000000..835013a --- /dev/null +++ b/tests/unit/core/python/_core/test__collect_definitions.py @@ -0,0 +1,138 @@ +# Copyright 2026 jCloud Services GbR + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from src.jcloud_docsgen.core.python._core import _collect_definitions +from src.jcloud_docsgen.core.python.definitions import PythonClassDefinition, PythonFunctionDefinition +from src.jcloud_docsgen.core.python.arguments import PythonFunctionArgument, PythonArgumentKind +import pytest +import ast + +def module(source_code: str) -> ast.Module: + ''' + Returns an AST module of the source code. + + :param source_code: The module source code. + :type source_code: str + + :return: The AST module. + :rtype: ast.Module + ''' + + return ast.parse(source_code) + +@pytest.mark.parametrize('module,expected', [ + (module('def add(a, b):\n\treturn a + b'), [ + PythonFunctionDefinition('add', [ + PythonFunctionArgument('a', PythonArgumentKind.NORMAL, None, None), + PythonFunctionArgument('b', PythonArgumentKind.NORMAL, None, None), + ], None, None, []) + ]), + (module('''def add(a: int | float, b: int | float) -> int | float: + """Adds two numbers.""" + return a + b + +import typing + +class Something: + """A class.""" + def __init__(self, value: typing.Any = 42) -> None: + self._value = value + + @property + def value(self) -> typing.Any: + """ + The value. + """ + return self.get_value() + + def get_value(self) -> typing.Any: + """ + Returns the value. + """ + return self._value +'''), [ + PythonFunctionDefinition('add', [ + PythonFunctionArgument('a', PythonArgumentKind.NORMAL, None, 'int | float'), + PythonFunctionArgument('b', PythonArgumentKind.NORMAL, None, 'int | float'), + ], 'int | float', 'Adds two numbers.', []), + PythonClassDefinition('Something', [], 'A class.', [], [ + PythonFunctionDefinition('__init__', [ + PythonFunctionArgument('self', PythonArgumentKind.NORMAL, None, None), + PythonFunctionArgument('value', PythonArgumentKind.NORMAL, '42', 'typing.Any'), + ], 'None', None, []), + PythonFunctionDefinition('value', [ + PythonFunctionArgument('self', PythonArgumentKind.NORMAL, None, None), + ], 'typing.Any', 'The value.', ['property']), + PythonFunctionDefinition('get_value', [ + PythonFunctionArgument('self', PythonArgumentKind.NORMAL, None, None), + ], 'typing.Any', 'Returns the value.', []), + ]) + ]), + (module('''from src.jcloud_docsgen.core.python import PythonDocumentationGenerator +from src.jcloud_docsgen.core.python.namespaces import PythonModuleNamespace, PythonPackageNamespace +import pytest +from src.jcloud_docsgen.utils import ExistingDirectory + +@pytest.mark.parametrize('project_directory,docs_directory', [ + (1, 1), + (1, None), + (42, None), + (ExistingDirectory('tests'), 1), + (ExistingDirectory('tests/'), 1), + (1, ExistingDirectory('tests/')), + (1, ExistingDirectory('tests')), + (None, ExistingDirectory('tests')), +]) +def test_PythonDocumentationGenerator_type_exceptions(project_directory, docs_directory): + with pytest.raises(TypeError): + PythonDocumentationGenerator(project_directory, docs_directory) + +@pytest.mark.parametrize('python_documentation_generator,expected', [ + (PythonDocumentationGenerator(ExistingDirectory('tests/unit/core/python/_core/test_project_dirs/pdir_1'), ExistingDirectory('tests/unit/core/python/_core/test_project_dirs/pdir_1/docs')), []), + (PythonDocumentationGenerator(ExistingDirectory('tests/unit/core/python/_core/test_project_dirs/pdir_2'), ExistingDirectory('tests/unit/core/python/_core/test_project_dirs/pdir_2/docs')), [PythonPackageNamespace('pkg', [PythonModuleNamespace('module')])]), + (PythonDocumentationGenerator(ExistingDirectory('tests/unit/core/python/_core/test_project_dirs/pdir_3'), ExistingDirectory('tests/unit/core/python/_core/test_project_dirs/pdir_3/docs')), [PythonPackageNamespace('pkg', [PythonModuleNamespace('module'), PythonPackageNamespace('pkg', [PythonModuleNamespace('module2')])])]), +]) +def test_PythonDocumentationGenerator_collect_modules(python_documentation_generator: PythonDocumentationGenerator, expected): + assert python_documentation_generator.namespace() == expected'''), [ + PythonFunctionDefinition('test_PythonDocumentationGenerator_type_exceptions', [ + PythonFunctionArgument('project_directory', PythonArgumentKind.NORMAL, None, None), + PythonFunctionArgument('docs_directory', PythonArgumentKind.NORMAL, None, None), + ], None, None, ['''pytest.mark.parametrize('project_directory,docs_directory', [(1, 1), (1, None), (42, None), (ExistingDirectory('tests'), 1), (ExistingDirectory('tests/'), 1), (1, ExistingDirectory('tests/')), (1, ExistingDirectory('tests')), (None, ExistingDirectory('tests'))])''']), + PythonFunctionDefinition('test_PythonDocumentationGenerator_collect_modules', [ + PythonFunctionArgument('python_documentation_generator', PythonArgumentKind.NORMAL, None, 'PythonDocumentationGenerator'), + PythonFunctionArgument('expected', PythonArgumentKind.NORMAL, None, None) + ], None, None, ['''pytest.mark.parametrize('python_documentation_generator,expected', [(PythonDocumentationGenerator(ExistingDirectory('tests/unit/core/python/_core/test_project_dirs/pdir_1'), ExistingDirectory('tests/unit/core/python/_core/test_project_dirs/pdir_1/docs')), []), (PythonDocumentationGenerator(ExistingDirectory('tests/unit/core/python/_core/test_project_dirs/pdir_2'), ExistingDirectory('tests/unit/core/python/_core/test_project_dirs/pdir_2/docs')), [PythonPackageNamespace('pkg', [PythonModuleNamespace('module')])]), (PythonDocumentationGenerator(ExistingDirectory('tests/unit/core/python/_core/test_project_dirs/pdir_3'), ExistingDirectory('tests/unit/core/python/_core/test_project_dirs/pdir_3/docs')), [PythonPackageNamespace('pkg', [PythonModuleNamespace('module'), PythonPackageNamespace('pkg', [PythonModuleNamespace('module2')])])])])''']) + ]), + (module('''class C1: + class C2: + class C3: + def function(self, value: bool, /, arg) -> bool: + def should_be_not_in_the_result(arg: int) -> object: + pass + return not value'''), [ + PythonClassDefinition('C1', [], None, [], [ + PythonClassDefinition('C2', [], None, [], [ + PythonClassDefinition('C3', [], None, [], [ + PythonFunctionDefinition('function', [ + PythonFunctionArgument('self', PythonArgumentKind.POSITIONAL_ONLY, None, None), + PythonFunctionArgument('value', PythonArgumentKind.POSITIONAL_ONLY, None, 'bool'), + PythonFunctionArgument('arg', PythonArgumentKind.NORMAL, None, None), + ], 'bool', None, []) + ]) + ]) + ]) + ]) +]) +def test___collect_definitions(module, expected): + assert list(_collect_definitions(module)) == expected \ No newline at end of file