diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c2026f9..3012dec 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,3 +40,11 @@ jobs: - name: Run tests run: make test + + - name: Report coverage + id: coverage + if: ${{ !cancelled() }} + uses: MishaKav/pytest-coverage-comment@a1fe18e2b00c64a765568e2edb9f1706eb8fc88b # v1.1.51 + with: + title: Coverage Report + pytest-xml-coverage-path: ./coverage.xml diff --git a/.gitignore b/.gitignore index 0a53625..570e312 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ __pycache__/ # Virtual environments .venv/ env/ + +# Test coverage output +/.coverage +/coverage.xml diff --git a/environment-dev.yml b/environment-dev.yml index d141195..6e82d79 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -3,6 +3,7 @@ channels: dependencies: - python=3.10 - pytest +- pytest-cov - pyyaml - pip - mypy diff --git a/pyproject.toml b/pyproject.toml index b309c5f..17c5cef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,12 @@ files = [ python_version = "3.9" [tool.pytest.ini_options] +addopts = [ + "--cov", + "--color=yes", + "--cov-report=xml:./coverage.xml", + "--cov-report=term-missing" +] filterwarnings = ["error"] norecursedirs = ["env"] pythonpath = "src/" diff --git a/src/anaconda_pre_commit_hooks/add_renovate_annotations.py b/src/anaconda_pre_commit_hooks/add_renovate_annotations.py index 73199fc..02e0d11 100755 --- a/src/anaconda_pre_commit_hooks/add_renovate_annotations.py +++ b/src/anaconda_pre_commit_hooks/add_renovate_annotations.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 """Automagically add renovate comments to conda environment files. Given a number of paths specified as CLI arguments, we extract the unique app directories @@ -8,13 +7,10 @@ """ -import contextlib import json -import os import re import shlex import subprocess -from collections.abc import Iterator from pathlib import Path from typing import Annotated, NamedTuple, Optional, TypedDict @@ -40,16 +36,36 @@ class Dependencies(NamedTuple): conda: dict[str, Dependency] -@contextlib.contextmanager -def working_dir(d: Path) -> Iterator[None]: - orig = Path.cwd() - os.chdir(d) - yield - os.chdir(orig) +def setup_conda_environment(command: str, *, cwd: Optional[Path] = None) -> None: + """Ensure the conda environment is setup and updated.""" + cwd = cwd or Path.cwd() + result = subprocess.run( + shlex.split(command), capture_output=True, text=True, cwd=cwd + ) + if result.returncode != 0: + print(f"Failed to run setup command in {cwd}") + print(result.stdout) + print(result.stderr) + result.check_returncode() + + +def list_packages_in_conda_environment(environment_selector: str) -> list[dict]: + # Then we list the actual versions of each package in the environment + result = subprocess.run( + ["conda", "list", *shlex.split(environment_selector), "--json"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(result.stdout) + print(result.stderr) + result.check_returncode() + + return json.loads(result.stdout) def load_dependencies( - project_directory: Path, + project_directory: Optional[Path] = None, create_command: str = "make setup", environment_selector: str = "-p ./env", ) -> Dependencies: @@ -64,63 +80,44 @@ def load_dependencies( An object containing all dependencies in the installed environment, split between conda and pip packages. """ - with working_dir(project_directory): - # First ensure the conda environment exists - result = subprocess.run( - shlex.split(create_command), capture_output=True, text=True + setup_conda_environment(create_command, cwd=project_directory or Path.cwd()) + + data = list_packages_in_conda_environment(environment_selector) + + # We split the list separately into pip & conda dependencies + pip_deps = { + x["name"]: Dependency( + name=x["name"], channel=x["channel"], version=x["version"] ) - if result.returncode != 0: - print(f"Failed to run make setup for {project_directory}") - print(result.stdout) - print(result.stderr) - result.check_returncode() - - # Then we list the actual versions of each package in the environment - result = subprocess.run( - ["conda", "list", *shlex.split(environment_selector), "--json"], - capture_output=True, - text=True, + for x in data + if x["channel"] == "pypi" + } + + # We use endswith to match both pkgs/main and repo/main to main + conda_deps = { + x["name"]: Dependency( + name=x["name"], + channel="main" if x["channel"].endswith("/main") else x["channel"], + version=x["version"], ) - if result.returncode != 0: - print(result.stdout) - print(result.stderr) - result.check_returncode() - data = json.loads(result.stdout) - - # We split the list separately into pip & conda dependencies - pip_deps = { - x["name"]: Dependency( - name=x["name"], channel=x["channel"], version=x["version"] - ) - for x in data - if x["channel"] == "pypi" - } - - # We use endswith to match both pkgs/main and repo/main to main - conda_deps = { - x["name"]: Dependency( - name=x["name"], - channel="main" if x["channel"].endswith("/main") else x["channel"], - version=x["version"], - ) - for x in data - if x["channel"] != "pypi" - } - if len(pip_deps) + len(conda_deps) != len(data): - raise ValueError("Mismatch parsing dependencies") - return Dependencies(pip=pip_deps, conda=conda_deps) + for x in data + if x["channel"] != "pypi" + } + if len(pip_deps) + len(conda_deps) != len(data): + raise ValueError("Mismatch parsing dependencies") + return Dependencies(pip=pip_deps, conda=conda_deps) -def process_environment_file( +def add_comments_to_env_file( env_file: Path, dependencies: Dependencies, *, conda_channel_overrides: Optional[ChannelOverrides] = None, - pypi_index_overrides: Optional[IndexOverrides] = None, + pip_index_overrides: Optional[IndexOverrides] = None, ) -> None: """Process an environment file, which entails adding renovate comments and pinning the installed version.""" conda_channel_overrides = conda_channel_overrides or {} - pypi_index_overrides = pypi_index_overrides or {} + pip_index_overrides = pip_index_overrides or {} with env_file.open() as fp: in_lines = fp.readlines() @@ -176,7 +173,7 @@ def process_environment_file( if package_name != ".": if datasource == "conda": renovate_line = f"{' ' * indentation}# renovate: datasource={datasource} depName={dep_name}\n" - elif (index_url := pypi_index_overrides.get(dep_name)) is not None: + elif (index_url := pip_index_overrides.get(dep_name)) is not None: renovate_line = f"{' ' * indentation}# renovate: datasource={datasource} registryUrl={index_url}\n" else: renovate_line = ( @@ -202,24 +199,7 @@ def process_environment_file( fp.writelines(out_lines) -def add_comments_to_env_files( - env_files: list[Path], - dependencies: Dependencies, - *, - conda_channel_overrides: Optional[ChannelOverrides] = None, - pypi_index_overrides: Optional[IndexOverrides] = None, -) -> None: - """Process each environment file found.""" - for f in env_files: - process_environment_file( - f, - dependencies, - conda_channel_overrides=conda_channel_overrides, - pypi_index_overrides=pypi_index_overrides, - ) - - -def _parse_pip_index_overrides( +def parse_pip_index_overrides( internal_pip_index_url: str, internal_pip_package: list[str] ) -> dict[PackageName, IndexUrl]: pip_index_overrides = {} @@ -235,7 +215,7 @@ def cli( internal_pip_index_url: Annotated[str, typer.Option()] = "", ) -> None: # Construct a mapping of package name to index URL based on CLI options - pip_index_overrides = _parse_pip_index_overrides( + pip_index_overrides = parse_pip_index_overrides( internal_pip_index_url, internal_pip_package or [] ) @@ -244,15 +224,12 @@ def cli( project_dirs = sorted({env_file.parent for env_file in env_files}) for project_dir in project_dirs: deps = load_dependencies(project_dir) - project_env_files = [e for e in env_files if e.parent == project_dir] - add_comments_to_env_files( - project_env_files, deps, pypi_index_overrides=pip_index_overrides - ) + project_env_files = (e for e in env_files if e.parent == project_dir) + for env_file in project_env_files: + add_comments_to_env_file( + env_file, deps, pip_index_overrides=pip_index_overrides + ) def main() -> None: - typer.run(cli) - - -if __name__ == "__main__": - main() + typer.run(cli) # pragma: nocover diff --git a/tests/test_generate_renovate_annotations.py b/tests/test_generate_renovate_annotations.py index c88fa50..5dbc141 100644 --- a/tests/test_generate_renovate_annotations.py +++ b/tests/test_generate_renovate_annotations.py @@ -1,17 +1,33 @@ +import json +import subprocess +from pathlib import Path from textwrap import dedent import pytest import yaml +from anaconda_pre_commit_hooks.add_renovate_annotations import ( + Dependencies, + Dependency, + add_comments_to_env_file, + load_dependencies, + parse_pip_index_overrides, + setup_conda_environment, +) ENVIRONMENT_YAML = dedent("""\ channels: - defaults dependencies: - python=3.10 + # renovate: comment to be overridden - pytest - pip - pip: + - private-package + - fastapi==0.110.0 + - click[extras] - -e . + name: some-environment-name """) @@ -20,12 +36,138 @@ def environment_yaml(): return yaml.safe_load(ENVIRONMENT_YAML) -def test_the_tests(): - assert True - - def test_load_environment_yaml(environment_yaml): assert environment_yaml == { "channels": ["defaults"], - "dependencies": ["python=3.10", "pytest", "pip", {"pip": ["-e ."]}], + "dependencies": [ + "python=3.10", + "pytest", + "pip", + {"pip": ["private-package", "fastapi==0.110.0", "click[extras]", "-e ."]}, + ], + "name": "some-environment-name", } + + +@pytest.mark.parametrize( + "index_url, packages, expect_empty", + [ + ("https://my-internal-index.com/simple", ["package-1", "package-2"], False), + ("", ["package-1", "package-2"], True), + ("https://my-internal-index.com/simple", [], True), + ("", [], True), + ], +) +def test_parse_pip_index_overrides(index_url, packages, expect_empty): + """Test parsing of CLI arguments into a mapping. + + The mapping is empty of either of the arguments are Falsy. + + """ + result = parse_pip_index_overrides(index_url, packages) + if expect_empty: + assert result == {} + else: + assert result == {p: index_url for p in packages} + + +@pytest.fixture() +def mock_subprocess_run(monkeypatch): + old_subprocess_run = subprocess.run + + def f(args, *posargs, **kwargs): + if args == ["make", "setup"]: + return subprocess.CompletedProcess( + args, + 0, + "", + "", + ) + elif args[:2] == ["conda", "list"]: + return subprocess.CompletedProcess( + args, + 0, + json.dumps( + [ + { + "base_url": "https://conda.anaconda.org/pypi", + "build_number": 0, + "build_string": "pypi_0", + "channel": "pypi", + "dist_name": "click-8.1.7-pypi_0", + "name": "click", + "platform": "pypi", + "version": "8.1.7", + }, + { + "base_url": "https://repo.anaconda.com/pkgs/main", + "build_number": 1, + "build_string": "hb885b13_1", + "channel": "pkgs/main", + "dist_name": "python-3.10.14-hb885b13_1", + "name": "python", + "platform": "osx-arm64", + "version": "3.10.14", + }, + ] + ), + "", + ) + else: + return old_subprocess_run(args, *posargs, **kwargs) # pragma: nocover + + monkeypatch.setattr(subprocess, "run", f) + + +@pytest.mark.usefixtures("mock_subprocess_run") +def test_setup_conda_environment(): + result = setup_conda_environment("make setup") + assert result is None + + +@pytest.mark.usefixtures("mock_subprocess_run") +def test_load_dependencies(): + dependencies = load_dependencies(Path.cwd()) + assert dependencies == Dependencies( + pip={"click": Dependency(name="click", channel="pypi", version="8.1.7")}, + conda={"python": Dependency(name="python", channel="main", version="3.10.14")}, + ) + + +@pytest.mark.usefixtures("mock_subprocess_run") +def test_add_comments_to_env_file(tmp_path): + env_file_path = tmp_path / "environment.yml" + with env_file_path.open("w") as fp: + fp.write(ENVIRONMENT_YAML) + + # Modify the file in-place + add_comments_to_env_file( + env_file_path, + load_dependencies(), + pip_index_overrides={"private-package": "https://private-index.com/simple"}, + ) + + with env_file_path.open("r") as fp: + new_contents = fp.read() + + # Compare the results. Versions and channels should come from the mock above. + assert new_contents == dedent("""\ + channels: + - defaults + dependencies: + # renovate: datasource=conda depName=main/python + - python=3.10.14 + # renovate: datasource=conda depName=main/pytest + - pytest + # renovate: datasource=conda depName=main/pip + - pip + - pip: + # renovate: datasource=pypi registryUrl=https://private-index.com/simple + - private-package + # renovate: datasource=pypi + - fastapi==0.110.0 + # renovate: datasource=pypi + - click[extras]==8.1.7 + - -e . + name: some-environment-name + """)