From 16852f3d68102875f6f0150ee95d1069c0f4052d Mon Sep 17 00:00:00 2001 From: Jakob Scheid Date: Mon, 13 Apr 2026 14:15:49 +0200 Subject: [PATCH] Add class for Python docstrings --- docs/CHANGELOG.md | 3 +- src/jcloud_docsgen/core/python/definitions.py | 39 ++++++++++++++----- src/jcloud_docsgen/core/python/docstrings.py | 36 +++++++++++++++++ .../python/_core/test__collect_definitions.py | 9 +++-- .../definitions/test_PythonClassDefinition.py | 7 ++-- .../definitions/test_PythonDefinition.py | 16 ++++---- .../test_PythonFunctionDefinition.py | 7 +--- 7 files changed, 86 insertions(+), 31 deletions(-) create mode 100644 src/jcloud_docsgen/core/python/docstrings.py diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d0691bd..2109826 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,4 +19,5 @@ - Add classes for Python function arguments - Add classes for Python definitions - Add class for Python modules -- Add feature to get the signature representation of a Python function argument \ No newline at end of file +- Add feature to get the signature representation of a Python function argument +- Add class for Python docstrings \ No newline at end of file diff --git a/src/jcloud_docsgen/core/python/definitions.py b/src/jcloud_docsgen/core/python/definitions.py index 76cf28d..edc15da 100644 --- a/src/jcloud_docsgen/core/python/definitions.py +++ b/src/jcloud_docsgen/core/python/definitions.py @@ -16,8 +16,11 @@ from __future__ import annotations from typing import Union import ast from .arguments import PythonFunctionArgument, PythonASTArgumentsListParser +from .docstrings import PythonDocstring import keyword from ...exceptions import InvalidPythonIdentifierError +from ...utils import assert_that_is_instance +import types __all__ = [ 'PythonDefinition', @@ -26,6 +29,20 @@ __all__ = [ 'PythonClassDefinition' ] +def _python_docstring(doc: Union[str, None]) -> Union[PythonDocstring, None]: + ''' + Returns the PythonDocstring object if the docstring is a string and + not ``None``. + + :param doc: The docstring. + :type doc: Union[str, None] + + :return: The PythonDocstring object or ``None``. + :rtype: Union[PythonDocstring, None] + ''' + + return PythonDocstring(doc) if doc is not None else None + class PythonDefinition: ''' A base class for Python definition (a class, function or asynchronous @@ -34,7 +51,7 @@ class PythonDefinition: :param name: The name of the definition. :type name: str :param doc: The docstring. - :type doc: Union[str, None] + :type doc: Union[PythonDocstring, None] :param decorators: The definition decorators. :type decorators: list[str] @@ -42,10 +59,12 @@ class PythonDefinition: Python identifier. ''' - def __init__(self, name: str, doc: Union[str, None], decorators: list[str]) -> None: + def __init__(self, name: str, doc: Union[PythonDocstring, None], decorators: list[str]) -> None: if keyword.iskeyword(name) or not name.isidentifier() or not name: raise InvalidPythonIdentifierError('invalid identifier', identifier = name) + assert_that_is_instance(doc, (PythonDocstring, types.NoneType)) + self.name = name self.doc = doc self.decorators = decorators @@ -71,7 +90,7 @@ class PythonDefinition: return cls( node.name, - ast.get_docstring(node), + _python_docstring(ast.get_docstring(node)), [ast.unparse(d) for d in node.decorator_list] ) @@ -92,7 +111,7 @@ class PythonFunctionDefinition(PythonDefinition): :param returns: The return type of the function or class. :type returns: Union[str, None] :param doc: The docstring. - :type doc: Union[str, None] + :type doc: Union[PythonDocstring, None] :param decorators: The definition decorators. :type decorators: list[str] @@ -100,7 +119,7 @@ class PythonFunctionDefinition(PythonDefinition): Python identifier. ''' - def __init__(self, name: str, args: list[PythonFunctionArgument], returns: Union[str, None], doc: Union[str, None], decorators: list[str]) -> None: + def __init__(self, name: str, args: list[PythonFunctionArgument], returns: Union[str, None], doc: Union[PythonDocstring, None], decorators: list[str]) -> None: super().__init__(name, doc, decorators) self.args = args self.returns = returns @@ -127,7 +146,7 @@ class PythonFunctionDefinition(PythonDefinition): node.name, PythonASTArgumentsListParser(node.args).to_argument_list(), ast.unparse(node.returns) if node.returns is not None else None, - ast.get_docstring(node), + _python_docstring(ast.get_docstring(node)), [ast.unparse(d) for d in node.decorator_list] ) @@ -143,7 +162,7 @@ class PythonAsyncFunctionDefinition(PythonFunctionDefinition): :param returns: The return type of the function or class. :type returns: Union[str, None] :param doc: The docstring. - :type doc: Union[str, None] + :type doc: Union[PythonDocstring, None] :param decorators: The definition decorators. :type decorators: list[str] @@ -160,7 +179,7 @@ class PythonClassDefinition(PythonDefinition): :param bases: The base classes. :type bases: list[str] :param doc: The docstring. - :type doc: Union[str, None] + :type doc: Union[PythonDocstring, None] :param decorators: The definition decorators. :type decorators: list[str] :param body: @@ -170,7 +189,7 @@ class PythonClassDefinition(PythonDefinition): Python identifier. ''' - def __init__(self, name: str, bases: list[str], doc: Union[str, None], decorators: list[str], body: list[PythonDefinition]) -> None: + def __init__(self, name: str, bases: list[str], doc: Union[PythonDocstring, None], decorators: list[str], body: list[PythonDefinition]) -> None: super().__init__(name, doc, decorators) self.bases = bases self.body = body @@ -196,7 +215,7 @@ class PythonClassDefinition(PythonDefinition): return cls( node.name, [ast.unparse(b) for b in node.bases], - ast.get_docstring(node), + _python_docstring(ast.get_docstring(node)), [ast.unparse(d) for d in node.decorator_list], [ PythonFunctionDefinition.from_node(expr) diff --git a/src/jcloud_docsgen/core/python/docstrings.py b/src/jcloud_docsgen/core/python/docstrings.py new file mode 100644 index 0000000..9d1c799 --- /dev/null +++ b/src/jcloud_docsgen/core/python/docstrings.py @@ -0,0 +1,36 @@ +# 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. + +__all__ = [ + 'PythonDocstring' +] + +class PythonDocstring: + ''' + Represents a Python docstring. + + :param docstring: The docstring as a string. + :type docstring: str + ''' + + def __init__(self, docstring: str) -> None: + self.docstring = docstring + + def __repr__(self) -> str: + return type(self).__name__ + '(' + repr(self.docstring) + ')' + + def __eq__(self, value) -> bool: + if not isinstance(value, type(self)): + return False + return self.docstring == value.docstring \ No newline at end of file diff --git a/tests/unit/core/python/_core/test__collect_definitions.py b/tests/unit/core/python/_core/test__collect_definitions.py index 835013a..6f4dc80 100644 --- a/tests/unit/core/python/_core/test__collect_definitions.py +++ b/tests/unit/core/python/_core/test__collect_definitions.py @@ -15,6 +15,7 @@ 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 +from src.jcloud_docsgen.core.python.docstrings import PythonDocstring import pytest import ast @@ -65,18 +66,18 @@ class Something: 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.', [], [ + ], 'int | float', PythonDocstring('Adds two numbers.'), []), + PythonClassDefinition('Something', [], PythonDocstring('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']), + ], 'typing.Any', PythonDocstring('The value.'), ['property']), PythonFunctionDefinition('get_value', [ PythonFunctionArgument('self', PythonArgumentKind.NORMAL, None, None), - ], 'typing.Any', 'Returns the value.', []), + ], 'typing.Any', PythonDocstring('Returns the value.'), []), ]) ]), (module('''from src.jcloud_docsgen.core.python import PythonDocumentationGenerator diff --git a/tests/unit/core/python/definitions/test_PythonClassDefinition.py b/tests/unit/core/python/definitions/test_PythonClassDefinition.py index 3980214..21958b7 100644 --- a/tests/unit/core/python/definitions/test_PythonClassDefinition.py +++ b/tests/unit/core/python/definitions/test_PythonClassDefinition.py @@ -14,13 +14,14 @@ from src.jcloud_docsgen.core.python.definitions import PythonFunctionDefinition, PythonClassDefinition from src.jcloud_docsgen.core.python.arguments import PythonFunctionArgument, PythonArgumentKind +from src.jcloud_docsgen.core.python.docstrings import PythonDocstring from src.jcloud_docsgen.exceptions import InvalidPythonIdentifierError from tests.utils.ast_node import ast_node import pytest @pytest.mark.parametrize('definition,expected', [ (PythonClassDefinition('A', [], None, None, []), 'PythonClassDefinition(\'A\', [], None, None, [])'), - (PythonClassDefinition('A', [], None, 'Hello, World!', []), 'PythonClassDefinition(\'A\', [], None, \'Hello, World!\', [])'), + (PythonClassDefinition('A', [], None, PythonDocstring('Hello, World!'), []), 'PythonClassDefinition(\'A\', [], None, PythonDocstring(\'Hello, World!\'), [])'), ]) def test_PythonDefinition_string_representation(definition, expected): assert repr(definition) == expected @@ -80,13 +81,13 @@ def test_PythonFunctionDefinition_exceptions(name, args, returns, doc, decorator ], 'object', None, ['property']) ])), (ast_node('class Class: ...'), PythonClassDefinition('Class', [], None, [], [])), - (ast_node('class Class: \'\'\'docstring\'\'\''), PythonClassDefinition('Class', [], 'docstring', [], [])), + (ast_node('class Class: \'\'\'docstring\'\'\''), PythonClassDefinition('Class', [], PythonDocstring('docstring'), [], [])), (ast_node('class Class:\n\tdef __init__(self): ...'), PythonClassDefinition('Class', [], None, [], [ PythonFunctionDefinition('__init__', [ PythonFunctionArgument('self', PythonArgumentKind.NORMAL, None, None) ], None, None, []) ])), - (ast_node('class Class:\n\t\'\'\'doc\'\'\'\n\tdef __init__(self): ...'), PythonClassDefinition('Class', [], 'doc', [], [ + (ast_node('class Class:\n\t\'\'\'doc\'\'\'\n\tdef __init__(self): ...'), PythonClassDefinition('Class', [], PythonDocstring('doc'), [], [ PythonFunctionDefinition('__init__', [ PythonFunctionArgument('self', PythonArgumentKind.NORMAL, None, None) ], None, None, []) diff --git a/tests/unit/core/python/definitions/test_PythonDefinition.py b/tests/unit/core/python/definitions/test_PythonDefinition.py index d675ca0..4be2342 100644 --- a/tests/unit/core/python/definitions/test_PythonDefinition.py +++ b/tests/unit/core/python/definitions/test_PythonDefinition.py @@ -13,13 +13,13 @@ # limitations under the License. from src.jcloud_docsgen.core.python.definitions import PythonDefinition +from src.jcloud_docsgen.core.python.docstrings import PythonDocstring from src.jcloud_docsgen.exceptions import InvalidPythonIdentifierError -from tests.utils.ast_node import ast_node import pytest @pytest.mark.parametrize('definition,expected', [ (PythonDefinition('a', None, []), 'PythonDefinition(\'a\', None, [])'), - (PythonDefinition('a', 'Hello, World!', []), 'PythonDefinition(\'a\', \'Hello, World!\', [])'), + (PythonDefinition('a', PythonDocstring('Hello, World!'), []), 'PythonDefinition(\'a\', PythonDocstring(\'Hello, World!\'), [])'), ]) def test_PythonDefinition_string_representation(definition, expected): assert repr(definition) == expected @@ -67,17 +67,17 @@ def test_PythonDefinition_exceptions(name, doc, decorators, expected_exception, @pytest.mark.parametrize('definition1,definition2,expected', [ (PythonDefinition('a', None, []), PythonDefinition('a', None, []), True), - (PythonDefinition('a', 'doc', []), PythonDefinition('a', 'doc', []), True), - (PythonDefinition('a', 'doc', ['a']), PythonDefinition('a', 'doc', ['a']), True), - (PythonDefinition('a', 'doc', ['a', 'b']), PythonDefinition('a', 'doc', ['a', 'b']), True), + (PythonDefinition('a', PythonDocstring('doc'), []), PythonDefinition('a', PythonDocstring('doc'), []), True), + (PythonDefinition('a', PythonDocstring('doc'), ['a']), PythonDefinition('a', PythonDocstring('doc'), ['a']), True), + (PythonDefinition('a', PythonDocstring('doc'), ['a', 'b']), PythonDefinition('a', PythonDocstring('doc'), ['a', 'b']), True), (PythonDefinition('a', None, []), PythonDefinition('b', None, []), False), - (PythonDefinition('a', 'doc', []), PythonDefinition('a', None, []), False), - (PythonDefinition('a', 'doc', []), PythonDefinition('a', 'docs', []), False), + (PythonDefinition('a', PythonDocstring('doc'), []), PythonDefinition('a', None, []), False), + (PythonDefinition('a', PythonDocstring('doc'), []), PythonDefinition('a', PythonDocstring('docs'), []), False), (PythonDefinition('a', None, ['a']), PythonDefinition('a', None, ['b']), False), (PythonDefinition('a', None, ['a']), PythonDefinition('a', None, []), False), (PythonDefinition('a', None, ['a']), PythonDefinition('a', None, ['a', 'b']), False), (PythonDefinition('a', None, ['b', 'a']), PythonDefinition('a', None, ['a', 'b']), False), - (PythonDefinition('a', 'doc', []), PythonDefinition('b', None, ['a', 'b']), False), + (PythonDefinition('a', PythonDocstring('doc'), []), PythonDefinition('b', None, ['a', 'b']), False), ]) def test_PythonDefinition___eq__(definition1, definition2, expected): assert (definition1 == definition2) == expected diff --git a/tests/unit/core/python/definitions/test_PythonFunctionDefinition.py b/tests/unit/core/python/definitions/test_PythonFunctionDefinition.py index 0fe61c0..d332a3c 100644 --- a/tests/unit/core/python/definitions/test_PythonFunctionDefinition.py +++ b/tests/unit/core/python/definitions/test_PythonFunctionDefinition.py @@ -14,13 +14,14 @@ from src.jcloud_docsgen.core.python.definitions import PythonFunctionDefinition from src.jcloud_docsgen.core.python.arguments import PythonFunctionArgument, PythonArgumentKind +from src.jcloud_docsgen.core.python.docstrings import PythonDocstring from src.jcloud_docsgen.exceptions import InvalidPythonIdentifierError from tests.utils.ast_node import ast_node import pytest @pytest.mark.parametrize('definition,expected', [ (PythonFunctionDefinition('a', [], None, None, []), 'PythonFunctionDefinition(\'a\', [], None, None, [])'), - (PythonFunctionDefinition('a', [], None, 'Hello, World!', []), 'PythonFunctionDefinition(\'a\', [], None, \'Hello, World!\', [])'), + (PythonFunctionDefinition('a', [], None, PythonDocstring('Hello, World!'), []), 'PythonFunctionDefinition(\'a\', [], None, PythonDocstring(\'Hello, World!\'), [])'), ]) def test_PythonDefinition_string_representation(definition, expected): assert repr(definition) == expected @@ -81,10 +82,6 @@ def test_PythonFunctionDefinition_exceptions(name, args, returns, doc, decorator ], 'int', None, ['decorator1', 'decorator2'])), (ast_node('def func(): ...'), PythonFunctionDefinition('func', [], None, None, [])), (ast_node('@decorator\ndef func(): ...'), PythonFunctionDefinition('func', [], None, None, ['decorator'])), - # (ast_node('class Class(BaseClass1, BaseClass2):\n\tdef __init__(self, value: object) -> None:\n\t\tself._value = value\n\n\t@property\n\tdef value(self) -> object:\n\t\treturn self._value'), PythonFunctionDefinition('add', [ - # PythonFunctionArgument('a', PythonArgumentKind.NORMAL, None, 'int'), - # PythonFunctionArgument('b', PythonArgumentKind.NORMAL, None, 'int'), - # ], 'int', None, [])), ]) def test_PythonDefinition_from_node(node, expected): assert PythonFunctionDefinition.from_node(node) == expected \ No newline at end of file