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 ...`
|
- CLI for `jcloud-docsgen` that can be called without `python3 -m ...`
|
||||||
- CI pipeline to generate API reference on every push or merge on `main`
|
- CI pipeline to generate API reference on every push or merge on `main`
|
||||||
- Python generator output directory creation
|
- Python generator output directory creation
|
||||||
|
- Protection against accidental overwriting of the output directory
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import argparse
|
|||||||
import pathlib
|
import pathlib
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from ..exceptions import DirectoryNotEmptyError
|
||||||
from .languages.python import VALID_SECTIONS as VALID_PYTHON_SECTIONS
|
from .languages.python import VALID_SECTIONS as VALID_PYTHON_SECTIONS
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -81,6 +82,15 @@ def create_argument_parser() -> argparse.ArgumentParser:
|
|||||||
default='docs/reference',
|
default='docs/reference',
|
||||||
help='the output directory the documentation will be written to',
|
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()
|
argument_parser = argparse.ArgumentParser()
|
||||||
|
|
||||||
@@ -150,6 +160,7 @@ def main(argv=None) -> int:
|
|||||||
exclude_sections=args.sections_exclude,
|
exclude_sections=args.sections_exclude,
|
||||||
allow_html=args.allow_html,
|
allow_html=args.allow_html,
|
||||||
allow_tables=args.allow_tables,
|
allow_tables=args.allow_tables,
|
||||||
|
force=args.force,
|
||||||
)
|
)
|
||||||
except FileNotFoundError as exc:
|
except FileNotFoundError as exc:
|
||||||
return error(f'{exc.filename}: no such file or directory')
|
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')
|
return error(f'{exc.filename}: not a directory')
|
||||||
except IsADirectoryError as exc:
|
except IsADirectoryError as exc:
|
||||||
return error(f'{exc.filename}: is a directory')
|
return error(f'{exc.filename}: is a directory')
|
||||||
|
except DirectoryNotEmptyError as exc:
|
||||||
|
return error(f'{exc.filename}: directory not empty')
|
||||||
except FileExistsError as exc:
|
except FileExistsError as exc:
|
||||||
return error(f'{exc.filename}: file already exists')
|
return error(f'{exc.filename}: file already exists')
|
||||||
except PermissionError as exc:
|
except PermissionError as exc:
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ def _generate(
|
|||||||
),
|
),
|
||||||
allow_html: bool = False,
|
allow_html: bool = False,
|
||||||
allow_tables: bool = False,
|
allow_tables: bool = False,
|
||||||
|
force: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Generates the documentation for a Python project.
|
Generates the documentation for a Python project.
|
||||||
@@ -94,6 +95,9 @@ def _generate(
|
|||||||
:type allow_html: bool
|
:type allow_html: bool
|
||||||
:param allow_tables: Indicates whether Markdown tables are allowed.
|
:param allow_tables: Indicates whether Markdown tables are allowed.
|
||||||
Warning: Markdown tables are not CommonMark.
|
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(
|
docs_generator = PythonDocumentationGenerator(
|
||||||
@@ -103,7 +107,7 @@ def _generate(
|
|||||||
allow_html=allow_html,
|
allow_html=allow_html,
|
||||||
allow_tables=allow_tables,
|
allow_tables=allow_tables,
|
||||||
)
|
)
|
||||||
docs_generator.generate_documentation()
|
docs_generator.generate_documentation(force)
|
||||||
|
|
||||||
|
|
||||||
def generate(
|
def generate(
|
||||||
@@ -114,6 +118,7 @@ def generate(
|
|||||||
exclude_sections: list[str] = [],
|
exclude_sections: list[str] = [],
|
||||||
allow_html: bool = False,
|
allow_html: bool = False,
|
||||||
allow_tables: bool = False,
|
allow_tables: bool = False,
|
||||||
|
force: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Generates the documentation for a Python project.
|
Generates the documentation for a Python project.
|
||||||
@@ -133,6 +138,9 @@ def generate(
|
|||||||
:type allow_html: bool
|
:type allow_html: bool
|
||||||
:param allow_tables: Indicates whether Markdown tables are allowed.
|
:param allow_tables: Indicates whether Markdown tables are allowed.
|
||||||
Warning: Markdown tables are not CommonMark.
|
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)
|
sections = parse_sections_list(include_sections, exclude_sections)
|
||||||
@@ -143,4 +151,5 @@ def generate(
|
|||||||
include_sections=sections,
|
include_sections=sections,
|
||||||
allow_html=allow_html,
|
allow_html=allow_html,
|
||||||
allow_tables=allow_tables,
|
allow_tables=allow_tables,
|
||||||
|
force=force,
|
||||||
)
|
)
|
||||||
@@ -22,7 +22,13 @@ from typing import TypeAlias
|
|||||||
|
|
||||||
import docstring_parser
|
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 ...utils import get_relative_path_segments, non_empty_str
|
||||||
from .arguments import PythonArgumentKind, PythonFunctionArgument
|
from .arguments import PythonArgumentKind, PythonFunctionArgument
|
||||||
from .definitions import (
|
from .definitions import (
|
||||||
@@ -1114,12 +1120,29 @@ class PythonDocumentationGenerator:
|
|||||||
|
|
||||||
return self._namespace(self.project_directory)
|
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
|
Generates the full project documentation and writes it to the
|
||||||
documentation directory.
|
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)
|
self.docs_directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
_generate_package_namespace_documentation(
|
_generate_package_namespace_documentation(
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
import errno
|
||||||
|
|
||||||
|
|
||||||
class NamespaceError(ValueError):
|
class NamespaceError(ValueError):
|
||||||
"""
|
"""
|
||||||
@@ -101,3 +103,12 @@ class PythonDefinitionError(ValueError):
|
|||||||
|
|
||||||
|
|
||||||
class PythonArgumentStructureError(PythonDefinitionError): ...
|
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 pytest
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
|
||||||
from jcloud_docsgen.cli.cli import create_argument_parser
|
from jcloud_docsgen.cli.cli import create_argument_parser
|
||||||
from jcloud_docsgen.cli.cli import main as cli_main
|
from jcloud_docsgen.cli.cli import main as cli_main
|
||||||
from jcloud_docsgen.cli.languages.python import parse_sections_list
|
from jcloud_docsgen.cli.languages.python import parse_sections_list
|
||||||
@@ -174,15 +176,40 @@ def test_sections_success():
|
|||||||
def test_docs_generation(expectdir):
|
def test_docs_generation(expectdir):
|
||||||
with expectdir('python_project') as output_dir:
|
with expectdir('python_project') as output_dir:
|
||||||
output_dir: pathlib.Path
|
output_dir: pathlib.Path
|
||||||
|
(output_dir / 'test_dir').mkdir()
|
||||||
input_dir = pathlib.Path(__file__).parent / 'python_project/project'
|
input_dir = pathlib.Path(__file__).parent / 'python_project/project'
|
||||||
cli_main(
|
assert (
|
||||||
[
|
cli_main(
|
||||||
'python',
|
[
|
||||||
'-s',
|
'python',
|
||||||
'params',
|
'-s',
|
||||||
'docstring',
|
'params',
|
||||||
'-HTo',
|
'docstring',
|
||||||
str(output_dir),
|
'-HTfo',
|
||||||
str(input_dir),
|
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
|
import tempfile
|
||||||
|
|
||||||
|
from src.jcloud_docsgen.exceptions import DirectoryNotEmptyError
|
||||||
from src.jcloud_docsgen.core.python import PythonDocumentationGenerator
|
from src.jcloud_docsgen.core.python import PythonDocumentationGenerator
|
||||||
from src.jcloud_docsgen.core.python.namespaces import (
|
from src.jcloud_docsgen.core.python.namespaces import (
|
||||||
PythonModuleNamespace,
|
PythonModuleNamespace,
|
||||||
@@ -120,7 +121,7 @@ def test_generate_documentation(dir, expectdir):
|
|||||||
pathlib.Path(str(d)),
|
pathlib.Path(str(d)),
|
||||||
allow_tables=True,
|
allow_tables=True,
|
||||||
allow_html=False,
|
allow_html=False,
|
||||||
).generate_documentation() # simplify the tests
|
).generate_documentation(True) # simplify the tests
|
||||||
|
|
||||||
|
|
||||||
def test_creates_directory():
|
def test_creates_directory():
|
||||||
@@ -144,3 +145,51 @@ def test_creates_directory():
|
|||||||
)
|
)
|
||||||
docs_generator.generate_documentation()
|
docs_generator.generate_documentation()
|
||||||
assert docs_dir.exists()
|
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