diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 731d028..7384717 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,7 @@ - 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 diff --git a/src/jcloud_docsgen/cli/cli.py b/src/jcloud_docsgen/cli/cli.py index 1fae4e0..14c7e10 100644 --- a/src/jcloud_docsgen/cli/cli.py +++ b/src/jcloud_docsgen/cli/cli.py @@ -20,6 +20,7 @@ import argparse import pathlib import sys +from ..exceptions import DirectoryNotEmptyError from .languages.python import VALID_SECTIONS as VALID_PYTHON_SECTIONS __all__ = [ @@ -81,6 +82,15 @@ def create_argument_parser() -> argparse.ArgumentParser: 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() @@ -150,6 +160,7 @@ 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') @@ -157,6 +168,8 @@ 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: diff --git a/src/jcloud_docsgen/cli/languages/python.py b/src/jcloud_docsgen/cli/languages/python.py index ae74fe9..c012e40 100644 --- a/src/jcloud_docsgen/cli/languages/python.py +++ b/src/jcloud_docsgen/cli/languages/python.py @@ -78,6 +78,7 @@ def _generate( ), allow_html: bool = False, allow_tables: bool = False, + force: bool = False, ) -> None: """ Generates the documentation for a Python project. @@ -94,6 +95,9 @@ 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( @@ -103,7 +107,7 @@ def _generate( allow_html=allow_html, allow_tables=allow_tables, ) - docs_generator.generate_documentation() + docs_generator.generate_documentation(force) def generate( @@ -114,6 +118,7 @@ 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. @@ -133,6 +138,9 @@ 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) @@ -143,4 +151,5 @@ def generate( include_sections=sections, allow_html=allow_html, allow_tables=allow_tables, + force=force, ) diff --git a/src/jcloud_docsgen/core/python/_core.py b/src/jcloud_docsgen/core/python/_core.py index 73b6b26..af783b3 100644 --- a/src/jcloud_docsgen/core/python/_core.py +++ b/src/jcloud_docsgen/core/python/_core.py @@ -22,7 +22,13 @@ from typing import TypeAlias import docstring_parser -from ...exceptions import InvalidNamespaceError, PythonArgumentStructureError +import shutil + +from ...exceptions import ( + DirectoryNotEmptyError, + InvalidNamespaceError, + PythonArgumentStructureError, +) from ...utils import get_relative_path_segments, non_empty_str from .arguments import PythonArgumentKind, PythonFunctionArgument from .definitions import ( @@ -1114,12 +1120,29 @@ class PythonDocumentationGenerator: return self._namespace(self.project_directory) - def generate_documentation(self) -> None: + def generate_documentation(self, force: bool = False) -> 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( diff --git a/src/jcloud_docsgen/exceptions.py b/src/jcloud_docsgen/exceptions.py index dc81f2e..d687917 100644 --- a/src/jcloud_docsgen/exceptions.py +++ b/src/jcloud_docsgen/exceptions.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import errno + class NamespaceError(ValueError): """ @@ -101,3 +103,12 @@ class PythonDefinitionError(ValueError): 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) diff --git a/tests/unit/cli/test_python.py b/tests/unit/cli/test_python.py index 70891de..d15e037 100644 --- a/tests/unit/cli/test_python.py +++ b/tests/unit/cli/test_python.py @@ -16,6 +16,8 @@ import pathlib import pytest +import tempfile + from jcloud_docsgen.cli.cli import create_argument_parser from jcloud_docsgen.cli.cli import main as cli_main from jcloud_docsgen.cli.languages.python import parse_sections_list @@ -174,15 +176,40 @@ 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' - cli_main( - [ - 'python', - '-s', - 'params', - 'docstring', - '-HTo', - str(output_dir), - str(input_dir), - ] + 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 ) diff --git a/tests/unit/core/python/_core/test_PythonDocumentationGenerator.py b/tests/unit/core/python/_core/test_PythonDocumentationGenerator.py index 36f095f..fb90798 100644 --- a/tests/unit/core/python/_core/test_PythonDocumentationGenerator.py +++ b/tests/unit/core/python/_core/test_PythonDocumentationGenerator.py @@ -18,6 +18,7 @@ import pytest import tempfile +from src.jcloud_docsgen.exceptions import DirectoryNotEmptyError from src.jcloud_docsgen.core.python import PythonDocumentationGenerator from src.jcloud_docsgen.core.python.namespaces import ( PythonModuleNamespace, @@ -120,7 +121,7 @@ def test_generate_documentation(dir, expectdir): pathlib.Path(str(d)), allow_tables=True, allow_html=False, - ).generate_documentation() # simplify the tests + ).generate_documentation(True) # simplify the tests def test_creates_directory(): @@ -144,3 +145,51 @@ def test_creates_directory(): ) 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)