diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7e3b690..7623886 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- CLI for `jcloud-docsgen` that can be called without `python3 -m ...` + ### Deprecated - utility class for existing directories diff --git a/pyproject.toml b/pyproject.toml index 8452ac3..1dbff9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,9 @@ dependencies = ["docstring-parser"] requires-python = ">=3.10" license = "Apache-2.0" +[project.scripts] +jcloud-docsgen = "jcloud_docsgen.cli:main" + [tool.poetry] packages = [ { include = "jcloud_docsgen", from = "src"} diff --git a/src/jcloud_docsgen/__main__.py b/src/jcloud_docsgen/__main__.py new file mode 100644 index 0000000..57403ce --- /dev/null +++ b/src/jcloud_docsgen/__main__.py @@ -0,0 +1,19 @@ +# Copyright 2026 jCloud + +# 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. + +import sys +from .cli.cli import main + +if __name__ == '__main__': + sys.exit(main()) diff --git a/src/jcloud_docsgen/cli.py b/src/jcloud_docsgen/cli.py deleted file mode 100644 index b153c0a..0000000 --- a/src/jcloud_docsgen/cli.py +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright 2026 jCloud - -# 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. - -''' -The command line interface -''' - -__all__ = [ - 'main' -] - -import argparse -import pathlib - -def existing_directory(path: str) -> pathlib.Path: - _path = path - path = pathlib.Path(_path) - if not path.exists(): - raise argparse.ArgumentTypeError(f'{_path}: no such file or directory') - if not path.is_dir(): - raise argparse.ArgumentTypeError(f'{_path}: not a directory') - -def main() -> int: - argument_parser = argparse.ArgumentParser() - argument_parser.add_argument('project_directory', type = existing_directory, default = pathlib.Path('.'), help = 'The directory of the project') - args = argument_parser.parse_args() - return 0 diff --git a/src/jcloud_docsgen/cli/__init__.py b/src/jcloud_docsgen/cli/__init__.py new file mode 100644 index 0000000..f9d1488 --- /dev/null +++ b/src/jcloud_docsgen/cli/__init__.py @@ -0,0 +1,23 @@ +# Copyright 2026 jCloud + +# 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 . import cli +from . import languages +from .cli import main + +__all__ = [ + 'cli', + 'languages', + 'main' +] diff --git a/src/jcloud_docsgen/cli/cli.py b/src/jcloud_docsgen/cli/cli.py new file mode 100644 index 0000000..f079be0 --- /dev/null +++ b/src/jcloud_docsgen/cli/cli.py @@ -0,0 +1,160 @@ +# Copyright 2026 jCloud + +# 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. + +''' +The command line interface +''' + +import argparse +import pathlib +import sys +from .languages.python import VALID_SECTIONS as VALID_PYTHON_SECTIONS + +__all__ = [ + 'existing_directory', + 'error', + 'create_argument_parser', + 'main' +] + +_LANG_PYTHON = 'python' +_PROGRAM_NAME = 'jcloud-docsgen' + +def existing_directory(path: str) -> pathlib.Path: + _path = path + path = pathlib.Path(_path) + if not path.exists(): + raise argparse.ArgumentTypeError(f'{_path}: no such file or directory') + if not path.is_dir(): + raise argparse.ArgumentTypeError(f'{_path}: not a directory') + return path + +def error(message: str, exit_code: int = 1): + ''' + Shows an error message and returns the exit code. + + :param message: The error message to show. + :type message: str + :param exit_code: The exit code. + :type exit_code: int + ''' + + print(f'{_PROGRAM_NAME}: error: {message}', file = sys.stderr) + return exit_code + +def create_argument_parser() -> argparse.ArgumentParser: + ''' + Prepares a parser to parse the command-line arguments using + argparse. + + :return: The parser. + :rtype: argparse.ArgumentParser + ''' + + common_parser = argparse.ArgumentParser(add_help = False) + common_parser.add_argument( + 'project_directory', + type = existing_directory, + nargs = '?', + default = pathlib.Path('.'), + help = 'The directory of the project' + ) + common_parser.add_argument( + '-o', + '--output-dir', + type = pathlib.Path, + help = 'the output directory the documentation will be written to' + ) + + argument_parser = argparse.ArgumentParser() + + subparsers = argument_parser.add_subparsers(dest = 'language', required = True) + + python_parser = subparsers.add_parser( + _LANG_PYTHON, + parents = [common_parser], + help = 'generate documentation for Python project' + ) + python_parser.add_argument( + '-s', + '--sections', + nargs = '+', + choices = VALID_PYTHON_SECTIONS, + help = 'the sections to be included in the documentation' + ) + python_parser.add_argument( + '-S', + '--sections-exclude', + nargs = '+', + default = [], + choices = VALID_PYTHON_SECTIONS, + help = 'the sections to be excluded in the documentation. If a section shall be included AND excluded, it is excluded.' + ) + python_parser.add_argument( + '-H', + '--allow-html', + action = 'store_true', + help = 'indicates whether HTML is allowed in the documentation. By default, HTML is disabled.' + ) + python_parser.add_argument( + '-T', + '--allow-tables', + action = 'store_true', + help = 'indicates whether Markdown tables are allowed in the documentation. By default, Markdown tables are disabled. Warning: Markdown tables are not CommonMark.' + ) + + return argument_parser + +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 + generate( + args.project_directory, + args.output_dir, + include_sections = args.sections, + exclude_sections = args.sections_exclude, + allow_html = args.allow_html, + allow_tables = args.allow_tables + ) + except FileNotFoundError as exc: + return error(f'{exc.filename}: no such file or directory') + except NotADirectoryError as exc: + return error(f'{exc.filename}: not a directory') + except IsADirectoryError as exc: + return error(f'{exc.filename}: is a directory') + except FileExistsError as exc: + return error(f'{exc.filename}: file already exists') + except PermissionError as exc: + return error(f'{exc.filename}: permission denied') + except OSError as exc: + return error(f'{exc.filename}: {exc.args[1]}') + except UnicodeError: + return error('invalid text encoding') + except SyntaxError: + # TODO: add a custom exception class to clearify that a project + # parsing syntax error is meant. + return error(f'syntax error in project source code') + except KeyboardInterrupt: + return 130 + except BrokenPipeError: + return 141 + + return 0 diff --git a/src/jcloud_docsgen/cli/languages/__init__.py b/src/jcloud_docsgen/cli/languages/__init__.py new file mode 100644 index 0000000..f774970 --- /dev/null +++ b/src/jcloud_docsgen/cli/languages/__init__.py @@ -0,0 +1,19 @@ +# Copyright 2026 jCloud + +# 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 . import python + +__all__ = [ + 'python' +] diff --git a/src/jcloud_docsgen/cli/languages/python.py b/src/jcloud_docsgen/cli/languages/python.py new file mode 100644 index 0000000..b905416 --- /dev/null +++ b/src/jcloud_docsgen/cli/languages/python.py @@ -0,0 +1,133 @@ +# Copyright 2026 jCloud + +# 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. + +import pathlib +from ...core.python import PythonDefinitionDocumentationIncludeSections, PythonDocumentationGenerator +from typing import Optional + +__all__ = [ + 'VALID_SECTIONS', + 'parse_sections_list', + 'generate' +] + +VALID_SECTIONS = list(PythonDefinitionDocumentationIncludeSections()._args.keys()) + +def parse_sections_list(include_sections: Optional[list[str]] = None, exclude_sections: list[str] = []) -> PythonDefinitionDocumentationIncludeSections: + ''' + Returns a Python definition documentation sections list from the + sections list to be included and the sections list to be excluded. + These lists both consist of the sections as strings which contain + the section name. + + :param include_sections: The list of sections to be included. + :type include_sections: Optional[list[str]] + :param exclude_sections: The list of sections to be excluded. + :type exclude_sections: list[str] + + :return: The Python definition documentation sections object. + :rtype: PythonDefinitionDocumentationIncludeSections + ''' + + if include_sections is None: + include_sections = VALID_SECTIONS[:] + + for section in exclude_sections: + while section in include_sections: + del include_sections[include_sections.index(section)] + + return PythonDefinitionDocumentationIncludeSections( + signature = 'signature' in include_sections, + returns = 'returns' in include_sections, + params = 'params' in include_sections, + exceptions = 'exceptions' in include_sections, + examples = 'examples' in include_sections, + docstring = 'docstring' in include_sections, + bases = 'bases' in include_sections, + decorators = 'decorators' in include_sections, + class_body = 'class_body' in include_sections + ) + +def _generate( + project_directory: pathlib.Path, + output_directory: pathlib.Path, + *, + include_sections: PythonDefinitionDocumentationIncludeSections = PythonDefinitionDocumentationIncludeSections(), + allow_html: bool = False, + allow_tables: bool = False +) -> None: + ''' + Generates the documentation for a Python project. + + :param project_directory: The project directory. + :type project_directory: pathlib.Path + :param output_directory: The output directory. + :type output_directory: pathlib.Path + :param include_sections: The sections to be included in the + documentation. + :type include_sections: PythonDefinitionDocumentationIncludeSections + :param allow_html: Indicates whether HTML is allowed to appear in + the generated documentation. + :type allow_html: bool + :param allow_tables: Indicates whether Markdown tables are allowed. + Warning: Markdown tables are not CommonMark. + ''' + + docs_generator = PythonDocumentationGenerator( + project_directory, + output_directory, + include_sections = include_sections, + allow_html = allow_html, + allow_tables = allow_tables + ) + docs_generator.generate_documentation() + +def generate( + project_directory: pathlib.Path, + output_directory: pathlib.Path, + *, + include_sections: Optional[list[str]] = None, + exclude_sections: list[str] = [], + allow_html: bool = False, + allow_tables: bool = False +) -> None: + ''' + Generates the documentation for a Python project. + + :param project_directory: The project directory. + :type project_directory: pathlib.Path + :param output_directory: The output directory. + :type output_directory: pathlib.Path + :param include_sections: The sections to be included in the + documentation. + :type include_sections: Optional[list[str]] + :param exclude_sections: The sections to be excluded in the + documentation. + :type exclude_sections: list[str] + :param allow_html: Indicates whether HTML is allowed to appear in + the generated documentation. + :type allow_html: bool + :param allow_tables: Indicates whether Markdown tables are allowed. + Warning: Markdown tables are not CommonMark. + ''' + + sections = parse_sections_list(include_sections, exclude_sections) + + _generate( + project_directory, + output_directory, + include_sections = sections, + allow_html = allow_html, + allow_tables = allow_tables + ) diff --git a/tests/integration/cli/test_cli.py b/tests/integration/cli/test_cli.py new file mode 100644 index 0000000..1e2d1f1 --- /dev/null +++ b/tests/integration/cli/test_cli.py @@ -0,0 +1,34 @@ +# Copyright 2026 jCloud + +# 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. + +import subprocess +import sys + +def test_cli(): + result = subprocess.run( + [ + sys.executable, + '-m', + 'jcloud_docsgen', + '--help' + ], + capture_output = True, + text = True + ) + stdout = result.stdout.lower() + assert \ + 'usage:' in stdout and \ + 'positional arguments' in stdout and \ + 'options' in stdout and \ + '--help' in stdout diff --git a/tests/unit/cli/python_project/expected/pkg/module.md b/tests/unit/cli/python_project/expected/pkg/module.md new file mode 100644 index 0000000..7b92bdc --- /dev/null +++ b/tests/unit/cli/python_project/expected/pkg/module.md @@ -0,0 +1,20 @@ +# Module `pkg.module` + +## Function `f` + +Does something. + + +### Parameters + +| Parameter | Type | Kind | Default value | Description | +| --- | --- | --- | --- | --- | +| `a` | `int` | normal | `42` | A parameter. | + +

Docstring

Does something. + +:param a: A parameter. +:type a: int + +:return: A string. +:rtype: str
\ No newline at end of file diff --git a/tests/unit/cli/python_project/project/src/pkg/module.py b/tests/unit/cli/python_project/project/src/pkg/module.py new file mode 100644 index 0000000..fd01ebd --- /dev/null +++ b/tests/unit/cli/python_project/project/src/pkg/module.py @@ -0,0 +1,11 @@ +def f(a: int = 42) -> str: + ''' + Does something. + + :param a: A parameter. + :type a: int + + :return: A string. + :rtype: str + ''' + ... diff --git a/tests/unit/cli/test_parser.py b/tests/unit/cli/test_parser.py new file mode 100644 index 0000000..7eb5a19 --- /dev/null +++ b/tests/unit/cli/test_parser.py @@ -0,0 +1,34 @@ +# Copyright 2026 jCloud + +# 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. + +import pytest +from jcloud_docsgen.cli.cli import create_argument_parser + +@pytest.mark.parametrize('lang', [ + 'javascript', + 'Python', + 'PYTHON' +]) +def test_language_error(lang): + parser = create_argument_parser() + + with pytest.raises(SystemExit): + parser.parse_args([lang]) + +@pytest.mark.parametrize('lang', [ + 'python' +]) +def test_language_success(lang): + parser = create_argument_parser() + parser.parse_args([lang]) diff --git a/tests/unit/cli/test_python.py b/tests/unit/cli/test_python.py new file mode 100644 index 0000000..a797249 --- /dev/null +++ b/tests/unit/cli/test_python.py @@ -0,0 +1,166 @@ +# Copyright 2026 jCloud + +# 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. + +import pathlib +import pytest +from jcloud_docsgen.cli.cli import create_argument_parser, main as cli_main +from jcloud_docsgen.cli.languages.python import parse_sections_list +from jcloud_docsgen.core.python import PythonDefinitionDocumentationIncludeSections + +@pytest.mark.parametrize('include,exclude,expected', [ + ( + [], + [], + PythonDefinitionDocumentationIncludeSections( + signature = False, + returns = False, + params = False, + exceptions = False, + examples = False, + docstring = False, + bases = False, + decorators = False, + class_body = False + ) + ), + ( + [], + ['signature'], + PythonDefinitionDocumentationIncludeSections( + signature = False, + returns = False, + params = False, + exceptions = False, + examples = False, + docstring = False, + bases = False, + decorators = False, + class_body = False + ) + ), + ( + ['signature'], + ['signature'], + PythonDefinitionDocumentationIncludeSections( + signature = False, + returns = False, + params = False, + exceptions = False, + examples = False, + docstring = False, + bases = False, + decorators = False, + class_body = False + ) + ), + ( + ['signature'], + [], + PythonDefinitionDocumentationIncludeSections( + signature = True, + returns = False, + params = False, + exceptions = False, + examples = False, + docstring = False, + bases = False, + decorators = False, + class_body = False + ) + ), + ( + None, + [], + PythonDefinitionDocumentationIncludeSections() + ), + ( + ['signature', 'exceptions', 'bases', 'class_body'], + ['signature', 'bases'], + PythonDefinitionDocumentationIncludeSections( + signature = False, + returns = False, + params = False, + exceptions = True, + examples = False, + docstring = False, + bases = False, + decorators = False, + class_body = True + ) + ), + ( + None, + ['params', 'docstring'], + PythonDefinitionDocumentationIncludeSections( + signature = True, + returns = True, + params = False, + exceptions = True, + examples = True, + docstring = False, + bases = True, + decorators = True, + class_body = True + ) + ) +]) +def test_parse_sections_list(include, exclude, expected): + assert parse_sections_list(include, exclude) == expected + +def test_sections_error(): + parser = create_argument_parser() + + with pytest.raises(SystemExit): + parser.parse_args([ + 'python', + '-s', + 'param' # correct: `params` + ]) + + with pytest.raises(SystemExit): + parser.parse_args([ + 'python', + '-s', + 'params', + 'docstrng' # correct: `docstring` + ]) + +def test_sections_success(): + parser = create_argument_parser() + + parser.parse_args([ + 'python', + '-s', + 'params' + ]) + parser.parse_args([ + 'python', + '-s', + 'params', + 'docstring' + ]) + +def test_docs_generation(expectdir): + with expectdir('python_project') as output_dir: + output_dir: pathlib.Path + input_dir = pathlib.Path(__file__).parent / 'python_project/project' + cli_main([ + 'python', + '-s', + 'params', + 'docstring', + '-HTo', + str(output_dir), + str(input_dir) + ])