diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 3c1cee1..38ad20e 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -197,6 +197,15 @@ files: "^.+\\.py$" language: system stages: [commit] +- id: poetry-sphinx-build-language + # Add sphinx to your poetry environment to use this hook. + name: poetry-sphinx-build-language + description: "Build documentation for a specific language using sphinx from the local poetry environment." + entry: sphinx_build_language + language: python + pass_filenames: false + require_serial: true + stages: [manual] - id: poetry-types-python # Add mypy to your poetry environment to use this hook. name: "types-python" diff --git a/pyproject.toml b/pyproject.toml index d282bb0..c244bb0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ from = 'src' include = 'cicd_tools_pre_commit' [tool.poetry.scripts] +sphinx_build_language = 'cicd_tools_pre_commit:sphinx_build_language' with-cicd-resources = 'cicd_tools_pre_commit:with_cicd_resources' [tool.ruff] diff --git a/src/cicd_tools_pre_commit/__init__.py b/src/cicd_tools_pre_commit/__init__.py index a227e9c..688752f 100644 --- a/src/cicd_tools_pre_commit/__init__.py +++ b/src/cicd_tools_pre_commit/__init__.py @@ -1,5 +1,9 @@ """CICD-Tools pre-commit scripts.""" from .resources import with_cicd_resources +from .sphinx import sphinx_build_language -__all__ = ("with_cicd_resources", ) +__all__ = ( + "sphinx_build_language", + "with_cicd_resources", +) diff --git a/src/cicd_tools_pre_commit/cli/__init__.py b/src/cicd_tools_pre_commit/cli/__init__.py new file mode 100644 index 0000000..dc58951 --- /dev/null +++ b/src/cicd_tools_pre_commit/cli/__init__.py @@ -0,0 +1 @@ +"""CLI related tools and types.""" diff --git a/src/cicd_tools_pre_commit/cli/tests/__init__.py b/src/cicd_tools_pre_commit/cli/tests/__init__.py new file mode 100644 index 0000000..2797013 --- /dev/null +++ b/src/cicd_tools_pre_commit/cli/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for CLI related tools and types.""" diff --git a/src/cicd_tools_pre_commit/cli/tests/test_existing_directory.py b/src/cicd_tools_pre_commit/cli/tests/test_existing_directory.py new file mode 100644 index 0000000..d6cbc75 --- /dev/null +++ b/src/cicd_tools_pre_commit/cli/tests/test_existing_directory.py @@ -0,0 +1,26 @@ +import argparse +import unittest +from unittest.mock import patch + +from cicd_tools_pre_commit.cli.types import existing_directory + + +class TestExistingDirectory(unittest.TestCase): + + @patch("os.path.isdir") + def test_existing_directory__valid_dir__returns_path( + self, + mock_isdir, + ): + mock_isdir.return_value = True + path = "/valid/dir" + self.assertEqual(existing_directory(path), path) + + @patch("os.path.isdir") + def test_existing_directory__invalid_dir__raises_error( + self, + mock_isdir, + ): + mock_isdir.return_value = False + with self.assertRaises(argparse.ArgumentTypeError): + existing_directory("/invalid/dir") diff --git a/src/cicd_tools_pre_commit/cli/tests/test_language_code.py b/src/cicd_tools_pre_commit/cli/tests/test_language_code.py new file mode 100644 index 0000000..a763926 --- /dev/null +++ b/src/cicd_tools_pre_commit/cli/tests/test_language_code.py @@ -0,0 +1,29 @@ +import argparse +import unittest + +from cicd_tools_pre_commit.cli.types import language_code + + +class TestLanguageCode(unittest.TestCase): + + def test_language_code__valid_short__returns_code(self): + self.assertEqual(language_code("en"), "en") + + def test_language_code__valid_long__returns_code(self): + self.assertEqual(language_code("zh_CN"), "zh_CN") + + def test_language_code__invalid_format__raises_error(self): + with self.assertRaises(argparse.ArgumentTypeError): + language_code("ENG") + + def test_language_code__uppercase_short__raises_error(self): + with self.assertRaises(argparse.ArgumentTypeError): + language_code("EN") + + def test_language_code__hyphenated__raises_error(self): + with self.assertRaises(argparse.ArgumentTypeError): + language_code("en-US") + + def test_language_code__empty__raises_error(self): + with self.assertRaises(argparse.ArgumentTypeError): + language_code("") diff --git a/src/cicd_tools_pre_commit/cli/tests/test_valid_path.py b/src/cicd_tools_pre_commit/cli/tests/test_valid_path.py new file mode 100644 index 0000000..87bf55a --- /dev/null +++ b/src/cicd_tools_pre_commit/cli/tests/test_valid_path.py @@ -0,0 +1,38 @@ +import argparse +import unittest +from unittest.mock import patch + +from cicd_tools_pre_commit.cli.types import valid_path + + +class TestValidPath(unittest.TestCase): + + @patch("os.path.exists") + @patch("os.path.abspath") + @patch("os.path.dirname") + def test_valid_path__parent_exists__returns_path( + self, + mock_dirname, + mock_abspath, + mock_exists, + ): + mock_abspath.return_value = "/valid/parent/file" + mock_dirname.return_value = "/valid/parent" + mock_exists.return_value = True + path = "file" + self.assertEqual(valid_path(path), path) + + @patch("os.path.exists") + @patch("os.path.abspath") + @patch("os.path.dirname") + def test_valid_path__parent_missing__raises_error( + self, + mock_dirname, + mock_abspath, + mock_exists, + ): + mock_abspath.return_value = "/invalid/parent/file" + mock_dirname.return_value = "/invalid/parent" + mock_exists.return_value = False + with self.assertRaises(argparse.ArgumentTypeError): + valid_path("file") diff --git a/src/cicd_tools_pre_commit/cli/types/__init__.py b/src/cicd_tools_pre_commit/cli/types/__init__.py new file mode 100644 index 0000000..e0874c7 --- /dev/null +++ b/src/cicd_tools_pre_commit/cli/types/__init__.py @@ -0,0 +1,11 @@ +"""CLI validation types.""" + +from .existing_directory import existing_directory +from .language_code import language_code +from .valid_path import valid_path + +__all__ = ( + "existing_directory", + "language_code", + "valid_path", +) diff --git a/src/cicd_tools_pre_commit/cli/types/existing_directory.py b/src/cicd_tools_pre_commit/cli/types/existing_directory.py new file mode 100644 index 0000000..93b53f1 --- /dev/null +++ b/src/cicd_tools_pre_commit/cli/types/existing_directory.py @@ -0,0 +1,13 @@ +"""existing_directory argparse type.""" + +import argparse +import os + + +def existing_directory(path: str) -> str: + """Check if the provided path is an existing directory.""" + if not os.path.isdir(path): + raise argparse.ArgumentTypeError( + f"The directory '{path}' does not exist.", + ) + return path diff --git a/src/cicd_tools_pre_commit/cli/types/language_code.py b/src/cicd_tools_pre_commit/cli/types/language_code.py new file mode 100644 index 0000000..bd36907 --- /dev/null +++ b/src/cicd_tools_pre_commit/cli/types/language_code.py @@ -0,0 +1,16 @@ +"""language_code argparse type.""" + +import argparse +import re + +LANGUAGE_CODE_REGEX = re.compile(r"^[a-z]{2}(_[A-Z]{2})?$") + + +def language_code(code: str) -> str: + """Check if the language code matches the allowed pattern.""" + if not LANGUAGE_CODE_REGEX.match(code): + raise argparse.ArgumentTypeError( + f"Language code '{code}' is invalid. " + "Expected format like 'en' or 'zh_CN'.", + ) + return code diff --git a/src/cicd_tools_pre_commit/cli/types/valid_path.py b/src/cicd_tools_pre_commit/cli/types/valid_path.py new file mode 100644 index 0000000..c28a2de --- /dev/null +++ b/src/cicd_tools_pre_commit/cli/types/valid_path.py @@ -0,0 +1,14 @@ +"""valid_path argparse type.""" + +import argparse +import os + + +def valid_path(path: str) -> str: + """Check if the provided path's parent directory exists.""" + parent = os.path.dirname(os.path.abspath(path)) + if not os.path.exists(parent): + raise argparse.ArgumentTypeError( + f"The parent directory of '{path}' does not exist.", + ) + return path diff --git a/src/cicd_tools_pre_commit/sphinx.py b/src/cicd_tools_pre_commit/sphinx.py new file mode 100644 index 0000000..1809de6 --- /dev/null +++ b/src/cicd_tools_pre_commit/sphinx.py @@ -0,0 +1,53 @@ +"""Sphinx related pre-commit scripts.""" + +import argparse +import os + +from .cli.types import existing_directory, language_code, valid_path +from .system import call + + +def sphinx_build_language() -> None: + """Build sphinx documentation for a specific language.""" + parser = argparse.ArgumentParser( + description="Build sphinx documentation for a specific language.", + ) + parser.add_argument( + "-l", + "--language", + required=True, + type=language_code, + help="The target language (e.g. en)", + ) + parser.add_argument( + "-t", + "--source", + required=True, + type=existing_directory, + help="The source folder", + ) + parser.add_argument( + "-b", + "--build", + required=True, + type=valid_path, + help="The build folder", + ) + + args = parser.parse_args() + + target_build_folder = os.path.join(args.build, args.language) + command = [ + "poetry", + "run", + "sphinx-build", + "-Ea", + "-b", + "html", + "-D", + f"language={args.language}", + args.source, + target_build_folder, + ] + + call(command) diff --git a/src/cicd_tools_pre_commit/tests/test_sphinx.py b/src/cicd_tools_pre_commit/tests/test_sphinx.py new file mode 100644 index 0000000..b22bcd3 --- /dev/null +++ b/src/cicd_tools_pre_commit/tests/test_sphinx.py @@ -0,0 +1,102 @@ +import unittest +from unittest.mock import patch + +from cicd_tools_pre_commit.sphinx import sphinx_build_language + + +class TestSphinx(unittest.TestCase): + + @patch( + "sys.argv", + ["sphinx_build_language", "-l", "en", "-t", "source", "-b", "build"], + ) + @patch("cicd_tools_pre_commit.sphinx.call") + @patch("cicd_tools_pre_commit.sphinx.existing_directory") + @patch("cicd_tools_pre_commit.sphinx.language_code") + @patch("cicd_tools_pre_commit.sphinx.valid_path") + def test_sphinx_build_language__valid_args__calls_sphinx_build( + self, + mock_valid_path, + mock_language_code, + mock_existing_directory, + mock_call, + ): + mock_existing_directory.side_effect = lambda x: x + mock_language_code.side_effect = lambda x: x + mock_valid_path.side_effect = lambda x: x + + sphinx_build_language() + + mock_call.assert_called_once_with( + [ + "poetry", + "run", + "sphinx-build", + "-Ea", + "-b", + "html", + "-D", + "language=en", + "source", + "build/en", + ], + ) + + @patch( + "sys.argv", + [ + "sphinx_build_language", + "-l", + "invalid", + "-t", + "source", + "-b", + "build", + ], + ) + @patch("cicd_tools_pre_commit.sphinx.call") + @patch("argparse.ArgumentParser.error") + @patch("cicd_tools_pre_commit.sphinx.existing_directory") + @patch("cicd_tools_pre_commit.sphinx.valid_path") + def test_sphinx_build_language__invalid_language__raises_system_exit( + self, + mock_valid_path, + mock_existing_directory, + mock_error, + mock_call, + ): + mock_error.side_effect = SystemExit(2) + mock_existing_directory.side_effect = lambda x: x + mock_valid_path.side_effect = lambda x: x + + with self.assertRaises(SystemExit): + sphinx_build_language() + + mock_call.assert_not_called() + + @patch( + "sys.argv", + ["sphinx_build_language", "-l", "en", "-t", "missing", "-b", "build"], + ) + @patch("cicd_tools_pre_commit.sphinx.call") + @patch("argparse.ArgumentParser.error") + @patch("cicd_tools_pre_commit.sphinx.existing_directory") + @patch("cicd_tools_pre_commit.sphinx.language_code") + @patch("cicd_tools_pre_commit.sphinx.valid_path") + def test_sphinx_build_language__missing_source__raises_system_exit( + self, + mock_valid_path, + mock_language_code, + mock_existing_directory, + mock_error, + mock_call, + ): + mock_error.side_effect = SystemExit(2) + mock_existing_directory.side_effect = SystemExit(2) + mock_language_code.side_effect = lambda x: x + mock_valid_path.side_effect = lambda x: x + + with self.assertRaises(SystemExit): + sphinx_build_language() + + mock_call.assert_not_called()