Feat(python-generator): protection against accidental overwriting of the output directory #40

Merged
jakob.scheid merged 6 commits from feature/protect-against-accidental-overwriting into main 2026-09-27 21:56:40 +02:00
7 changed files with 147 additions and 14 deletions

No files matched your search

+1
View File
@@ -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
+13
View File
@@ -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:
+10 -1
View File
@@ -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,
)
+25 -2
View File
@@ -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(
+11
View File
@@ -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)
+37 -10
View File
@@ -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
)
@@ -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)