Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(linter): add 'pip check' linter #1951

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 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 @@ -22,6 +22,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 @@ -689,6 +691,56 @@ 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
if not (venv_dir / "lib").is_dir():
self.text = "Python venv is not valid."
return self.Result.NONAPPLICABLE
if sys.platform == "win32":
self.text = "Linter does not work on Windows."
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

mr-cal marked this conversation as resolved.
Show resolved Hide resolved
pip_cmd = [sys.executable, "-m", "pip", "--python", str(python_exe), "check"]
check = subprocess.run(
pip_cmd,
text=True,
capture_output=True,
check=False,
)
if check.returncode == 0:
mr-cal marked this conversation as resolved.
Show resolved Hide resolved
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 All @@ -701,4 +753,5 @@ def run(self, basedir: pathlib.Path) -> str:
Entrypoint,
OpsMainCall,
AdditionalFiles,
PipCheck,
]
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ dependencies = [
"requests-toolbelt",
"snap-helpers",
"tabulate",
# Needed until requests-unixsocket supports urllib3 v2
# https://github.com/msabramo/requests-unixsocket/pull/69
# When updating, remove the urllib3 constraint from renovate config.
"urllib3<2.0",
bepri marked this conversation as resolved.
Show resolved Hide resolved
"pip>=24.2",
]
classifiers = [
"Development Status :: 5 - Production/Stable",
Expand Down
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ more-itertools==10.5.0
oauthlib==3.2.2
overrides==7.7.0
packaging==24.1
pip==24.2
platformdirs==4.3.6
pluggy==1.5.0
protobuf==5.28.2
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ more-itertools==10.5.0
oauthlib==3.2.2
overrides==7.7.0
packaging==24.1
pip==24.2
platformdirs==4.3.6
protobuf==5.28.2
pycparser==2.22
Expand Down
61 changes: 61 additions & 0 deletions tests/integration/test_linters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# 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"],
],
)
@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported")
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
65 changes: 65 additions & 0 deletions tests/unit/test_linters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# 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 sys

import pytest

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_invalid_venv(fake_path: pathlib.Path):
(fake_path / "venv").mkdir()
lint = linters.PipCheck()
assert lint.run(fake_path) == LintResult.NONAPPLICABLE
assert lint.text == "Python venv is not valid."


@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported.")
mr-cal marked this conversation as resolved.
Show resolved Hide resolved
def test_pip_check_success(fake_path: pathlib.Path, fp):
(fake_path / "venv" / "lib").mkdir(parents=True)
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


@pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported.")
def test_pip_check_warning(fake_path: pathlib.Path, fp):
(fake_path / "venv" / "lib").mkdir(parents=True)
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™"
Loading