Add Python definition documentation generator

This commit is contained in:
2026-04-19 19:12:33 +02:00
parent 3b45c1a8ed
commit 3fd51d126f
9 changed files with 2486 additions and 10 deletions
+2 -1
View File
@@ -22,4 +22,5 @@
- Add feature to get the signature representation of a Python function argument - Add feature to get the signature representation of a Python function argument
- Add class for Python docstrings - Add class for Python docstrings
- Add feature to parse Python docstrings - Add feature to parse Python docstrings
- Add function to ensure a non-empty string. - Add function to ensure a non-empty string.
- Add Python definition documentation generator
+694 -3
View File
@@ -13,15 +13,23 @@
# limitations under the License. # limitations under the License.
from __future__ import annotations from __future__ import annotations
from ...utils import ExistingDirectory, assert_that_is_instance, ExistingFile from ...utils import ExistingDirectory, assert_that_is_instance, ExistingFile, non_empty_str
from ...exceptions import PythonArgumentStructureError
from .namespaces import PythonModuleNamespace, PythonPackageNamespace from .namespaces import PythonModuleNamespace, PythonPackageNamespace
from .arguments import PythonASTArgumentsListParser
from .definitions import PythonDefinition, PythonFunctionDefinition, PythonAsyncFunctionDefinition, PythonClassDefinition from .definitions import PythonDefinition, PythonFunctionDefinition, PythonAsyncFunctionDefinition, PythonClassDefinition
from .arguments import PythonArgumentKind, PythonFunctionArgument
from .docstrings import PythonDocstring
from collections.abc import Iterator
from typing import Union
from dataclasses import dataclass
from enum import Enum
import docstring_parser
import pathlib import pathlib
import ast import ast
from collections.abc import Iterator
__all__ = [ __all__ = [
'PythonDefinitionDocumentationIncludeSections',
'PythonDefinitionDocumentationGenerator',
'PythonModuleDocumentationGenerator', 'PythonModuleDocumentationGenerator',
'PythonDocumentationGenerator', 'PythonDocumentationGenerator',
] ]
@@ -44,6 +52,689 @@ def _collect_definitions(tree_or_node) -> Iterator[PythonDefinition]:
if isinstance(node, ast.ClassDef): if isinstance(node, ast.ClassDef):
yield PythonClassDefinition.from_node(node) yield PythonClassDefinition.from_node(node)
def _group_argument_list(argument_list: list[PythonFunctionArgument]) -> tuple[list[PythonFunctionArgument], list[PythonFunctionArgument], Union[PythonFunctionArgument, None], list[PythonFunctionArgument], Union[PythonFunctionArgument, None]]:
'''
Groups the argument list into a list of positional-only arguments, a
list of normal arguments, the * argument if there is one, a list of
keyword-only arguments and the var-keyword-argument if there is one.
:param argument_list: The argument list.
:type argument_list: list[PythonFunctionArgument]
:rtype: tuple[list[PythonFunctionArgument], list[PythonFunctionArgument], Union[PythonFunctionArgument, None], list[PythonFunctionArgument], Union[PythonFunctionArgument, None]]
'''
if argument_list:
if argument_list[-1].kind != PythonArgumentKind.KWARGS and any([
arg.kind == PythonArgumentKind.KWARGS
for arg in argument_list
]):
raise PythonArgumentStructureError('arguments cannot follow var-keyword argument')
positional_only_args = []
normal_args = []
vararg = None
keyword_only_args = []
kwargs = None
for arg in argument_list:
if arg.kind == PythonArgumentKind.POSITIONAL_ONLY:
positional_only_args.append(arg)
if arg.kind == PythonArgumentKind.NORMAL:
normal_args.append(arg)
if arg.kind == PythonArgumentKind.VARARG:
if vararg is not None:
raise PythonArgumentStructureError('* argument may only appear once', definition = arg)
vararg = arg
if arg.kind == PythonArgumentKind.KEYWORD_ONLY:
keyword_only_args.append(arg)
if arg.kind == PythonArgumentKind.KWARGS:
kwargs = arg
return positional_only_args, normal_args, vararg, keyword_only_args, kwargs
def _signature_argument_list(argument_list: list[PythonFunctionArgument]) -> list[str]:
'''
Returns a list of the arguments as strings for a function signature.
:param argument_list: The argument list.
:type argument_list: list[PythonFunctionArgument]
:return: The list of function signature argument strings.
:rtype: list[str]
'''
positional_only_args, normal_args, vararg, keyword_only_args, kwargs = _group_argument_list(argument_list)
args = []
for arg in positional_only_args:
args.append(arg.signature_repr())
if positional_only_args:
args.append('/')
for arg in normal_args:
args.append(arg.signature_repr())
if vararg is not None:
args.append(vararg.signature_repr())
if keyword_only_args and vararg is None:
args.append('*')
for arg in keyword_only_args:
args.append(arg.signature_repr())
if kwargs is not None:
args.append(kwargs.signature_repr())
return args
def _fit_row_length(expected: int, row: tuple) -> tuple:
'''
Fits the row length to the expected length.
If the row is too long, the tail, that makes it too long, is omitted.
If it is too short, it is filled with ``None`` (at the end).
:param expected: The expected length.
:type expected: int
:param row: The row.
:type row: tuple
'''
if expected < 0:
raise ValueError('expected must be larger than or equal 0.')
if len(row) > expected:
return row[:expected]
else:
return row + (None,) * (expected - len(row))
def _markdown_table(header: tuple, body: list[tuple], *, allow_markdown_table: bool = False, force_row_lengths: bool = False, one_line_values: bool = True, missing_value: str = '--') -> str:
'''
Generates a table.
If ``allow_markdown_table`` is ``False``, an alternative
representation is used.
:param headers: The table headers.
:type headers: tuple
:param body: The table body.
:type body: list[tuple]
:param allow_markdown_table: Controls whether a markdown table may be
used.
:type allow_markdown_table: bool
:param force_row_lengths: Controls whether an exception is raised if
a row does not have the correct length.
:type force_row_lengths: bool
:param one_line_values: Controls whether all newlines in all values
are replaced with spaces.
:type one_line_values: bool
:param missing_value: The string that is used if a value is missing
(only if ``force_row_lengths`` is ``False``).
:type missing_value: str
'''
if not header or not body:
return ''
header_len = len(header)
if allow_markdown_table:
table = '| ' + ' | '.join(header) + ' |\n|' + ' --- |' * len(header) + '\n'
for row in body:
if len(row) != header_len:
if force_row_lengths:
raise ValueError(f'expected {header_len} value{"s" if header_len > 1 else ""}, got {len(row)}')
else:
row = _fit_row_length(header_len, row)
table += '| ' + ' | '.join([
str(col if col is not None else missing_value).replace('\n', ' ')
if one_line_values
else str(col if col is not None else missing_value)
for col in row
]) + ' |\n'
else:
table = ''
for row in body:
if len(row) != header_len:
if force_row_lengths:
raise ValueError(f'expected {header_len} value{"s" if header_len > 1 else ""}, got {len(row)}')
else:
row = _fit_row_length(header_len, row)
sep = '- '
for i, col in enumerate(row):
table += sep + header[i] + ': ' + (str(col if col is not None else missing_value).replace('\n', ' ') \
if one_line_values \
else str(col if col is not None else missing_value)) + '\n\n'
sep = ' '
return table.strip()
def _is_docstring(docstring: Union[str, PythonDocstring, None]) -> bool:
'''
Checks whether the docstring is a docstring or an empty docstring.
:param docstring: The docstring.
:type docstring: Union[str, PythonDocstring, None]
:return: Whether there is a docstring.
:rtype: bool
'''
if docstring is None:
return False
if isinstance(docstring, PythonDocstring):
docstring = docstring.docstring
return docstring.strip() != ''
@dataclass
class _FunctionParamInfo:
annotation: Union[str, None]
description: Union[str, None]
@dataclass
class _NoDocstringTemplate:
description = None
params = []
raises = []
returns = None
many_returns = []
deprecation = None
examples = []
short_description = None
long_description = None
blank_after_short_description = None
_NoDocstring = _NoDocstringTemplate()
def _argument_kind_human_readable(argument_kind: PythonArgumentKind) -> str:
'''
Returns the human-readable representation of a
``PythonArgumentKind``.
:param argument_kind: The argument kind.
:type argument_kind: PythonArgumentKind
:return: The human-readable representation.
:rtype: str
'''
return argument_kind.name.lower().replace('_', '-')
def _function_params_info(arg_list: list[PythonFunctionArgument], docstring_arg_list: list[docstring_parser.DocstringParam]) -> dict[str, _FunctionParamInfo]:
'''
Returns a dictionary with the parameter info.
The keys of the dictionary are the parameter names and the values are
their types. If there is no type annotation for a parameter, the type
in the docstring is used as a fallback. If the docstring also does
not contain the type for a parameter, its value in the dictionary is
set to ``None``.
:param arg_list: The list of parameters from the function parameter
list.
:type arg_list: list[PythonFunctionArgument]
:param docstring_arg_list: The list of parameters from the docstring
parameter list.
:return: The dictionary with the parameter types.
:rtype: dict[str, _FunctionParamInfo]
'''
arg_annotations = {arg.name: _FunctionParamInfo(arg.annotation, None) for arg in arg_list}
for arg in docstring_arg_list:
if arg.arg_name in arg_annotations.keys():
arg_annotations[arg.arg_name] = _FunctionParamInfo(
arg_annotations[arg.arg_name].annotation or arg.type_name,
arg.description
)
return arg_annotations
class PythonDefinitionDocumentationIncludeSections:
'''
A class representing the included sections in a Python definition
documentation.
:param signature: Whether include the signature section.
:type signature: bool
:param returns: Whether include the returns section.
:type returns: bool
:param params: Whether include the params section.
:type params: bool
:param exceptions: Whether include the exceptions section.
:type exceptions: bool
:param examples: Whether include the examples section.
:type examples: bool
:param docstring: Whether include the docstring section.
:type docstring: bool
:param bases: Whether include the bases section.
:type bases: bool
:param decorators: Whether include the decorators section.
:type decorators: bool
'''
def __init__(
self,
*,
signature: bool = True,
returns: bool = True,
params: bool = True,
exceptions: bool = True,
examples: bool = True,
docstring: bool = True,
bases: bool = True,
decorators: bool = True,
class_body: bool = True
):
self.signature = signature
self.returns = returns
self.params = params
self.exceptions = exceptions
self.examples = examples
self.docstring = docstring
self.bases = bases
self.decorators = decorators
self.class_body = class_body
self._args = {
'signature': self.signature,
'returns': self.returns,
'params': self.params,
'exceptions': self.exceptions,
'examples': self.examples,
'docstring': self.docstring,
'bases': self.bases,
'decorators': self.decorators,
'class_body': self.class_body,
}
def __repr__(self) -> str:
return type(self).__name__+ '(' + ', '.join(
[
key + ' = ' + repr(value)
for key, value in self._args.items()
]
) + ')'
def __eq__(self, value) -> str:
if not isinstance(value, PythonDefinitionDocumentationIncludeSections):
return False
return self._args == value._args
class PythonDefinitionDocumentationGenerator:
'''
A documentation generator for a Python definition.
:param definition: The Python definition.
:type definition: PythonDefinition
:param include_sections: The sections that will be included.
:type include_sections: PythonDefinitionDocumentationIncludeSections
:param level: The level of the definition in the markdown content.
:type level: int
:param allow_html: Controls whether the generated documentation may
contain HTML.
:type allow_html: bool
:param allow_tables: Controls whether the generated documentation may
contain tables.
:type allow_tables: bool
:param skip_empty_sections: Controls whether sections that does not
have any content (such as the parameters
section if there are no parameters taken)
are skipped. If ``False``, a short text
(such as 'This function takes no
parameters') is used.
:type skip_empty_sections: bool
:param is_method: Controls whether the definition is a class method.
:type is_method: bool
'''
def __init__(
self,
definition: PythonDefinition,
include_sections: PythonDefinitionDocumentationIncludeSections = PythonDefinitionDocumentationIncludeSections(),
*,
level: int = 1,
allow_html: bool = True,
allow_tables: bool = False,
skip_empty_sections: bool = True,
is_method: bool = False
) -> None:
self.definition = definition
self.include_sections = include_sections
self.level = level
self.allow_html = allow_html
self.allow_tables = allow_tables
self.skip_empty_sections = skip_empty_sections
self.is_method = is_method
self._docstring_cache = None
@property
def _docstring(self):
if self._docstring_cache is None:
self._docstring_cache = self.definition.doc.parse() if _is_docstring(self.definition.doc) else _NoDocstring
return self._docstring_cache
def generate_signature(self) -> str:
'''
Generates the signature of the definition. Works only if the
definition is a function or asynchronous function definition.
:raises TypeError: If the definition is a class definition.
:return: The signature.
:rtype: str
'''
if isinstance(self.definition, PythonClassDefinition):
raise TypeError('Cannot generate a signature of a class definition.')
signature = self.definition.name + '(' + ', '.join(_signature_argument_list(self.definition.args)) + ')'
if self.definition.returns is not None:
signature += ' -> ' + self.definition.returns
return signature.strip()
def _generate_signature_section(self) -> str:
'''
Generates the signature section.
:return: The signature section.
:rtype: str
'''
return self.level * '#' + '# ' + ('Function' if not self.is_method else 'Method') +' signature\n\n```python\n' + self.generate_signature() + '\n```'
def _generate_docstring_section(self) -> str:
'''
Generates the docstring section.
:return: The docstring section.
:rtype: str
'''
md = ''
if _is_docstring(self.definition.doc) or not self.skip_empty_sections:
if self.allow_html:
md += '<details><summary><h' + str(self.level + 1) + ' style="display:inline">Docstring' + '</h' + str(self.level + 1) + '></summary>'
else:
md += self.level * '#' + '# Docstring\n\n'
if _is_docstring(self.definition.doc):
md += self.definition.doc.docstring
elif not self.skip_empty_sections:
md += 'There is no docstring to show.'
if _is_docstring(self.definition.doc) or not self.skip_empty_sections:
if self.allow_html:
md += '</details>'
md += '\n\n'
return md.strip()
def _generate_params_section(self) -> str:
'''
Generates the parameters section.
:return: The parameters section.
:rtype: str
'''
md = ''
if self.definition.args or not self.skip_empty_sections:
md += self.level * '#' + '# Parameters\n\n'
params_types = _function_params_info(self.definition.args, self._docstring.params)
if params_types:
md += _markdown_table(['Parameter', 'Type', 'Kind', 'Default value', 'Description'], [
(
'`' + arg.name + '`',
('`' + params_types.get(arg.name).annotation + '`') if params_types.get(arg.name).annotation is not None else None,
_argument_kind_human_readable(arg.kind),
('`' + arg.default + '`') if arg.default is not None else None,
non_empty_str(params_types.get(arg.name).description)
)
for arg in self.definition.args
], allow_markdown_table = self.allow_tables)
md += '\n\n'
elif not self.skip_empty_sections:
md += 'This function takes no parameters.'
return md.strip()
def _generate_returns_section(self) -> str:
'''
Generates the returns section.
:return: The returns section.
:rtype: str
'''
md = ''
if self._docstring.returns or self.definition.returns or not self.skip_empty_sections:
md += self.level * '#' + '# Returns\n\n'
returns = False
if self._docstring.returns:
if self._docstring.returns.description is not None:
returns = True
md += self._docstring.returns.description + '\n\n'
rtype = self.definition.returns
if rtype is None and self._docstring.returns is not None:
rtype = self._docstring.returns.type_name
if rtype is not None:
returns = True
md += 'Return type: `' + rtype + '`'
if not self.skip_empty_sections and not returns:
md += 'This function does not provide any information about its return value.'
return md.strip()
def _generate_exceptions_section(self) -> str:
'''
Generates the exceptions section.
:return: The exceptions section.
:rtype: str
'''
md = ''
if self._docstring.raises or not self.skip_empty_sections:
md += self.level * '#' + '# Exceptions\n\n'
if self._docstring.raises:
md += _markdown_table(('Exception', 'Description'), [
(('`' + exception.type_name + '`') if exception.type_name is not None else None, non_empty_str(exception.description))
for exception in self._docstring.raises
], allow_markdown_table = self.allow_tables)
elif not self.skip_empty_sections:
md += 'This function raises no exceptions.'
return md.strip()
def _generate_examples_section(self) -> str:
'''
Generates the examples section.
:return: The examples section.
:rtype: str
'''
md = ''
if self._docstring.examples or not self.skip_empty_sections:
md += self.level * '#' + '# Examples\n\n'
examples = []
for example in self._docstring.examples:
examples.append('```python\n' +\
(example.snippet or example.description) + \
'\n```')
md += '\n\n---\n\n'.join(examples)
if not self._docstring.examples and not self.skip_empty_sections:
md += 'There are no examples.'
return md.strip()
def _generate_decorators_section(self) -> str:
'''
Generates the decorators section.
:return: The decorators section.
:rtype: str
'''
md = ''
if self.definition.decorators or not self.skip_empty_sections:
md += self.level * '#' + '# Decorators\n\n'
for decorator in self.definition.decorators:
md += '```python\n' + decorator + '\n```\n\n'
if not self.definition.decorators and not self.skip_empty_sections:
md += 'This function or class does not have any decorators.'
return md.strip()
def _generate_bases_section(self) -> str:
'''
Generates the bases section.
:return: The bases section.
:rtype: str
'''
md = ''
if self.definition.bases or not self.skip_empty_sections:
md += self.level * '#' + '# Base classes\n\n'
for base in self.definition.bases:
md += '- `' + base + '`\n'
if not self.definition.bases and not self.skip_empty_sections:
md += 'This class does not have any base classes.'
return md.strip()
def _generate_body_section(self) -> str:
'''
Generates the body section.
:return: The body section.
:rtype: str
'''
md = ''
if self.definition.body or not self.skip_empty_sections:
md += self.level * '#' + '# Body\n\n'
for method_or_class in self.definition.body:
md += PythonDefinitionDocumentationGenerator(
method_or_class,
self.include_sections,
level = self.level + 2,
allow_html = self.allow_html,
allow_tables = self.allow_tables,
skip_empty_sections = self.skip_empty_sections,
is_method = True
).generate_documentation() + '\n\n'
if not self.definition.body and not self.skip_empty_sections:
md += 'This class does not have a body.'
return md.strip()
def generate_documentation(self) -> str:
'''
Generates the documentation in the markdown format.
:return: The documentation.
:rtype: str
'''
md = self.level * '#' + ' '
if isinstance(self.definition, PythonAsyncFunctionDefinition):
md += 'Asynchronous ' + ('function' if not self.is_method else 'method')
elif isinstance(self.definition, PythonFunctionDefinition):
if self.is_method and self.definition.name == '__init__':
md += 'Constructor (method'
else:
md += 'Function' if not self.is_method else 'Method'
else:
md += 'Class'
md += ' `' + self.definition.name + '`'
if isinstance(self.definition, PythonFunctionDefinition) and not isinstance(self.definition, PythonAsyncFunctionDefinition) and self.is_method and self.definition.name == '__init__':
md += ')'
md += '\n\n'
if self._docstring.description is not None:
md += self._docstring.description + '\n\n'
if isinstance(self.definition, PythonFunctionDefinition):
if self.include_sections.signature:
md += self._generate_signature_section() + '\n\n'
if self.include_sections.returns:
md += self._generate_returns_section() + '\n\n'
if self.include_sections.params:
md += self._generate_params_section() + '\n\n'
if self.include_sections.exceptions:
md += self._generate_exceptions_section() + '\n\n'
if isinstance(self.definition, PythonClassDefinition):
if self.include_sections.bases:
md += self._generate_bases_section() + '\n\n'
if self.include_sections.class_body:
md += self._generate_body_section() + '\n\n'
if self.include_sections.decorators:
md += self._generate_decorators_section() + '\n\n'
if self.include_sections.examples:
md += self._generate_examples_section() + '\n\n'
if self.include_sections.docstring:
md += self._generate_docstring_section() + '\n\n'
return md.strip()
class PythonModuleDocumentationGenerator: class PythonModuleDocumentationGenerator:
''' '''
A documentation generator for a Python module. A documentation generator for a Python module.
@@ -16,15 +16,9 @@ from enum import Enum
import docstring_parser import docstring_parser
__all__ = [ __all__ = [
'PythonDocstringStyle',
'PythonDocstring' 'PythonDocstring'
] ]
class PythonDocstringStyle(Enum):
SPHINX = 'sphinx'
NUMPY = 'numpy'
GOOGLE = 'google'
class PythonDocstring: class PythonDocstring:
''' '''
Represents a Python docstring. Represents a Python docstring.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
# 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 _argument_kind_human_readable
from src.jcloud_docsgen.core.python.arguments import PythonArgumentKind
import pytest
@pytest.mark.parametrize('argument_kind,expected', [
(PythonArgumentKind.NORMAL, 'normal'),
(PythonArgumentKind.POSITIONAL_ONLY, 'positional-only'),
(PythonArgumentKind.KEYWORD_ONLY, 'keyword-only'),
(PythonArgumentKind.VARARG, 'vararg'),
(PythonArgumentKind.KWARGS, 'kwargs'),
])
def test__argument_kind_human_readable(argument_kind, expected):
assert _argument_kind_human_readable(argument_kind) == expected
@@ -0,0 +1,56 @@
# 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 _fit_row_length
import pytest
@pytest.mark.parametrize('expected_length,row,expected', [
(0, (), ()),
(1, (), (None,)),
(2, (), (None, None)),
(2, (1,), (1, None)),
(0, (1,), ()),
(1, (1, 2), (1,)),
(1, (1, 2), (1,)),
(0, (1, 2), ()),
(0, (1, 2, 3), ()),
(1, (1, 2, 3), (1,)),
(2, (1, 2, 3), (1, 2)),
(1, (1,), (1,)),
(2, (1, 1), (1, 1)),
(2, (1, 2), (1, 2)),
(3, (1, 2, 3), (1, 2, 3)),
])
def test__fit_row_length(expected_length, row, expected):
assert _fit_row_length(expected_length, row) == expected
@pytest.mark.parametrize('row', [
(),
(1,),
(1, 2),
(1, 2, 3),
(1, 2, 3, 456),
])
@pytest.mark.parametrize('expected_length,expected_exception', [
(-1, ValueError),
(-2, ValueError),
(-3, ValueError),
(-42, ValueError),
])
def test__fit_row_length_exceptions(expected_length, row, expected_exception):
with pytest.raises(expected_exception) as exc_info:
_fit_row_length(expected_length, row)
assert str(exc_info.value) == 'expected must be larger than or equal 0.'
@@ -0,0 +1,104 @@
# 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 _function_params_info, _FunctionParamInfo
from src.jcloud_docsgen.core.python.arguments import PythonFunctionArgument, PythonArgumentKind
import pytest
import docstring_parser
def docstring_args_list(docstring: str) -> list[docstring_parser.DocstringParam]:
'''
Returns the parameter list of a docstring.
:param docstring: The docstring
:type docstring: str
:return: The parameter list.
:rtype: list[docstring_parser.DocstringParam]
'''
return docstring_parser.parse(docstring).params
@pytest.mark.parametrize('arg_list,docstring,expected', [
([
PythonFunctionArgument('a', PythonArgumentKind.NORMAL, '', 'int'),
], ':param a: a\n:type a: str', {
'a': _FunctionParamInfo('int', 'a')
}),
([
PythonFunctionArgument('a', PythonArgumentKind.NORMAL, '', None),
], ':param a: a\n:type a: str', {
'a': _FunctionParamInfo('str', 'a')
}),
([
PythonFunctionArgument('a', PythonArgumentKind.NORMAL, '', None),
PythonFunctionArgument('b', PythonArgumentKind.NORMAL, '', None),
], ':param a: a\n:type a: str', {
'a': _FunctionParamInfo('str', 'a'),
'b': _FunctionParamInfo(None, None)
}),
([
PythonFunctionArgument('a', PythonArgumentKind.NORMAL, '', None),
PythonFunctionArgument('b', PythonArgumentKind.NORMAL, '', 'int'),
], ':param a: a\n:type a: str', {
'a': _FunctionParamInfo('str', 'a'),
'b': _FunctionParamInfo('int', None)
}),
([
PythonFunctionArgument('a', PythonArgumentKind.NORMAL, '', None),
PythonFunctionArgument('b', PythonArgumentKind.NORMAL, '', 'int'),
], ':param a: a\n:type a: str\n:param b: b\n:type b: str', {
'a': _FunctionParamInfo('str', 'a'),
'b': _FunctionParamInfo('int', 'b')
}),
([
PythonFunctionArgument('a', PythonArgumentKind.NORMAL, '', None),
], ':param a: a\n:type a: str\n:param b: b\n:type b: int', {
'a': _FunctionParamInfo('str', 'a')
}),
([
PythonFunctionArgument('a', PythonArgumentKind.NORMAL, '', None),
], '', {
'a': _FunctionParamInfo(None, None)
}),
([
PythonFunctionArgument('a', PythonArgumentKind.NORMAL, '', 'str'),
], '', {
'a': _FunctionParamInfo('str', None)
}),
([
PythonFunctionArgument('a', PythonArgumentKind.NORMAL, '', 'str'),
], ':param a:\n:type a: int', {
'a': _FunctionParamInfo('str', '')
}),
([
PythonFunctionArgument('a', PythonArgumentKind.NORMAL, '', None),
], ':param a:\n:type a: int', {
'a': _FunctionParamInfo('int', '')
}),
([
PythonFunctionArgument('a', PythonArgumentKind.NORMAL, '', None),
], ':param a: a', {
'a': _FunctionParamInfo(None, 'a')
}),
([
PythonFunctionArgument('a', PythonArgumentKind.NORMAL, '', 'str'),
], ':param a: a', {
'a': _FunctionParamInfo('str', 'a')
}),
([], ':param a: a\n:type a: str', {}),
([], '', {}),
])
def test__function_params_types(arg_list, docstring, expected):
assert _function_params_info(arg_list, docstring_args_list(docstring)) == expected
@@ -0,0 +1,93 @@
# 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 _markdown_table
import pytest
@pytest.mark.parametrize('header,body,allow_markdown_table,force_row_lengths,one_line_values,missing_value,expected', [
([], [], False, False, True, '--', ''),
([], [('a',)], False, False, True, '--', ''),
(['a'], [], False, False, True, '--', ''),
(['a'], [(1,)], True, False, True, '--', '''| a |
| --- |
| 1 |'''),
(['a'], [(1, 2)], True, False, True, '--', '''| a |
| --- |
| 1 |'''),
(['a', 'b'], [(1, 2)], True, False, True, '--', '''| a | b |
| --- | --- |
| 1 | 2 |'''),
(['a', 'b'], [(1,)], True, False, True, '--', '''| a | b |
| --- | --- |
| 1 | -- |'''),
(['a', 'b'], [()], True, False, True, '--', '''| a | b |
| --- | --- |
| -- | -- |'''),
(['a', 'b'], [(1, 2), (3, 4)], True, False, True, '--', '''| a | b |
| --- | --- |
| 1 | 2 |
| 3 | 4 |'''),
(['a'], [('line1\nline2',)], True, False, True, '--', '''| a |
| --- |
| line1 line2 |'''),
(['a'], [('line1\nline2',)], True, False, False, '--', '''| a |
| --- |
| line1\nline2 |'''),
([], [], False, False, True, '--', ''),
([], [('a',)], False, False, True, '--', ''),
(['a'], [], False, False, True, '--', ''),
(['a'], [(1,)], False, False, True, '--', '''- a: 1'''),
(['a'], [(1, 2)], False, False, True, '--', '''- a: 1'''),
(['a', 'b'], [(1, 2)], False, False, True, '--', '''- a: 1
b: 2'''),
(['a', 'b'], [(1,)], False, False, True, '--', '''- a: 1
b: --'''),
(['a', 'b'], [()], False, False, True, '--', '''- a: --
b: --'''),
(['a', 'b'], [(1, 2), (3, 4)], False, False, True, '--', '''- a: 1
b: 2
- a: 3
b: 4'''),
(['a'], [('line1\nline2',)], False, False, True, '--', '- a: line1 line2'),
(['a'], [('line1\nline2',)], False, False, False, '--', '- a: line1\nline2'),
])
def test__markdown_table(header, body, allow_markdown_table, force_row_lengths, one_line_values, missing_value, expected):
assert _markdown_table(header, body, allow_markdown_table = allow_markdown_table, force_row_lengths = force_row_lengths, one_line_values = one_line_values, missing_value = missing_value) == expected
@pytest.mark.parametrize('header,body,allow_markdown_table,force_row_lengths,one_line_values,missing_value,expected_exception,expected_exception_msg', [
(['a'], [(1, 2)], True, True, True, '--', ValueError, 'expected 1 value, got 2'),
(['a'], [(1, 2, 3)], True, True, True, '--', ValueError, 'expected 1 value, got 3'),
(['a'], [()], True, True, True, '--', ValueError, 'expected 1 value, got 0'),
(['a', 'b'], [()], True, True, True, '--', ValueError, 'expected 2 values, got 0'),
(['a', 'b'], [(1,)], True, True, True, '--', ValueError, 'expected 2 values, got 1'),
(['a', 'b'], [(1, 2, 3)], True, True, True, '--', ValueError, 'expected 2 values, got 3'),
(['a'], [(1, 2)], False, True, True, '--', ValueError, 'expected 1 value, got 2'),
(['a'], [(1, 2, 3)], False, True, True, '--', ValueError, 'expected 1 value, got 3'),
(['a'], [()], False, True, True, '--', ValueError, 'expected 1 value, got 0'),
(['a', 'b'], [()], False, True, True, '--', ValueError, 'expected 2 values, got 0'),
(['a', 'b'], [(1,)], False, True, True, '--', ValueError, 'expected 2 values, got 1'),
(['a', 'b'], [(1, 2, 3)], False, True, True, '--', ValueError, 'expected 2 values, got 3'),
])
def test__markdown_table_exceptions(header, body, allow_markdown_table, force_row_lengths, one_line_values, missing_value, expected_exception, expected_exception_msg):
with pytest.raises(expected_exception) as exc_info:
_markdown_table(header, body, allow_markdown_table = allow_markdown_table, force_row_lengths = force_row_lengths, one_line_values = one_line_values, missing_value = missing_value)
assert str(exc_info.value) == expected_exception_msg
@@ -0,0 +1,97 @@
# 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 _signature_argument_list
from src.jcloud_docsgen.core.python.arguments import PythonFunctionArgument, PythonArgumentKind
import pytest
@pytest.mark.parametrize('argument_list,expected', [
([
PythonFunctionArgument('a', PythonArgumentKind.NORMAL, None, None)
], ['a']),
([
PythonFunctionArgument('a', PythonArgumentKind.POSITIONAL_ONLY, None, None)
], ['a', '/']),
([
PythonFunctionArgument('a', PythonArgumentKind.POSITIONAL_ONLY, None, None),
PythonFunctionArgument('b', PythonArgumentKind.NORMAL, None, None),
], ['a', '/', 'b']),
([
PythonFunctionArgument('a', PythonArgumentKind.POSITIONAL_ONLY, None, None),
PythonFunctionArgument('b', PythonArgumentKind.NORMAL, None, None),
PythonFunctionArgument('c', PythonArgumentKind.KEYWORD_ONLY, None, None),
], ['a', '/', 'b', '*', 'c']),
([
PythonFunctionArgument('b', PythonArgumentKind.NORMAL, None, None),
PythonFunctionArgument('c', PythonArgumentKind.KEYWORD_ONLY, None, None),
], ['b', '*', 'c']),
([
PythonFunctionArgument('c', PythonArgumentKind.KEYWORD_ONLY, None, None),
], ['*', 'c']),
([
PythonFunctionArgument('b', PythonArgumentKind.POSITIONAL_ONLY, None, None),
PythonFunctionArgument('c', PythonArgumentKind.KEYWORD_ONLY, None, None),
], ['b', '/', '*', 'c']),
([
PythonFunctionArgument('args', PythonArgumentKind.VARARG, None, None),
], ['*args']),
([
PythonFunctionArgument('a', PythonArgumentKind.NORMAL, None, None),
PythonFunctionArgument('args', PythonArgumentKind.VARARG, None, None),
], ['a', '*args']),
([
PythonFunctionArgument('a', PythonArgumentKind.POSITIONAL_ONLY, None, None),
PythonFunctionArgument('args', PythonArgumentKind.VARARG, None, None),
], ['a', '/', '*args']),
([
PythonFunctionArgument('args', PythonArgumentKind.VARARG, None, None),
PythonFunctionArgument('a', PythonArgumentKind.KEYWORD_ONLY, None, None),
], ['*args', 'a']),
([
PythonFunctionArgument('args', PythonArgumentKind.VARARG, None, None),
PythonFunctionArgument('kwargs', PythonArgumentKind.KWARGS, None, None),
], ['*args', '**kwargs']),
([
PythonFunctionArgument('a', PythonArgumentKind.POSITIONAL_ONLY, None, None),
PythonFunctionArgument('args', PythonArgumentKind.VARARG, None, None),
PythonFunctionArgument('kwargs', PythonArgumentKind.KWARGS, None, None),
], ['a', '/', '*args', '**kwargs']),
([
PythonFunctionArgument('a', PythonArgumentKind.NORMAL, None, None),
PythonFunctionArgument('args', PythonArgumentKind.VARARG, None, None),
PythonFunctionArgument('kwargs', PythonArgumentKind.KWARGS, None, None),
], ['a', '*args', '**kwargs']),
([
PythonFunctionArgument('args', PythonArgumentKind.VARARG, None, None),
PythonFunctionArgument('a', PythonArgumentKind.KEYWORD_ONLY, None, None),
PythonFunctionArgument('kwargs', PythonArgumentKind.KWARGS, None, None),
], ['*args', 'a', '**kwargs']),
([
PythonFunctionArgument('a', PythonArgumentKind.KEYWORD_ONLY, None, None),
PythonFunctionArgument('kwargs', PythonArgumentKind.KWARGS, None, None),
], ['*', 'a', '**kwargs']),
([
PythonFunctionArgument('a', PythonArgumentKind.NORMAL, None, None),
PythonFunctionArgument('kwargs', PythonArgumentKind.KWARGS, None, None),
], ['a', '**kwargs']),
([
PythonFunctionArgument('a', PythonArgumentKind.POSITIONAL_ONLY, None, None),
PythonFunctionArgument('kwargs', PythonArgumentKind.KWARGS, None, None),
], ['a', '/', '**kwargs']),
([
PythonFunctionArgument('a', PythonArgumentKind.NORMAL, '1', 'int'),
], ['a: int = 1']),
])
def test__signature_argument_list(argument_list, expected):
assert _signature_argument_list(argument_list) == expected