From 4845c123cdb2783138b630a484c52f9003cf213e Mon Sep 17 00:00:00 2001 From: Alex Lowe Date: Tue, 8 Oct 2024 20:45:34 -0400 Subject: [PATCH] feat(linter): add 'pip check' linter This linter runs 'pip check' on the venv of the resulting charm and warns if the virtual environment is inconsistent. --- charmcraft/linters.py | 47 +++++++++++++++++++++++- pyproject.toml | 1 + tests/integration/test_linters.py | 60 +++++++++++++++++++++++++++++++ tests/unit/test_linters.py | 54 ++++++++++++++++++++++++++++ 4 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 tests/integration/test_linters.py create mode 100644 tests/unit/test_linters.py diff --git a/charmcraft/linters.py b/charmcraft/linters.py index edcccd2e6..5c4a35a59 100644 --- a/charmcraft/linters.py +++ b/charmcraft/linters.py @@ -1,4 +1,4 @@ -# Copyright 2021-2022 Canonical Ltd. +# Copyright 2021-2024 Canonical Ltd. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -21,6 +21,8 @@ import pathlib import re import shlex +import subprocess +import sys import typing from collections.abc import Generator from typing import final @@ -668,6 +670,49 @@ def run(self, basedir: pathlib.Path) -> str: return self._check_additional_files(stage_dir, basedir) +class PipCheck(Linter): + """Check that the pip virtual environment is valid.""" + + name = "pip-check" + text = "Virtual environment is valid." + url = "https://pip.pypa.io/en/stable/cli/pip_check/" + + def run(self, basedir: pathlib.Path) -> str: + """Run pip check.""" + venv_dir = basedir / "venv" + if not venv_dir.is_dir(): + self.text = "Charm does not contain a Python venv." + return self.Result.NONAPPLICABLE + python_exe = venv_dir / "bin" / "python" + delete_parent = False + if not python_exe.parent.exists(): + delete_parent = True + python_exe.parent.mkdir() + if not python_exe.exists(): + delete_python_exe = True + python_exe.symlink_to(sys.executable) + else: + delete_python_exe = False + + pip_cmd = [sys.executable, "-m", "pip", "--python", str(python_exe), "check"] + check = subprocess.run( + pip_cmd, + text=True, + capture_output=True, + ) + if check.returncode == 0: + result = self.Result.OK + else: + self.text = check.stdout + result = self.Result.WARNING + if delete_python_exe: + python_exe.unlink() + if delete_parent: + python_exe.parent.rmdir() + + return result + + # all checkers to run; the order here is important, as some checkers depend on the # results from others CHECKERS: list[type[BaseChecker]] = [ diff --git a/pyproject.toml b/pyproject.toml index 179746404..8cc132284 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ # https://github.com/msabramo/requests-unixsocket/pull/69 # When updating, remove the urllib3 constraint from renovate config. "urllib3<2.0", + "pip>=24.2", ] classifiers = [ "Development Status :: 5 - Production/Stable", diff --git a/tests/integration/test_linters.py b/tests/integration/test_linters.py new file mode 100644 index 000000000..376e7b25e --- /dev/null +++ b/tests/integration/test_linters.py @@ -0,0 +1,60 @@ +# Copyright 2024 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# For further info, check https://github.com/canonical/charmcraft +"""Unit tests for linters.""" + +import pathlib +import subprocess +import sys + +import pytest + +from charmcraft import linters +from charmcraft.models.lint import LintResult + +pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="Windows not supported") + + +@pytest.mark.parametrize( + "pip_cmd", + [ + ["--version"], + ["install", "pytest", "hypothesis"], + ] +) +def test_pip_check_success(tmp_path: pathlib.Path, pip_cmd: list[str]): + venv_path = tmp_path / "venv" + subprocess.run([sys.executable, "-m", "venv", venv_path], check=True) + subprocess.run([venv_path / "bin" / "python", "-m", "pip", *pip_cmd], check=True) + + lint = linters.PipCheck() + assert lint.run(tmp_path) == LintResult.OK + assert lint.text == linters.PipCheck.text + + +@pytest.mark.parametrize( + "pip_cmd", + [ + ["install", "--no-deps", "pydantic==2.9.2"], + ] +) +def test_pip_check_failure(tmp_path: pathlib.Path, pip_cmd: list[str]): + venv_path = tmp_path / "venv" + subprocess.run([sys.executable, "-m", "venv", venv_path], check=True) + subprocess.run([venv_path / "bin" / "python", "-m", "pip", *pip_cmd], check=True) + + lint = linters.PipCheck() + assert lint.run(tmp_path) == LintResult.WARNING + assert "pydantic 2.9.2 requires pydantic-core" in lint.text diff --git a/tests/unit/test_linters.py b/tests/unit/test_linters.py new file mode 100644 index 000000000..1977d6c5d --- /dev/null +++ b/tests/unit/test_linters.py @@ -0,0 +1,54 @@ +# Copyright 2024 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# For further info, check https://github.com/canonical/charmcraft +"""Unit tests for linters.""" + +import pathlib +import subprocess +import sys +from charmcraft import linters +from charmcraft.models.lint import LintResult + + +def test_pip_check_not_venv(fake_path: pathlib.Path): + lint = linters.PipCheck() + assert lint.run(fake_path) == LintResult.NONAPPLICABLE + assert lint.text == "Charm does not contain a Python venv." + + +def test_pip_check_success(fake_path: pathlib.Path, fp): + (fake_path / "venv").mkdir() + fp.register( + [sys.executable, "-m", "pip", "--python", fp.any(), "check"], + returncode=0, + stdout="Loo loo loo, doing pip stuff. Pip stuff is my favourite stuff." + ) + + lint = linters.PipCheck() + assert lint.run(fake_path) == LintResult.OK + assert lint.text == linters.PipCheck.text + + +def test_pip_check_warning(fake_path: pathlib.Path, fp): + (fake_path / "venv").mkdir() + fp.register( + [sys.executable, "-m", "pip", "--python", fp.any(), "check"], + returncode=1, + stdout="This error was sponsored by Raytheon Knife Missiles™" + ) + + lint = linters.PipCheck() + assert lint.run(fake_path) == LintResult.WARNING + assert lint.text == "This error was sponsored by Raytheon Knife Missiles™"