Skip to content

Commit 4fabfca

Browse files
Add primer tests, (running pylint on external libs during tests)
In order to anticipate crash/fatal messages, false positives are harder to anticipate.
1 parent 363b3ac commit 4fabfca

File tree

9 files changed

+265
-5
lines changed

9 files changed

+265
-5
lines changed

.github/workflows/ci.yaml

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -508,4 +508,38 @@ jobs:
508508
run: |
509509
. venv/bin/activate
510510
pip install -e .
511-
pytest -m primer_stdlib -n auto
511+
pytest -m primer -n auto -k test_primer_stdlib_no_crash
512+
513+
pytest-primer-external:
514+
name: Run primer tests on external libs Python ${{ matrix.python-version }} (Linux)
515+
runs-on: ubuntu-latest
516+
needs: prepare-tests-linux
517+
strategy:
518+
matrix:
519+
python-version: [3.8, 3.9, "3.10"]
520+
steps:
521+
- name: Check out code from GitHub
522+
uses: actions/[email protected]
523+
- name: Set up Python ${{ matrix.python-version }}
524+
id: python
525+
uses: actions/[email protected]
526+
with:
527+
python-version: ${{ matrix.python-version }}
528+
- name: Restore Python virtual environment
529+
id: cache-venv
530+
uses: actions/[email protected]
531+
with:
532+
path: venv
533+
key:
534+
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
535+
needs.prepare-tests-linux.outputs.python-key }}
536+
- name: Fail job if Python cache restore failed
537+
if: steps.cache-venv.outputs.cache-hit != 'true'
538+
run: |
539+
echo "Failed to restore Python venv from cache"
540+
exit 1
541+
- name: Run pytest
542+
run: |
543+
. venv/bin/activate
544+
pip install -e .
545+
pytest -m primer -n auto -k test_primer_external_packages_no_crash

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ build-stamp
2020
.pytest_cache/
2121
.mypy_cache/
2222
.benchmarks/
23+
.pylint_primer_tests/

pylint/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from pylint.__pkginfo__ import __version__
1010

11+
PY37_PLUS = sys.version_info[:2] >= (3, 7)
1112
PY38_PLUS = sys.version_info[:2] >= (3, 8)
1213
PY39_PLUS = sys.version_info[:2] >= (3, 9)
1314

pylint/testutils/primer.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import logging
2+
from pathlib import Path
3+
from typing import Dict, List, Optional, Union
4+
5+
import git
6+
7+
PRIMER_DIRECTORY_PATH = Path(".pylint_primer_tests")
8+
9+
10+
class PackageToLint:
11+
"""Represents data about a package to be tested during primer tests"""
12+
13+
url: str
14+
"""URL of the repository to clone"""
15+
16+
branch: str
17+
"""Branch of the repository to clone"""
18+
19+
directories: List[str]
20+
"""Directories within the repository to run pylint over"""
21+
22+
commit: Optional[str] = None
23+
"""Commit hash to pin the repository on"""
24+
25+
pylint_additional_args: List[str] = []
26+
"""Arguments to give to pylint"""
27+
28+
pylintrc_relpath: Optional[str] = None
29+
"""Path relative to project's main directory to the pylintrc if it exists"""
30+
31+
def __init__(
32+
self,
33+
url: str,
34+
branch: str,
35+
directories: List[str],
36+
commit: Optional[str] = None,
37+
pylint_additional_args: Optional[List[str]] = None,
38+
pylintrc_relpath: Optional[str] = None,
39+
):
40+
self.url = url
41+
self.branch = branch
42+
self.directories = directories
43+
self.commit = commit
44+
if pylint_additional_args is None:
45+
pylint_additional_args = []
46+
self.pylint_additional_args = pylint_additional_args
47+
self.pylintrc_relpath = pylintrc_relpath
48+
49+
@property
50+
def pylintrc(self) -> Optional[str]:
51+
if self.pylintrc_relpath is None:
52+
return None
53+
return f"{self.clone_directory}/{self.pylintrc_relpath}"
54+
55+
@property
56+
def clone_directory(self) -> Path:
57+
"""Directory to clone repository into"""
58+
clone_name = "/".join(self.url.split("/")[-2:]).replace(".git", "")
59+
return PRIMER_DIRECTORY_PATH / clone_name
60+
61+
@property
62+
def paths_to_lint(self) -> List[str]:
63+
"""The paths we need to lint"""
64+
return [str(self.clone_directory / path) for path in self.directories]
65+
66+
@property
67+
def pylint_args(self) -> List[str]:
68+
options: List[str] = []
69+
if self.pylintrc is not None:
70+
# There is an error if rcfile is given but does not exists
71+
options += [f"--rcfile={self.pylintrc}"]
72+
return self.paths_to_lint + options + self.pylint_additional_args
73+
74+
def lazy_clone(self) -> None:
75+
"""Concatenates the target directory and clones the file"""
76+
logging.info("Lazy cloning %s", self.url)
77+
if not self.clone_directory.exists():
78+
options: Dict[str, Union[str, int]] = {
79+
"url": self.url,
80+
"to_path": str(self.clone_directory),
81+
"branch": self.branch,
82+
"depth": 1,
83+
}
84+
logging.info("Directory does not exists, cloning: %s", options)
85+
git.Repo.clone_from(**options)
86+
return
87+
88+
remote_sha1_commit = (
89+
git.cmd.Git().ls_remote(self.url, self.branch).split("\t")[0]
90+
)
91+
local_sha1_commit = git.Repo(self.clone_directory).head.object.hexsha
92+
if remote_sha1_commit != local_sha1_commit:
93+
logging.info(
94+
"Remote sha is %s while local sha is %s : pulling",
95+
remote_sha1_commit,
96+
local_sha1_commit,
97+
)
98+
repo = git.Repo(self.clone_directory)
99+
origin = repo.remotes.origin
100+
origin.pull()
101+
else:
102+
logging.info("Already up to date.")

requirements_test_min.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
astroid==2.9.0 # Pinned to a specific version for tests
44
pytest~=6.2
55
pytest-benchmark~=3.4
6+
gitpython>3

setup.cfg

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,9 @@ test = pytest
6969
[tool:pytest]
7070
testpaths = tests
7171
python_files = *test_*.py
72-
addopts = -m "not primer_stdlib"
72+
addopts = --strict-markers -m "not primer"
7373
markers =
74-
primer_stdlib: Checks for crashes and errors when running pylint on stdlib
74+
primer: Checks for crashes and errors when running pylint on stdlib or on external libs
7575
benchmark: Baseline of pylint performance, if this regress something serious happened
7676
7777
[isort]
@@ -118,3 +118,6 @@ ignore_missing_imports = True
118118
119119
[mypy-toml.decoder]
120120
ignore_missing_imports = True
121+
122+
[mypy-git.*]
123+
ignore_missing_imports = True

tests/primer/packages_to_lint.json

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{
2+
"flask": {
3+
"url": "https://github.com/pallets/flask.git",
4+
"branch": "main",
5+
"directories": ["src/flask"]
6+
},
7+
"keras": {
8+
"url": "https://github.com/keras-team/keras.git",
9+
"branch": "master",
10+
"directories": ["keras"]
11+
},
12+
"sentry": {
13+
"url": "https://github.com/getsentry/sentry.git",
14+
"branch": "master",
15+
"directories": ["src/sentry"]
16+
},
17+
"django": {
18+
"url": "https://github.com/django/django.git",
19+
"branch": "main",
20+
"directories": ["django"]
21+
},
22+
"pandas": {
23+
"url": "https://github.com/pandas-dev/pandas.git",
24+
"branch": "master",
25+
"directories": ["pandas"],
26+
"pylint_additional_args": ["--ignore-patterns=\"test_"]
27+
},
28+
"black": {
29+
"url": "https://github.com/psf/black.git",
30+
"branch": "main",
31+
"directories": ["src/black/", "src/blackd/", "src/black_primer/", "src/blib2to3/"]
32+
},
33+
"home-assistant": {
34+
"url": "https://github.com/home-assistant/core.git",
35+
"branch": "dev",
36+
"directories": ["homeassistant"]
37+
},
38+
"graph-explorer": {
39+
"url": "https://github.com/vimeo/graph-explorer.git",
40+
"branch": "master",
41+
"directories": ["graph_explorer"],
42+
"pylintrc_relpath": ".pylintrc"
43+
},
44+
"pygame": {
45+
"url": "https://github.com/pygame/pygame.git",
46+
"branch": "main",
47+
"directories": ["src_py"]
48+
}
49+
}

tests/primer/test_primer_external.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
2+
# For details: https://github.com/PyCQA/pylint/blob/main/LICENSE
3+
import json
4+
import logging
5+
import subprocess
6+
from pathlib import Path
7+
from typing import Dict, Union
8+
9+
import pytest
10+
from pytest import LogCaptureFixture
11+
12+
from pylint.constants import PY37_PLUS
13+
from pylint.testutils.primer import PackageToLint
14+
15+
PRIMER_DIRECTORY = Path(".pylint_primer_tests/").resolve()
16+
17+
18+
def get_packages_to_lint_from_json(
19+
json_path: Union[Path, str]
20+
) -> Dict[str, PackageToLint]:
21+
result: Dict[str, PackageToLint] = {}
22+
with open(json_path, encoding="utf8") as f:
23+
for name, package_data in json.load(f).items():
24+
result[name] = PackageToLint(**package_data)
25+
return result
26+
27+
28+
PACKAGE_TO_LINT_JSON = Path(__file__).parent / "packages_to_lint.json"
29+
PACKAGES_TO_LINT = get_packages_to_lint_from_json(PACKAGE_TO_LINT_JSON)
30+
"""Dictionary of external packages used during the primer test"""
31+
32+
33+
class TestPrimer:
34+
@staticmethod
35+
@pytest.mark.primer
36+
@pytest.mark.parametrize("package", PACKAGES_TO_LINT.values(), ids=PACKAGES_TO_LINT)
37+
def test_primer_external_packages_no_crash(
38+
package: PackageToLint,
39+
caplog: LogCaptureFixture,
40+
) -> None:
41+
TestPrimer._primer_test(package, caplog)
42+
43+
@staticmethod
44+
def _primer_test(package: PackageToLint, caplog: LogCaptureFixture) -> None:
45+
"""Runs pylint over external packages to check for crashes and fatal messages
46+
47+
We only check for crashes (bit-encoded exit code 32) and fatal messages
48+
(bit-encoded exit code 1). We assume that these external repositories do not
49+
have any fatal errors in their code so that any fatal errors are pylint false
50+
positives
51+
"""
52+
caplog.set_level(logging.INFO)
53+
package.lazy_clone()
54+
55+
try:
56+
# We want to test all the code we can
57+
enables = ["--enable-all-extensions", "--enable=all"]
58+
# Duplicate code takes too long and is relatively safe
59+
disables = ["--disable=duplicate-code"]
60+
command = ["pylint"] + enables + disables + package.pylint_args
61+
logging.info("Launching primer:\n%s", " ".join(command))
62+
if PY37_PLUS:
63+
subprocess.run(command, check=True, capture_output=True)
64+
else:
65+
subprocess.run(command, check=True)
66+
except subprocess.CalledProcessError as ex:
67+
msg = f"Encountered {{}} during primer test for {package}"
68+
assert ex.returncode != 32, msg.format("a crash")
69+
assert ex.returncode % 2 == 0, msg.format("a message of category 'fatal'")

tests/primer/test_primer_stdlib.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,11 @@ def _patch_stdout(out):
3939
MODULES_NAMES = [m[1] for m in MODULES_TO_CHECK]
4040

4141

42-
@pytest.mark.primer_stdlib
42+
@pytest.mark.primer
4343
@pytest.mark.parametrize(
4444
("test_module_location", "test_module_name"), MODULES_TO_CHECK, ids=MODULES_NAMES
4545
)
46-
def test_lib_module_no_crash(
46+
def test_primer_stdlib_no_crash(
4747
test_module_location: str, test_module_name: str, capsys: CaptureFixture
4848
) -> None:
4949
"""Test that pylint does not produces any crashes or fatal errors on stdlib modules"""

0 commit comments

Comments
 (0)