from __future__ import annotations import pathlib import shutil import subprocess import tempfile import unittest from types import TracebackType from typing import Any, Optional, Type import tomli import yaml from copier import run_copy def _run_shell_command_in_dir(command: list[str], dir: str) -> tuple[bytes, bytes, int]: with subprocess.Popen(command, cwd=dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE) as proc: outs, errs = proc.communicate() returncode = proc.returncode return outs, errs, returncode class CopierRenderer: DEFAULT_PARAMETERS = { "project_name": "My Little Project", "author_first_name": "Twilight", "author_last_name": "Sparkle", "bitbucket_organization": "MY_LITTLE_BITBUCKET_ORG", } def __init__( self, temp_source_dir: tempfile.TemporaryDirectory, parameters: dict[str, str] | None = None, ): self._temp_source_dir = temp_source_dir if parameters is None: self.parameters = {} else: self.parameters = parameters def __enter__(self) -> CopierRenderer: """Run when the context is entered. Starts the transaction.""" self._temp_render_dir = tempfile.TemporaryDirectory() self.render_path = pathlib.Path(self._temp_render_dir.name) run_copy( src_path=self._temp_source_dir.name, dst_path=self.render_path, vcs_ref="HEAD", defaults=True, data=(CopierRenderer.DEFAULT_PARAMETERS | self.parameters), quiet=True, ) return self def __exit__( self, exc_type: Optional[Type[BaseException]], exc_value: Optional[BaseException], traceback: Optional[TracebackType], ) -> None: """Run when the context is exited. Ends the transaction with one of the actions specified in the initializer.""" self._temp_render_dir.cleanup() def load_yaml(self, file_path: str) -> Any: return yaml.safe_load((self.render_path / file_path).read_text()) def load_toml(self, file_path: str) -> Any: return tomli.loads((self.render_path / file_path).read_text()) class TestTemplate(unittest.TestCase): def setUp(self): source_dir = pathlib.Path(__file__).parent.parent.parent self.temp_source_dir = tempfile.TemporaryDirectory() shutil.copytree(str(source_dir), self.temp_source_dir.name, dirs_exist_ok=True) return super().setUp() def tearDown(self): self.temp_source_dir.cleanup() return super().tearDown() def test_dot_python_version(self) -> None: with CopierRenderer(self.temp_source_dir) as copier_renderer: self.assertEqual( "3.12\n", (copier_renderer.render_path / ".python-version").read_text(), ) def test_pyproject_toml(self) -> None: with CopierRenderer(self.temp_source_dir) as copier_renderer: pyproject = tomli.loads((copier_renderer.render_path / "pyproject.toml").read_text()) self.assertEqual(pyproject["project"]["name"], "sbb-my-little-project") self.assertEqual( pyproject["project"]["description"], "A project created with esta-python-template", ) self.assertEqual( pyproject["project"]["authors"], [ { "name": "Twilight Sparkle", "email": "twilight.sparkle@sbb.ch", } ], ) self.assertEqual(pyproject["project"]["requires-python"], "~= 3.12.0") self.assertEqual( pyproject["project"]["urls"]["repository"], "https://code.sbb.ch/projects/MY_LITTLE_BITBUCKET_ORG/repos/my-little-project", ) self.assertEqual( pyproject["project"]["urls"]["documentation"], "https://code.sbb.ch/projects/MY_LITTLE_BITBUCKET_ORG/repos/my-little-project/browse/README.md", ) self.assertEqual( pyproject["tool"]["poetry"]["packages"], [{"include": "my_little_project", "from": "src"}], ) self.assertEqual( pyproject["project"]["scripts"], {"entrypoint": "my_little_project.main:cli"}, ) self.assertEqual(pyproject["tool"]["mypy"]["python_version"], "3.12") def test_with_docker(self) -> None: with CopierRenderer( self.temp_source_dir, {"docker_repository": "esta.docker", "python_version": "3.9"}, ) as copier_renderer: dockerfile = copier_renderer.render_path / "Dockerfile" # Dockerfile exists self.assertTrue(dockerfile.is_file()) # Dockerignore exists self.assertTrue((copier_renderer.render_path / ".dockerignore").is_file()) # FROM-instruction with dockerfile.open() as fh: self.assertEqual( fh.readline(), "FROM registry-redhat.docker.bin.sbb.ch/rhel9/python-39 AS base\n", ) # Tekton-Pipeline tekton_pipeline = copier_renderer.load_yaml("estaTektonPipeline.yaml") # Tekton-Docker section self.assertEqual( tekton_pipeline["docker"], {"artifactoryDockerRepo": "esta.docker", "caching": True}, ) continuous_build = tekton_pipeline["pipelines"][0]["build"] snapshot_build = tekton_pipeline["pipelines"][1]["build"] release_build = tekton_pipeline["pipelines"][2]["build"] # Check build sections self.assertEqual( continuous_build, { "sonarScan": {"enabled": True}, "owaspDependencyCheck": { "enabled": True, "additionalParams": "--suppression dependency-check-suppressions.xml --disablePyDist --disablePyPkg --failOnCVSS 9", }, "failOnQualityGateFailure": True, "buildDockerImage": True, "deployDockerImage": False, }, ) self.assertEqual( snapshot_build, { "sonarScan": {"enabled": True}, "owaspDependencyCheck": { "enabled": True, "additionalParams": "--suppression dependency-check-suppressions.xml --disablePyDist --disablePyPkg --failOnCVSS 9", }, "failOnQualityGateFailure": True, "buildDockerImage": True, "deployDockerImage": True, "deployArtifacts": False, }, ) self.assertEqual( release_build, { "sonarScan": {"enabled": True}, "owaspDependencyCheck": { "enabled": True, "additionalParams": "--suppression dependency-check-suppressions.xml --disablePyDist --disablePyPkg --failOnCVSS 9", }, "failOnQualityGateFailure": True, "buildDockerImage": True, "deployArtifacts": True, "additionalDockerImageTags": ["latest"], }, ) def test_with_docker_default_python_version(self) -> None: with CopierRenderer( self.temp_source_dir, {"docker_repository": "esta.docker"}, # By not specifying a Python version, the default is taken ) as copier_renderer: dockerfile = copier_renderer.render_path / "Dockerfile" # Dockerfile exists self.assertTrue(dockerfile.is_file()) # Dockerignore exists self.assertTrue((copier_renderer.render_path / ".dockerignore").is_file()) # FROM-instruction with dockerfile.open() as fh: self.assertEqual( fh.readline(), "FROM registry-redhat.docker.bin.sbb.ch/rhel9/python-312 AS base\n", ) def test_without_docker(self) -> None: with CopierRenderer(self.temp_source_dir, {"docker_repository": "", "python_version": "3.12"}) as copier_renderer: # Dockerfile does not exist self.assertFalse((copier_renderer.render_path / "Dockerfile").exists()) # Dockerignore does not exist self.assertFalse((copier_renderer.render_path / ".dockerignore").exists()) # Tekton-Pipeline tekton_pipeline = copier_renderer.load_yaml("estaTektonPipeline.yaml") # Tekton-Docker section self.assertNotIn("docker", tekton_pipeline) # Check build sections for i, pipeline in enumerate(["continuous", "snapshot", "release"]): with self.subTest(msg=f"Checking pipeline: '{pipeline}'."): self.assertEqual( tekton_pipeline["pipelines"][i]["build"], { "sonarScan": {"enabled": True}, "owaspDependencyCheck": { "enabled": True, "additionalParams": "--suppression dependency-check-suppressions.xml --disablePyDist --disablePyPkg --failOnCVSS 9", }, "failOnQualityGateFailure": True, }, ) def test_with_pypi(self) -> None: with CopierRenderer(self.temp_source_dir, {"pypi_repository": "esta.pypi"}) as copier_renderer: # Check Tekton-Pipeline tekton_pipeline = copier_renderer.load_yaml("estaTektonPipeline.yaml") self.assertEqual(tekton_pipeline["python"], {"targetRepo": "esta.pypi"}) def test_without_pypi(self) -> None: with CopierRenderer(self.temp_source_dir, {"pypi_repository": ""}) as copier_renderer: # Check Tekton-Pipeline tekton_pipeline = copier_renderer.load_yaml("estaTektonPipeline.yaml") self.assertEqual(tekton_pipeline["python"], {}) def test_pre_commit_hooks_in_template(self) -> None: with CopierRenderer(self.temp_source_dir) as copier_renderer: outs, errs, _ = _run_shell_command_in_dir(["git", "init"], dir=str(copier_renderer.render_path)) outs, errs, _ = _run_shell_command_in_dir(["git", "add", "--all"], dir=str(copier_renderer.render_path)) commands = [ ["make"], ["poetry", "run", "pre-commit", "run", "--all-files"], ] for command in commands: with self.subTest(msg=f"Running {command=}."): stdout, stderr, returncode = _run_shell_command_in_dir(command=command, dir=str(copier_renderer.render_path)) self.assertEqual( returncode, 0, msg=f"\nstdout:\n{stdout.decode()}\nstderr:\n{stderr.decode()}.", ) def test_directory_names(self) -> None: with CopierRenderer(self.temp_source_dir, {"project_name": "Funky Grogu"}) as copier_renderer: self.assertTrue((copier_renderer.render_path / "src" / "funky_grogu").is_dir()) self.assertTrue((copier_renderer.render_path / "tests" / "funky_grogu").is_dir()) def test_poetry_toml(self) -> None: with CopierRenderer(self.temp_source_dir, {"pypi_repository": "funky-grogu.pypi"}) as copier_renderer: poetry = tomli.loads((copier_renderer.render_path / "poetry.toml").read_text()) self.assertEqual( poetry["repositories"]["artifactory"]["url"], "https://bin.sbb.ch/artifactory/api/pypi/funky-grogu.pypi", ) def test_with_helm(self) -> None: with CopierRenderer( self.temp_source_dir, { "name": "my-project", "description": "My project description", "helm_repository": "esta.helm.local", }, ) as copier_renderer: for dir_path in [ "charts", "charts/my-project", "charts/my-project/templates", ]: self.assertTrue((copier_renderer.render_path / dir_path).is_dir()) for file_path in [ "charts/my-project/.helmignore", "charts/my-project/Chart.yaml", "charts/my-project/values.yaml", ]: self.assertTrue((copier_renderer.render_path / file_path).is_file()) chart_yaml = copier_renderer.load_yaml("charts/my-project/Chart.yaml") self.assertEqual( chart_yaml, { "apiVersion": "v2", "description": "My project description", "icon": "http://acme.org/replaceme.jpg", "name": "my-project", "type": "application", "version": "0.0.0", }, ) # Tekton-Pipeline tekton_pipeline = copier_renderer.load_yaml("estaTektonPipeline.yaml") self.assertEqual( tekton_pipeline["helm"], {"chartRepository": "esta.helm.local", "linting": True}, ) release_build = tekton_pipeline["pipelines"][2]["build"] self.assertEqual( release_build, { "sonarScan": {"enabled": True}, "owaspDependencyCheck": { "enabled": True, "additionalParams": "--suppression dependency-check-suppressions.xml --disablePyDist --disablePyPkg --failOnCVSS 9", }, "failOnQualityGateFailure": True, "packageAndDeployHelmChart": True, }, ) def test_without_helm(self) -> None: with CopierRenderer( self.temp_source_dir, { "name": "my-project", "description": "My project description", "helm_repository": "", }, ) as copier_renderer: self.assertFalse((copier_renderer.render_path / "charts").exists()) # Tekton-Pipeline tekton_pipeline = copier_renderer.load_yaml("estaTektonPipeline.yaml") self.assertNotIn("helm", tekton_pipeline) release_build = tekton_pipeline["pipelines"][2]["build"] self.assertEqual( release_build, { "sonarScan": {"enabled": True}, "owaspDependencyCheck": { "enabled": True, "additionalParams": "--suppression dependency-check-suppressions.xml --disablePyDist --disablePyPkg --failOnCVSS 9", }, "failOnQualityGateFailure": True, }, ) def test_without_ggshield(self) -> None: with CopierRenderer( self.temp_source_dir, { "name": "my-project", "description": "My project description", "use_ggshield": "False", }, ) as copier_renderer: # .pre-commit-config.yaml pre_commit_config = copier_renderer.load_yaml(".pre-commit-config.yaml") hook_ids = [h["id"] for h in pre_commit_config["repos"][0]["hooks"]] self.assertNotIn("ggshield", hook_ids) # estaTektonPipeline.yaml esta_tekton_pipeline = copier_renderer.load_yaml("estaTektonPipeline.yaml") for pipeline in esta_tekton_pipeline["pipelines"]: self.assertNotIn("gitguardian", pipeline["build"]) # .env.example env_example = (copier_renderer.render_path / ".env.example").read_text() self.assertNotIn("GITGUARDIAN_API_KEY", env_example) # Makefile makefile = (copier_renderer.render_path / "Makefile").read_text() self.assertNotIn("ggshield_has_key", makefile) # pyproject.toml pyproject_toml = (copier_renderer.render_path / "pyproject.toml").read_text() self.assertNotIn("ggshield", pyproject_toml) # README.md readme_md = (copier_renderer.render_path / "README.md").read_text() self.assertNotIn("GitGuardian", readme_md) def test_with_ggshield(self) -> None: with CopierRenderer( self.temp_source_dir, { "name": "my-project", "description": "My project description", "use_ggshield": "True", }, ) as copier_renderer: # .pre-commit-config.yaml pre_commit_config = copier_renderer.load_yaml(".pre-commit-config.yaml") self.assertIn( { "id": "ggshield", "name": "ggshield", "entry": "bash", "description": "Runs ggshield to detect hardcoded secrets, security vulnerabilities and policy breaks.", "stages": ["pre-commit"], "args": ["-c", '[ -n "$CI" ] || ggshield secret scan pre-commit'], "language": "system", "pass_filenames": True, }, pre_commit_config["repos"][0]["hooks"], ) # estaTektonPipeline.yaml esta_tekton_pipeline = copier_renderer.load_yaml("estaTektonPipeline.yaml") for pipeline in esta_tekton_pipeline["pipelines"]: self.assertEqual( {"enabled": True, "reportmode": "FAILED"}, pipeline["build"]["gitguardian"], ) # .env.example env_example_lines = (copier_renderer.render_path / ".env.example").read_text().splitlines() for line in [ "# Template for GGShield", 'GITGUARDIAN_API_KEY=""', 'GITGUARDIAN_INSTANCE="https://gitguardian.sbb.ch"', ]: self.assertIn(line, env_example_lines) # Makefile makefile = (copier_renderer.render_path / "Makefile").read_text() self.assertIn( """ ggshield_has_key: ifeq ($(GITGUARDIAN_API_KEY),) $(warning No API-Key for GitGuardian was set!) endif """, makefile, ) # pyproject.toml pyproject_toml = copier_renderer.load_toml("pyproject.toml") self.assertIn( "ggshield", pyproject_toml["tool"]["poetry"]["group"]["dev"]["dependencies"], ) # README.md readme_md = (copier_renderer.render_path / "README.md").read_text() self.assertIn("### Setup GGShield (GitGuardian)", readme_md)