2 Commits
Author SHA1 Message Date
jakob.scheid 045167098f docs(changelog): add Python signatures PEP 8 compliance entry
Added a changelog entry for the PEP 8 compliance of Python function
signatures default values.
2026-09-22 16:09:30 +02:00
jakob.scheid 31995e830b test(python-generator): update tests to PEP 8 compliance
Updated test cases to the new default value PEP 8 compliance and added
new test cases to test the new behavior.
2026-09-22 16:04:20 +02:00
28 changed files with 99 additions and 287 deletions

No files matched your search

+3 -2
View File
@@ -39,7 +39,8 @@ jobs:
- name: Generate documentation
run: |
jcloud-docsgen python -T -f -o docs/reference ./src
rm -rf docs/reference
jcloud-docsgen python -T -o docs/reference .
- name: Commit and push
run: |
@@ -48,7 +49,7 @@ jobs:
git add docs/reference
if git diff --cached --quiet; then
if git diff --chached --quit; then
echo "No changes"
exit 0
fi
+1 -4
View File
@@ -1,19 +1,16 @@
# Changelog
## [0.2.0] - 2026-09-28
## [Unreleased]
### Added
- CLI for `jcloud-docsgen` that can be called without `python3 -m ...`
- CI pipeline to generate API reference on every push or merge on `main`
- Python generator output directory creation
- Protection against accidental overwriting of the output directory
### Changed
- Replaced the installation instructions, which used the old registry, with a reference to the Gitea Package Registry in README
- Made Python function signatures (argument default values) compliant with PEP 8
- Removed implicit Python project directory structure assumptions
### Deprecated
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
[project]
name = "jcloud-docsgen"
version = "0.2.0"
version = "0.1.0"
description = "A tool that generates Markdown documentation for software project source code"
dependencies = ["docstring-parser"]
requires-python = ">=3.10"
+1 -1
View File
@@ -13,7 +13,7 @@
# limitations under the License.
from . import cli
from .core import * # noqa: F403
from .core import *
from .core import __all__ as _core__all__
__all__ = [
+3 -14
View File
@@ -20,7 +20,6 @@ import argparse
import pathlib
import sys
from ..exceptions import DirectoryNotEmptyError
from .languages.python import VALID_SECTIONS as VALID_PYTHON_SECTIONS
__all__ = [
@@ -79,18 +78,8 @@ def create_argument_parser() -> argparse.ArgumentParser:
'-o',
'--output-dir',
type=pathlib.Path,
default='docs/reference',
help='the output directory the documentation will be written to',
)
common_parser.add_argument(
'-f',
'--force',
action='store_true',
help=(
'generate documentation even if the output directory is not '
'empty. Warning: data can be lost when setting this flag.'
),
)
argument_parser = argparse.ArgumentParser()
@@ -149,6 +138,9 @@ def main(argv=None) -> int:
argument_parser = create_argument_parser()
args = argument_parser.parse_args(argv)
if args.output_dir is None:
args.output_dir = args.project_directory / 'docs/reference'
try:
if args.language == _LANG_PYTHON:
from .languages.python import generate
@@ -160,7 +152,6 @@ def main(argv=None) -> int:
exclude_sections=args.sections_exclude,
allow_html=args.allow_html,
allow_tables=args.allow_tables,
force=args.force,
)
except FileNotFoundError as exc:
return error(f'{exc.filename}: no such file or directory')
@@ -168,8 +159,6 @@ def main(argv=None) -> int:
return error(f'{exc.filename}: not a directory')
except IsADirectoryError as exc:
return error(f'{exc.filename}: is a directory')
except DirectoryNotEmptyError as exc:
return error(f'{exc.filename}: directory not empty')
except FileExistsError as exc:
return error(f'{exc.filename}: file already exists')
except PermissionError as exc:
+1 -10
View File
@@ -78,7 +78,6 @@ def _generate(
),
allow_html: bool = False,
allow_tables: bool = False,
force: bool = False,
) -> None:
"""
Generates the documentation for a Python project.
@@ -95,9 +94,6 @@ def _generate(
:type allow_html: bool
:param allow_tables: Indicates whether Markdown tables are allowed.
Warning: Markdown tables are not CommonMark.
:param force: Indicates whether the output directory shall be
overwritten if it is not empty.
:type force: bool
"""
docs_generator = PythonDocumentationGenerator(
@@ -107,7 +103,7 @@ def _generate(
allow_html=allow_html,
allow_tables=allow_tables,
)
docs_generator.generate_documentation(force)
docs_generator.generate_documentation()
def generate(
@@ -118,7 +114,6 @@ def generate(
exclude_sections: list[str] = [],
allow_html: bool = False,
allow_tables: bool = False,
force: bool = False,
) -> None:
"""
Generates the documentation for a Python project.
@@ -138,9 +133,6 @@ def generate(
:type allow_html: bool
:param allow_tables: Indicates whether Markdown tables are allowed.
Warning: Markdown tables are not CommonMark.
:param force: Indicates whether the output directory shall be
overwritten if it is not empty.
:type force: bool
"""
sections = parse_sections_list(include_sections, exclude_sections)
@@ -151,5 +143,4 @@ def generate(
include_sections=sections,
allow_html=allow_html,
allow_tables=allow_tables,
force=force,
)
+1 -1
View File
@@ -13,7 +13,7 @@
# limitations under the License.
from . import namespaces
from ._core import * # noqa: F403
from ._core import *
from ._core import __all__ as _core__all__
__all__ = [
+23 -40
View File
@@ -16,18 +16,13 @@ from __future__ import annotations
import ast
import pathlib
import shutil
from collections.abc import Iterator
from dataclasses import dataclass
from typing import TypeAlias
import docstring_parser
from ...exceptions import (
DirectoryNotEmptyError,
InvalidNamespaceError,
PythonArgumentStructureError,
)
from ...exceptions import InvalidNamespaceError, PythonArgumentStructureError
from ...utils import get_relative_path_segments, non_empty_str
from .arguments import PythonArgumentKind, PythonFunctionArgument
from .definitions import (
@@ -249,8 +244,9 @@ def _markdown_table(
if len(row) != header_len:
if force_row_lengths:
raise ValueError(
f'expected {header_len} value'
f'{"s" if header_len > 1 else ""}, got {len(row)}'
f'expected {header_len} value{
"s" if header_len > 1 else ""
}, got {len(row)}',
)
else:
row = _fit_row_length(header_len, row)
@@ -276,8 +272,9 @@ def _markdown_table(
if len(row) != header_len:
if force_row_lengths:
raise ValueError(
f'expected {header_len} value'
f'{"s" if header_len > 1 else ""}, got {len(row)}'
f'expected {header_len} value{
"s" if header_len > 1 else ""
}, got {len(row)}',
)
else:
row = _fit_row_length(header_len, row)
@@ -599,10 +596,11 @@ class PythonDefinitionDocumentationGenerator:
if _is_docstring(self.definition.doc) or not self.skip_empty_sections:
if self.allow_html:
md += (
f'<details><summary><h{str(self.level + 1)} style="displa'
f'y:inline">Docstring</h{str(self.level + 1)}></summary>'
)
md += f'<details><summary><h{
str(self.level + 1)
} style="display:inline">Docstring</h{
str(self.level + 1)
}></summary>'
else:
md += self.level * '#' + '# Docstring\n\n'
@@ -1106,46 +1104,31 @@ class PythonDocumentationGenerator:
return namespace
def namespace(self) -> PythonPackageNamespace:
def namespace(self) -> list[PythonPackageNamespace]:
"""
Returns the project as a namespace.
:return: The project as a namespace.
:rtype: PythonPackageNamespace:
:rtype: list[PythonPackageNamespace]
"""
return self._namespace(self.project_directory)
src_dir = self.project_directory / 'src'
def generate_documentation(self, force: bool = False) -> None:
return [
self._namespace(dir) for dir in src_dir.iterdir() if dir.is_dir()
]
def generate_documentation(self) -> None:
"""
Generates the full project documentation and writes it to the
documentation directory.
:param force: Indicates whether existing contents of the output
directory shall be overwritten. If ``True``, all
contents of the output directory will be deleted
first.
:type force: bool
:raises DirectoryNotEmptyError: If ``force`` is ``False`` and
the output directory is not
empty.
"""
if self.docs_directory.exists() and list(
self.docs_directory.iterdir()
):
if force:
shutil.rmtree(self.docs_directory)
else:
raise DirectoryNotEmptyError(str(self.docs_directory))
self.docs_directory.mkdir(parents=True, exist_ok=True)
_generate_package_namespace_documentation(
self.namespace(),
PythonPackageNamespace('src', self.namespace()),
self.docs_directory,
self.project_directory,
self.project_directory,
self.project_directory / 'src',
self.project_directory / 'src',
self.include_sections,
self.kwargs,
)
+2 -2
View File
@@ -156,7 +156,7 @@ class PythonPackageNamespace(PythonNamespace):
# For performance reasons, is not called self.sub_namespace_names
# if the name attribute does not match, which can be quickly
# verified
if not (isinstance(value, type(self)) and self.name == value.name):
if self.name != value.name:
return False
return value.sub_namespace_names() == self.sub_namespace_names()
@@ -223,4 +223,4 @@ class PythonModuleNamespace(PythonNamespace):
super().__init__(name)
def __eq__(self, value: PythonModuleNamespace) -> bool:
return isinstance(value, type(self)) and self.name == value.name
return self.name == value.name
+12 -31
View File
@@ -12,8 +12,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import errno
class NamespaceError(ValueError):
"""
@@ -28,11 +26,9 @@ class NamespaceError(ValueError):
if not self.args:
return ''
else:
return (
self.args[0]
+ (': ' if self.namespace_identifier and self.args[0] else '')
+ (self.namespace_identifier if self.args[0] else '')
)
return f'{self.args[0]}{
": " if self.namespace_identifier and self.args[0] else ""
}{self.namespace_identifier if self.args[0] else ""}'
class InvalidNamespaceError(NamespaceError): ...
@@ -57,11 +53,9 @@ class PythonIdentifierError(ValueError):
if not self.args:
return ''
else:
return (
self.args[0]
+ (': ' if self.identifier and self.args[0] else '')
+ (self.identifier if self.args[0] else '')
)
return f'{self.args[0]}{
": " if self.identifier and self.args[0] else ""
}{self.identifier if self.args[0] else ""}'
class InvalidPythonIdentifierError(PythonIdentifierError): ...
@@ -80,11 +74,9 @@ class PythonAnnotationError(ValueError):
if not self.args:
return ''
else:
return (
self.args[0]
+ (': ' if self.annotation and self.args[0] else '')
+ (self.annotation if self.args[0] else '')
)
return f'{self.args[0]}{
": " if self.annotation and self.args[0] else ""
}{self.annotation if self.args[0] else ""}'
class InvalidPythonAnnotationError(PythonAnnotationError): ...
@@ -103,20 +95,9 @@ class PythonDefinitionError(ValueError):
if not self.args:
return ''
else:
return (
self.args[0]
+ (': ' if self.definition and self.args[0] else '')
+ (self.definition if self.args[0] else '')
)
return f'{self.args[0]}{
": " if self.definition and self.args[0] else ""
}{self.definition if self.args[0] else ""}'
class PythonArgumentStructureError(PythonDefinitionError): ...
class DirectoryNotEmptyError(OSError):
"""
Directory is not empty.
"""
def __init__(self, filename: str | None = None) -> None:
super().__init__(errno.ENOTEMPTY, 'Directory not empty', filename)
+7 -5
View File
@@ -20,6 +20,7 @@ import errno
import os
import pathlib
import types
from typing import Union
try:
from warnings import deprecated
@@ -153,10 +154,10 @@ def assert_that_is_instance(
obj: object,
class_or_tuple: type | types.UnionType | tuple[type, ...],
) -> None:
if not isinstance(class_or_tuple, (type, types.UnionType, tuple)):
if not isinstance(class_or_tuple, Union[type, types.UnionType, tuple]):
raise TypeError(
f"class_or_tuple: expected 'type | types.UnionType, tupl"
f"e[type, ...]', 'got {type(class_or_tuple).__name__}'",
f"class_or_tuple: expected 'Union[type, types.UnionType, "
f"tuple[type, ...]]', 'got {type(class_or_tuple).__name__}'",
)
if not isinstance(obj, class_or_tuple):
if isinstance(class_or_tuple, (tuple, types.UnionType)):
@@ -174,8 +175,9 @@ def assert_that_is_instance(
else:
exception_message_expected = "'" + class_or_tuple.__name__ + "'"
raise TypeError(
f'expected {exception_message_expected}'
f", got '{type(obj).__name__}'"
f"expected {exception_message_expected}, got '{
type(obj).__name__
}'",
)
+10 -36
View File
@@ -13,7 +13,6 @@
# limitations under the License.
import pathlib
import tempfile
import pytest
@@ -175,40 +174,15 @@ def test_sections_success():
def test_docs_generation(expectdir):
with expectdir('python_project') as output_dir:
output_dir: pathlib.Path
(output_dir / 'test_dir').mkdir()
input_dir = pathlib.Path(__file__).parent / 'python_project/project'
assert (
cli_main(
[
'python',
'-s',
'params',
'docstring',
'-HTfo',
str(output_dir),
str(input_dir),
]
)
== 0
)
def test_docs_generation_error():
with tempfile.TemporaryDirectory() as output_dir:
output_dir = pathlib.Path(output_dir)
(output_dir / 'test_dir').mkdir()
input_dir = pathlib.Path(__file__).parent / 'python_project/project'
assert (
cli_main(
[
'python',
'-s',
'params',
'docstring',
'-HTo',
str(output_dir),
str(input_dir),
]
)
== 1
cli_main(
[
'python',
'-s',
'params',
'docstring',
'-HTo',
str(output_dir),
str(input_dir),
]
)
@@ -13,7 +13,6 @@
# limitations under the License.
import pathlib
import tempfile
import pytest
@@ -22,7 +21,6 @@ from src.jcloud_docsgen.core.python.namespaces import (
PythonModuleNamespace,
PythonPackageNamespace,
)
from src.jcloud_docsgen.exceptions import DirectoryNotEmptyError
@pytest.mark.parametrize(
@@ -31,7 +29,7 @@ from src.jcloud_docsgen.exceptions import DirectoryNotEmptyError
(
PythonDocumentationGenerator(
pathlib.Path(
'tests/unit/core/python/_core/test_project_dirs/pdir_1/src'
'tests/unit/core/python/_core/test_project_dirs/pdir_1'
),
pathlib.Path(
'tests/unit/core/python/_core/test_project_dirs/pdir_1/docs'
@@ -42,7 +40,7 @@ from src.jcloud_docsgen.exceptions import DirectoryNotEmptyError
(
PythonDocumentationGenerator(
pathlib.Path(
'tests/unit/core/python/_core/test_project_dirs/pdir_2/src'
'tests/unit/core/python/_core/test_project_dirs/pdir_2'
),
pathlib.Path(
'tests/unit/core/python/_core/test_project_dirs/pdir_2/docs'
@@ -58,7 +56,7 @@ from src.jcloud_docsgen.exceptions import DirectoryNotEmptyError
(
PythonDocumentationGenerator(
pathlib.Path(
'tests/unit/core/python/_core/test_project_dirs/pdir_3/src'
'tests/unit/core/python/_core/test_project_dirs/pdir_3'
),
pathlib.Path(
'tests/unit/core/python/_core/test_project_dirs/pdir_3/docs'
@@ -85,9 +83,7 @@ def test_PythonDocumentationGenerator_collect_modules(
python_documentation_generator: PythonDocumentationGenerator,
expected,
):
assert (
python_documentation_generator.namespace().sub_namespaces
) == expected
assert python_documentation_generator.namespace() == expected
@pytest.mark.parametrize(
@@ -122,75 +118,4 @@ def test_generate_documentation(dir, expectdir):
pathlib.Path(str(d)),
allow_tables=True,
allow_html=False,
).generate_documentation(True) # simplify the tests
def test_creates_directory():
with tempfile.TemporaryDirectory() as output_dir:
output_dir = pathlib.Path(output_dir)
with tempfile.TemporaryDirectory() as project_dir:
project_dir = pathlib.Path(project_dir)
# The directory of the package
package_dir = project_dir / 'pkg'
assert not package_dir.exists()
package_dir.mkdir(parents=True, exist_ok=True)
# The actual output directory directory
docs_dir = output_dir / 'docs' / 'reference'
# It should not exist
assert not docs_dir.exists()
docs_generator = PythonDocumentationGenerator(
project_dir, docs_dir
)
docs_generator.generate_documentation()
assert docs_dir.exists()
class TestForce:
def _prepare(self, directory):
directory = pathlib.Path(directory)
src_directory = directory / 'src'
src_directory.mkdir()
docs_directory = directory / 'docs'
docs_directory.mkdir()
return directory, src_directory, docs_directory
def test_overwrites_output_directory(self):
with tempfile.TemporaryDirectory() as directory:
directory, src_directory, docs_directory = self._prepare(directory)
test_dir = docs_directory / 'test_dir'
test_dir.mkdir()
documentation_generator = PythonDocumentationGenerator(
src_directory, docs_directory
)
documentation_generator.generate_documentation(True)
assert not test_dir.exists()
def test_error(self):
with tempfile.TemporaryDirectory() as directory:
directory, src_directory, docs_directory = self._prepare(directory)
(docs_directory / 'test_dir').mkdir()
documentation_generator = PythonDocumentationGenerator(
src_directory, docs_directory
)
with pytest.raises(DirectoryNotEmptyError) as exc_info:
documentation_generator.generate_documentation()
assert exc_info.value.filename == str(docs_directory)
def test_nothing_force(self):
with tempfile.TemporaryDirectory() as directory:
directory, src_directory, docs_directory = self._prepare(directory)
documentation_generator = PythonDocumentationGenerator(
src_directory, docs_directory
)
documentation_generator.generate_documentation(True)
def test_nothing_not_force(self):
with tempfile.TemporaryDirectory() as directory:
directory, src_directory, docs_directory = self._prepare(directory)
documentation_generator = PythonDocumentationGenerator(
src_directory, docs_directory
)
documentation_generator.generate_documentation(True)
).generate_documentation() # simplify the tests
@@ -125,12 +125,12 @@ Returns all users.
##### Method signature
```python
list_all(self) -> list[User]
list_all(self) -> List[User]
```
##### Returns
Return type: `list[User]`
Return type: `List[User]`
##### Parameters
@@ -1,4 +1,5 @@
from dataclasses import dataclass
from typing import Optional
@dataclass
@@ -10,4 +11,4 @@ class User:
id: str
name: str
email: str
age: int | None = None
age: Optional[int] = None
@@ -1,6 +1,6 @@
from .exceptions import InvalidEmailError, UserNotFoundError
from .models import User
from .storage import InMemoryUserStorage
from .exceptions import UserNotFoundError, InvalidEmailError
from .utils import is_valid_email, normalize_name
@@ -1,3 +1,4 @@
from typing import Dict, List
from .models import User
@@ -8,7 +9,7 @@ class InMemoryUserStorage:
"""
def __init__(self):
self._users: dict[str, User] = {}
self._users: Dict[str, User] = {}
def add(self, user: User) -> None:
"""Adds a user."""
@@ -22,6 +23,6 @@ class InMemoryUserStorage:
"""Deletes a user."""
del self._users[user_id]
def list_all(self) -> list[User]:
def list_all(self) -> List[User]:
"""Returns all users."""
return list(self._users.values())
@@ -1424,7 +1424,7 @@ A dog.""",
),
],
)
def test_PythonModuleDocumentationGenerator_generate_documentation(
def test_PythonModuleDocumentationGenerator_exceptions(
module_documentation_generator,
expected,
):
@@ -292,25 +292,22 @@ def test_PythonDocumentationGenerator_collect_modules(
None,
[
(
"pytest.mark.parametrize('python_documentatio"
"n_generator,expected', [(PythonDocumentation"
"Generator(pathlib.Path('tests/unit/core/pyth"
"on/_core/test_project_dirs/pdir_1'), pathlib"
".Path('tests/unit/core/python/_core/test_pro"
"ject_dirs/pdir_1/docs')), []), (PythonDocume"
"ntationGenerator(pathlib.Path('tests/unit/co"
"re/python/_core/test_project_dirs/pdir_2'), "
"pathlib.Path('tests/unit/core/python/_core/t"
"est_project_dirs/pdir_2/docs')), [PythonPack"
"ageNamespace('pkg', [PythonModuleNamespace('"
"module')])]), (PythonDocumentationGenerator("
"pathlib.Path('tests/unit/core/python/_core/t"
"est_project_dirs/pdir_3'), pathlib.Path('tes"
'ts/unit/core/python/_core/test_project_dirs/'
"pdir_3/docs')), [PythonPackageNamespace('pkg"
"', [PythonModuleNamespace('module'), Python"
"PackageNamespace('pkg', [PythonModuleNames"
"pace('module2')])])])])"
"""pytest.mark.parametrize('python_documentation_gener"""
"""ator,expected', [(PythonDocumentationGenerator(path"""
"""lib.Path('tests/unit/core/python/_core/test_project"""
"""_dirs/pdir_1'), pathlib.Path('tests/unit/core/pytho"""
"""n/_core/test_project_dirs/pdir_1/docs')), []), (Pyt"""
"""honDocumentationGenerator(pathlib.Path('tests/unit/"""
"""core/python/_core/test_project_dirs/pdir_2'), pathl"""
"""ib.Path('tests/unit/core/python/_core/test_project_"""
"""dirs/pdir_2/docs')), [PythonPackageNamespace('pkg',"""
""" [PythonModuleNamespace('module')])]), (PythonDocum"""
"""entationGenerator(pathlib.Path('tests/unit/core/pyt"""
"""hon/_core/test_project_dirs/pdir_3'), pathlib.Path("""
"""'tests/unit/core/python/_core/test_project_dirs/pdi"""
"""r_3/docs')), [PythonPackageNamespace('pkg', [Python"""
"""ModuleNamespace(\'module'), PythonPackageNamespace("""
"""\'pkg\', [PythonModuleNamespace(\'module2')])])])])"""
)
],
),
@@ -2,14 +2,12 @@
A test module.
"""
def fn(a: int, b: int = 42, c=42, d=42):
"""
:param c:
:type c: int
"""
def add(a: int | float, b: int | float) -> int | float:
"""
Adds two numbers.
@@ -727,7 +727,9 @@ def argument_list(argument_list: str) -> ast.arguments:
],
),
(
argument_list("a, b='a', /, *args, c=None, d=True, **kwargs"),
argument_list(
"a, b='a', /, *args, c=None, d=True, **kwargs"
),
[
PythonFunctionArgument(
'a',
@@ -14,11 +14,7 @@
import pytest
from src.jcloud_docsgen.core.python.namespaces import (
PythonModuleNamespace,
PythonNamespace,
PythonPackageNamespace,
)
from src.jcloud_docsgen.core.python.namespaces import PythonModuleNamespace
class StrSubclass(str): ...
@@ -46,10 +42,6 @@ def test_PythonModuleNamespace_name_attribute(namespace, expected_name):
(PythonModuleNamespace('a'), PythonModuleNamespace('b'), False),
(PythonModuleNamespace('0'), PythonModuleNamespace('-0'), False),
(PythonModuleNamespace('1'), PythonModuleNamespace('a'), False),
(PythonModuleNamespace('a'), 'a', False),
(PythonModuleNamespace('a'), 'b', False),
(PythonModuleNamespace('a'), PythonPackageNamespace('a', []), False),
(PythonModuleNamespace('a'), PythonNamespace('a'), False),
],
)
def test_PythonModuleNamespace___eq__(namespace1, namespace2, expected):
@@ -16,7 +16,6 @@ import pytest
from src.jcloud_docsgen.core.python.namespaces import (
PythonModuleNamespace,
PythonNamespace,
PythonPackageNamespace,
)
from src.jcloud_docsgen.exceptions import (
@@ -257,26 +256,6 @@ def test_PythonPackageNamespace_sub_namespace_names(namespace, expected):
PythonPackageNamespace('b', []),
False,
),
(
PythonPackageNamespace('a', []),
'a',
False,
),
(
PythonPackageNamespace('a', []),
'b',
False,
),
(
PythonPackageNamespace('a', []),
PythonModuleNamespace('a'),
False,
),
(
PythonPackageNamespace('a', []),
PythonNamespace('a'),
False,
),
],
)
def test_PythonPackageNamespace___eq__(namespace1, namespace2, expected):