generated from jCloud/repository-template
Feat(python-generator): protection against accidental overwriting of the output directory #40
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user