Merge pull request 'Feature(cli): add base command-line interface' (#16) from feature/cli into main

Reviewed-on: #16
This commit was merged in pull request #16.
This commit is contained in:
jakob.scheid authored and Gitea committed 2026-09-13 20:11:00 +02:00
commit 8930efc7e6
13 files changed
+626 -38

No files matched your search

+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- CLI for `jcloud-docsgen` that can be called without `python3 -m ...`
### Deprecated
- utility class for existing directories
+3
View File
@@ -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"}
+19
View File
@@ -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())
-38
View File
@@ -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
+23
View File
@@ -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'
]
+160
View File
@@ -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
@@ -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'
]
+133
View File
@@ -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
)
+34
View File
@@ -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
@@ -0,0 +1,20 @@
# Module `pkg.module`
## Function `f`
Does something.
### Parameters
| Parameter | Type | Kind | Default value | Description |
| --- | --- | --- | --- | --- |
| `a` | `int` | normal | `42` | A parameter. |
<details><summary><h3 style="display:inline">Docstring</h3></summary>Does something.
:param a: A parameter.
:type a: int
:return: A string.
:rtype: str</details>
@@ -0,0 +1,11 @@
def f(a: int = 42) -> str:
'''
Does something.
:param a: A parameter.
:type a: int
:return: A string.
:rtype: str
'''
...
+34
View File
@@ -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])
+166
View File
@@ -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)
])