Skip to content

Commit

Permalink
feat(linter): add 'pip check' linter
Browse files Browse the repository at this point in the history
This linter runs 'pip check' on the venv of the resulting charm and
warns if the virtual environment is inconsistent.
  • Loading branch information
lengau committed Oct 9, 2024
1 parent f2fe9c7 commit 4845c12
Show file tree
Hide file tree
Showing 4 changed files with 161 additions and 1 deletion.
47 changes: 46 additions & 1 deletion charmcraft/linters.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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]] = [
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
60 changes: 60 additions & 0 deletions tests/integration/test_linters.py
Original file line number Diff line number Diff line change
@@ -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
54 changes: 54 additions & 0 deletions tests/unit/test_linters.py
Original file line number Diff line number Diff line change
@@ -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™"

0 comments on commit 4845c12

Please sign in to comment.