From e934b85e2359e28f74112b7761a509b52d673c0b Mon Sep 17 00:00:00 2001 From: Casper Welzel Andersen <43357585+CasperWA@users.noreply.github.com> Date: Tue, 23 Aug 2022 15:51:16 +0200 Subject: [PATCH] Add unit tests for tasks (#45) Add tests for tasks. Run tests in CI with coverage. Fix API reference generation project links in markdown files. --- .github/workflows/_local_ci_tests.yml | 51 ++++++- .gitignore | 4 + .pre-commit-config.yaml | 18 +++ ci_cd/tasks.py | 16 ++- pyproject.toml | 4 + tests/test_tasks.py | 200 ++++++++++++++++++++++++++ 6 files changed, 283 insertions(+), 10 deletions(-) create mode 100644 tests/test_tasks.py diff --git a/.github/workflows/_local_ci_tests.yml b/.github/workflows/_local_ci_tests.yml index 05bcd11a..f98bbe91 100644 --- a/.github/workflows/_local_ci_tests.yml +++ b/.github/workflows/_local_ci_tests.yml @@ -12,13 +12,56 @@ jobs: name: Call reusable workflow uses: ./.github/workflows/ci_tests.yml with: + # general python_version: "3.9" - install_extras: "[dev,docs]" - skip_pre-commit_hooks: pylint - pylint_options: --rcfile=pyproject.toml - pylint_targets: ci_cd + install_extras: "[dev,docs,testing]" + + # pre-commit + skip_pre-commit_hooks: pylint,pylint-tests + + # pylint + pylint_runs: | + --rcfile=pyproject.toml ci_cd + --rcfile=pyproject.toml --disable=import-outside-toplevel,redefined-outer-name tests + + # build dist build_libs: flit build_cmd: flit build + + # build docs update_python_api_ref: false update_docs_landing_page: false debug: false + + pytest: + name: pytest + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + python-version: ["3.7", "3.8", "3.9", "3.10"] + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version}} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version}} + + - name: Install Python dependencies + run: | + python -m pip install -U pip + pip install -U setuptools wheel flit + pip install -e .[testing] + + - name: Test with pytest + run: pytest -vvv --cov=ci_cd --cov-report=xml + + - name: Upload coverage to Codecov + if: matrix.python-version == '3.9' && github.repository == 'SINTEF/ci-cd' + uses: codecov/codecov-action@v3 + with: + fail_ci_if_error: true diff --git a/.gitignore b/.gitignore index 9ab70085..f3708abf 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,8 @@ __pycache__/ *.egg*/ build/ +# Documentation site/ + +# Coverage +.coverage diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f840d257..934af99c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -41,6 +41,7 @@ repos: hooks: - id: bandit args: ["-r"] + exclude: ^tests/.*$ # mypy is a static typing linter # The main code repository can be found at: @@ -62,6 +63,23 @@ repos: - id: pylint name: pylint entry: pylint + args: ["--rcfile=pyproject.toml"] language: python types: [python] require_serial: true + exclude: ^tests/.*$ + # pylint is a Python linter + # It is run through the local environment to ensure external packages can be + # imported without issue. + # For more information about pylint see its documentation at: + # https://pylint.pycqa.org/en/latest/ + - id: pylint-tests + name: pylint - tests + entry: pylint + args: + - "--rcfile=pyproject.toml" + - "--disable=import-outside-toplevel,redefined-outer-name" + language: python + types: [python] + require_serial: true + files: ^tests/.*$ diff --git a/ci_cd/tasks.py b/ci_cd/tasks.py index 1cb24979..fef14c99 100644 --- a/ci_cd/tasks.py +++ b/ci_cd/tasks.py @@ -401,10 +401,8 @@ def update_deps( # pylint: disable=too-many-branches,too-many-locals,too-many-s # Update pyproject.toml updated_version = ".".join(latest_version[: len(version.split("."))]) - escaped_full_dependency_name = full_dependency_name.replace( - "[", "\[" # pylint: disable=anomalous-backslash-in-string - ).replace( - "]", "\]" # pylint: disable=anomalous-backslash-in-string + escaped_full_dependency_name = full_dependency_name.replace("[", r"\[").replace( + "]", r"\]" ) update_file( pyproject_path, @@ -500,6 +498,8 @@ def create_api_reference_docs( # pylint: disable=too-many-locals,too-many-branc unwanted_folder: list[str] = ["__pycache__"] if not unwanted_file: unwanted_file: list[str] = ["__init__.py"] + if not full_docs_folder: + full_docs_folder: list[str] = [] def write_file(full_path: Path, content: str) -> None: """Write file with `content` to `full_path`""" @@ -604,9 +604,13 @@ def write_file(full_path: Path, content: str) -> None: basename = filename[: -len(".py")] py_path = ( - f"{package_dir.name}/{relpath}/{basename}".replace("/", ".") + f"{package_dir.relative_to(root_repo_path)}/{relpath}/{basename}".replace( + "/", "." + ) if str(relpath) != "." - else f"{package_dir.name}/{basename}".replace("/", ".") + else f"{package_dir.relative_to(root_repo_path)}/{basename}".replace( + "/", "." + ) ) md_filename = filename.replace(".py", ".md") if debug: diff --git a/pyproject.toml b/pyproject.toml index 3ff6f19d..72d752fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,10 @@ docs = [ "mkdocs-material ~=8.4", "mkdocstrings[python] ~=0.19.0", ] +testing = [ + "pytest ~=7.1", + "pytest-cov ~=3.0", +] dev = [ "pre-commit ~=2.20", "pylint ~=2.13", diff --git a/tests/test_tasks.py b/tests/test_tasks.py new file mode 100644 index 00000000..3e428abb --- /dev/null +++ b/tests/test_tasks.py @@ -0,0 +1,200 @@ +"""Test `ci_cd/tasks.py`.""" +# pylint: disable=too-many-locals +import pytest + + +def test_create_api_reference() -> None: + """Check create_api_reference_docs runs with defaults.""" + import os + import shutil + from pathlib import Path + from tempfile import TemporaryDirectory + + from invoke import MockContext + + from ci_cd.tasks import create_api_reference_docs + + with TemporaryDirectory() as tmpdir: + root_path = Path(tmpdir).resolve() + package_dir = root_path / "ci_cd" + shutil.copytree( + src=Path(__file__).resolve().parent.parent / "ci_cd", + dst=package_dir, + ) + + docs_folder = root_path / "docs" + docs_folder.mkdir() + + create_api_reference_docs( + MockContext(), + str(package_dir), + root_repo_path=str(root_path), + ) + + api_reference_folder = docs_folder / "api_reference" + + assert docs_folder.exists(), f"Parent content: {os.listdir(docs_folder.parent)}" + assert ( + api_reference_folder.exists() + ), f"Parent content: {os.listdir(api_reference_folder.parent)}" + assert {".pages", "main.md", "tasks.md"} == set( + os.listdir(api_reference_folder) + ) + + assert (api_reference_folder / ".pages").read_text( + encoding="utf8" + ) == 'title: "API Reference"\n' + assert (api_reference_folder / "main.md").read_text( + encoding="utf8" + ) == "# main\n\n::: ci_cd.main\n" + assert (api_reference_folder / "tasks.md").read_text( + encoding="utf8" + ) == "# tasks\n\n::: ci_cd.tasks\n" + + +def test_api_reference_nested_package() -> None: + """Check create_api_reference_docs generates correct link to sub-nested package + directory.""" + import os + import shutil + from pathlib import Path + from tempfile import TemporaryDirectory + + from invoke import MockContext + + from ci_cd.tasks import create_api_reference_docs + + with TemporaryDirectory() as tmpdir: + root_path = Path(tmpdir).resolve() + package_dir = root_path / "src" / "ci_cd" / "ci_cd" + shutil.copytree( + src=Path(__file__).resolve().parent.parent / "ci_cd", + dst=package_dir, + ) + + docs_folder = root_path / "docs" + docs_folder.mkdir() + + create_api_reference_docs( + MockContext(), + str(package_dir), + root_repo_path=str(root_path), + ) + + api_reference_folder = docs_folder / "api_reference" + + assert docs_folder.exists(), f"Parent content: {os.listdir(docs_folder.parent)}" + assert ( + api_reference_folder.exists() + ), f"Parent content: {os.listdir(api_reference_folder.parent)}" + assert {".pages", "main.md", "tasks.md"} == set( + os.listdir(api_reference_folder) + ) + + assert (api_reference_folder / ".pages").read_text( + encoding="utf8" + ) == 'title: "API Reference"\n' + assert (api_reference_folder / "main.md").read_text( + encoding="utf8" + ) == "# main\n\n::: src.ci_cd.ci_cd.main\n" + assert (api_reference_folder / "tasks.md").read_text( + encoding="utf8" + ) == "# tasks\n\n::: src.ci_cd.ci_cd.tasks\n" + + +def test_update_deps() -> None: + """Check update_deps runs with defaults.""" + import re + from pathlib import Path + from tempfile import TemporaryDirectory + + import tomlkit + from invoke import MockContext + + from ci_cd.tasks import update_deps + + original_dependencies = { + "invoke": "1.7", + "tomlkit": "0.11.4", + "mike": "1.1", + "pytest": "7.1", + "pytest-cov": "3.0", + "pre-commit": "2.20", + "pylint": "2.13", + } + + with TemporaryDirectory() as tmpdir: + root_path = Path(tmpdir).resolve() + pyproject_file = root_path / "pyproject.toml" + pyproject_file.write_text( + data=f""" +[project] +requires-python = "~=3.7" + +dependencies = [ + "invoke ~={original_dependencies['invoke']}", + "tomlkit[test,docs] ~={original_dependencies['tomlkit']}", +] + +[project.optional-dependencies] +docs = [ + "mike >={original_dependencies['mike']},<3", +] +testing = [ + "pytest ~={original_dependencies['pytest']}", + "pytest-cov ~={original_dependencies['pytest-cov']}", +] +dev = [ + "mike >={original_dependencies['mike']},<3", + "pytest ~={original_dependencies['pytest']}", + "pytest-cov ~={original_dependencies['pytest-cov']}", + "pre-commit ~={original_dependencies['pre-commit']}", + "pylint ~={original_dependencies['pylint']}", +] +""", + encoding="utf8", + ) + + context = MockContext( + run={ + re.compile(r".*invoke$"): "invoke (1.7.1)\n", + re.compile(r".*tomlkit$"): "tomlkit (1.0.0)", + re.compile(r".*mike$"): "mike (1.0.1)", + re.compile(r".*pytest$"): "pytest (7.1.0)", + re.compile(r".*pytest-cov$"): "pytest-cov (3.1.0)", + re.compile(r".*pre-commit$"): "pre-commit (2.20.0)", + re.compile(r".*pylint$"): "pylint (2.14.0)", + } + ) + + update_deps( + context, + root_repo_path=str(root_path), + ) + + pyproject = tomlkit.loads(pyproject_file.read_bytes()) + + dependencies: list[str] = pyproject.get("project", {}).get("dependencies", []) + for optional_deps in ( + pyproject.get("project", {}).get("optional-dependencies", {}).values() + ): + dependencies.extend(optional_deps) + + for line in dependencies: + if any( + package_name in line + for package_name in ["invoke ", "pytest ", "pre-commit "] + ): + package_name = line.split(maxsplit=1)[0] + assert line == f"{package_name} ~={original_dependencies[package_name]}" + elif "tomlkit" in line: + # Should be three version digits, since the original dependency had three. + assert line == "tomlkit[test,docs] ~=1.0.0" + elif "mike" in line: + assert line == "mike >=1.0,<3" + elif "pytest-cov" in line: + assert line == "pytest-cov ~=3.1" + elif "pylint" in line: + assert line == "pylint ~=2.14" + else: + pytest.fail(f"Unknown package in line: {line}")