Skip to content

Commit c62738b

Browse files
Create a framework of functional tests for configuration files (#5287)
* Migrate old unittest to the new framework for testing. * Add a regression test for #4746 : This permits to introduce an example of configuration file with an error. * Proper import for pytest import of CaptureFixture Co-authored-by: Daniël van Noord <[email protected]>
1 parent 165a2cb commit c62738b

22 files changed

+307
-101
lines changed

pylint/config/option_manager_mixin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ def read_config_file(self, config_file=None, verbose=None):
271271

272272
use_config_file = config_file and os.path.exists(config_file)
273273
if use_config_file:
274+
self.set_current_module(config_file)
274275
parser = self.cfgfile_parser
275276
if config_file.endswith(".toml"):
276277
self._parse_toml(config_file, parser)

pylint/config/option_parser.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,7 @@ def format_option_help(self, formatter=None):
2424
formatter = self.formatter
2525
outputlevel = getattr(formatter, "output_level", 0)
2626
formatter.store_option_strings(self)
27-
result = []
28-
result.append(formatter.format_heading("Options"))
27+
result = [formatter.format_heading("Options")]
2928
formatter.indent()
3029
if self.option_list:
3130
result.append(optparse.OptionContainer.format_option_help(self, formatter))
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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+
4+
"""Utility functions for configuration testing."""
5+
import copy
6+
import json
7+
import logging
8+
import unittest
9+
from pathlib import Path
10+
from typing import Any, Dict, Tuple, Union
11+
from unittest.mock import Mock
12+
13+
from pylint.lint import Run
14+
15+
USER_SPECIFIC_PATH = Path(__file__).parent.parent.parent
16+
# We use Any in this typing because the configuration contains real objects and constants
17+
# that could be a lot of things.
18+
ConfigurationValue = Any
19+
PylintConfiguration = Dict[str, ConfigurationValue]
20+
21+
22+
def get_expected_or_default(
23+
tested_configuration_file: str, suffix: str, default: ConfigurationValue
24+
) -> str:
25+
"""Return the expected value from the file if it exists, or the given default."""
26+
27+
def get_path_according_to_suffix() -> Path:
28+
path = Path(tested_configuration_file)
29+
return path.parent / f"{path.stem}.{suffix}"
30+
31+
expected = default
32+
expected_result_path = get_path_according_to_suffix()
33+
if expected_result_path.exists():
34+
with open(expected_result_path, encoding="utf8") as f:
35+
expected = f.read()
36+
# logging is helpful to realize your file is not taken into
37+
# account after a misspell of the file name. The output of the
38+
# program is checked during the test so printing messes with the result.
39+
logging.info("%s exists.", expected_result_path)
40+
else:
41+
logging.info("%s not found, using '%s'.", expected_result_path, default)
42+
return expected
43+
44+
45+
EXPECTED_CONF_APPEND_KEY = "functional_append"
46+
EXPECTED_CONF_REMOVE_KEY = "functional_remove"
47+
48+
49+
def get_expected_configuration(
50+
configuration_path: str, default_configuration: PylintConfiguration
51+
) -> PylintConfiguration:
52+
"""Get the expected parsed configuration of a configuration functional test"""
53+
result = copy.deepcopy(default_configuration)
54+
config_as_json = get_expected_or_default(
55+
configuration_path, suffix="result.json", default="{}"
56+
)
57+
to_override = json.loads(config_as_json)
58+
for key, value in to_override.items():
59+
if key == EXPECTED_CONF_APPEND_KEY:
60+
for fkey, fvalue in value.items():
61+
result[fkey] += fvalue
62+
elif key == EXPECTED_CONF_REMOVE_KEY:
63+
for fkey, fvalue in value.items():
64+
new_value = []
65+
for old_value in result[fkey]:
66+
if old_value not in fvalue:
67+
new_value.append(old_value)
68+
result[fkey] = new_value
69+
else:
70+
result[key] = value
71+
return result
72+
73+
74+
def get_expected_output(configuration_path: str) -> Tuple[int, str]:
75+
"""Get the expected output of a functional test."""
76+
output = get_expected_or_default(configuration_path, suffix="out", default="")
77+
if output:
78+
# logging is helpful to see what the expected exit code is and why.
79+
# The output of the program is checked during the test so printing
80+
# messes with the result.
81+
logging.info(
82+
"Output exists for %s so the expected exit code is 2", configuration_path
83+
)
84+
exit_code = 2
85+
else:
86+
logging.info(".out file does not exists, so the expected exit code is 0")
87+
exit_code = 0
88+
return exit_code, output.format(
89+
abspath=configuration_path,
90+
relpath=Path(configuration_path).relative_to(USER_SPECIFIC_PATH),
91+
)
92+
93+
94+
def run_using_a_configuration_file(
95+
configuration_path: Union[Path, str], file_to_lint: str = __file__
96+
) -> Tuple[Mock, Mock, Run]:
97+
"""Simulate a run with a configuration without really launching the checks."""
98+
configuration_path = str(configuration_path)
99+
args = ["--rcfile", configuration_path, file_to_lint]
100+
# We do not capture the `SystemExit` as then the `runner` variable
101+
# would not be accessible outside the `with` block.
102+
with unittest.mock.patch("sys.exit") as mocked_exit:
103+
# Do not actually run checks, that could be slow. We don't mock
104+
# `Pylinter.check`: it calls `Pylinter.initialize` which is
105+
# needed to properly set up messages inclusion/exclusion
106+
# in `_msg_states`, used by `is_message_enabled`.
107+
check = "pylint.lint.pylinter.check_parallel"
108+
with unittest.mock.patch(check) as mocked_check_parallel:
109+
runner = Run(args)
110+
return mocked_exit, mocked_check_parallel, runner

tests/config/conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
5+
HERE = Path(__file__).parent
6+
7+
8+
@pytest.fixture()
9+
def file_to_lint_path() -> str:
10+
return str(HERE / "file_to_lint.py")
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Check that we can read the "regular" INI .pylintrc file
2+
[messages control]
3+
disable = logging-not-lazy,logging-format-interpolation
4+
jobs = 10
5+
reports = yes
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"functional_append": {
3+
"disable": [["logging-not-lazy"], ["logging-format-interpolation"]]
4+
},
5+
"jobs": 10,
6+
"reports": true
7+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# setup.cfg is an INI file where section names are prefixed with "pylint."
2+
[pylint.messages control]
3+
disable = logging-not-lazy,logging-format-interpolation
4+
jobs = 10
5+
reports = yes
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"functional_append": {
3+
"disable": [["logging-not-lazy"], ["logging-format-interpolation"]]
4+
},
5+
"jobs": 10,
6+
"reports": true
7+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
************* Module {abspath}
2+
{relpath}:1:0: E0013: Plugin 'pylint_websockets' is impossible to load, is it installed ? ('No module named 'pylint_websockets'') (bad-plugin-value)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"load_plugins": ["pylint_websockets"]
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# The pylint_websockets plugin does not exist and therefore this toml is invalid
2+
[tool.pylint.MASTER]
3+
load-plugins = 'pylint_websockets'
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"functional_append": {
3+
"disable": [["logging-not-lazy"], ["logging-format-interpolation"]]
4+
},
5+
"jobs": 10,
6+
"reports": true
7+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Check that we can read a TOML file where lists, integers and
2+
# booleans are expressed as such (and not as strings), using TOML
3+
# type system.
4+
5+
[tool.pylint."messages control"]
6+
disable = [
7+
"logging-not-lazy",
8+
"logging-format-interpolation",
9+
]
10+
jobs = 10
11+
reports = true
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"functional_append": {
3+
"disable": [["logging-not-lazy"], ["logging-format-interpolation"]],
4+
"enable": [["suppressed-message"], ["locally-disabled"]]
5+
},
6+
"functional_remove": {
7+
"disable": [["suppressed-message"], ["locally-disabled"]]
8+
}
9+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Check that we can add or remove value in list
2+
# (This is mostly a check for the functional test themselves)
3+
[tool.pylint."messages control"]
4+
disable = "logging-not-lazy,logging-format-interpolation"
5+
enable = "locally-disabled,suppressed-message"
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"functional_append": {
3+
"disable": [["logging-not-lazy"], ["logging-format-interpolation"]]
4+
},
5+
"jobs": 10,
6+
"reports": true
7+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Check that we can read a TOML file where lists and integers are
2+
# expressed as strings.
3+
4+
[tool.pylint."messages control"]
5+
disable = "logging-not-lazy,logging-format-interpolation"
6+
jobs = "10"
7+
reports = "yes"

tests/config/test_config.py

Lines changed: 8 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,9 @@
1-
# pylint: disable=missing-module-docstring, missing-function-docstring, protected-access
21
import os
3-
import unittest.mock
42
from pathlib import Path
5-
from typing import Optional, Set, Union
3+
from typing import Optional, Set
64

7-
import pylint.lint
85
from pylint.lint.run import Run
9-
10-
# We use an external file and not __file__ or pylint warning in this file
11-
# makes the tests fails because the exit code changes
12-
FILE_TO_LINT = str(Path(__file__).parent / "file_to_lint.py")
13-
14-
15-
def get_runner_from_config_file(
16-
config_file: Union[str, Path], expected_exit_code: int = 0
17-
) -> Run:
18-
"""Initialize pylint with the given configuration file and return the Run"""
19-
args = ["--rcfile", str(config_file), FILE_TO_LINT]
20-
# If we used `pytest.raises(SystemExit)`, the `runner` variable
21-
# would not be accessible outside the `with` block.
22-
with unittest.mock.patch("sys.exit") as mocked_exit:
23-
# Do not actually run checks, that could be slow. Do not mock
24-
# `Pylinter.check`: it calls `Pylinter.initialize` which is
25-
# needed to properly set up messages inclusion/exclusion
26-
# in `_msg_states`, used by `is_message_enabled`.
27-
with unittest.mock.patch("pylint.lint.pylinter.check_parallel"):
28-
runner = pylint.lint.Run(args)
29-
mocked_exit.assert_called_once_with(expected_exit_code)
30-
return runner
6+
from pylint.testutils.configuration_test import run_using_a_configuration_file
317

328

339
def check_configuration_file_reader(
@@ -46,74 +22,7 @@ def check_configuration_file_reader(
4622
assert bool(runner.linter.config.reports) == expected_reports_truthey
4723

4824

49-
def test_can_read_ini(tmp_path: Path) -> None:
50-
# Check that we can read the "regular" INI .pylintrc file
51-
config_file = tmp_path / ".pylintrc"
52-
config_file.write_text(
53-
"""
54-
[messages control]
55-
disable = logging-not-lazy,logging-format-interpolation
56-
jobs = 10
57-
reports = yes
58-
"""
59-
)
60-
run = get_runner_from_config_file(config_file)
61-
check_configuration_file_reader(run)
62-
63-
64-
def test_can_read_setup_cfg(tmp_path: Path) -> None:
65-
# Check that we can read a setup.cfg (which is an INI file where
66-
# section names are prefixed with "pylint."
67-
config_file = tmp_path / "setup.cfg"
68-
config_file.write_text(
69-
"""
70-
[pylint.messages control]
71-
disable = logging-not-lazy,logging-format-interpolation
72-
jobs = 10
73-
reports = yes
74-
"""
75-
)
76-
run = get_runner_from_config_file(config_file)
77-
check_configuration_file_reader(run)
78-
79-
80-
def test_can_read_toml(tmp_path: Path) -> None:
81-
# Check that we can read a TOML file where lists and integers are
82-
# expressed as strings.
83-
config_file = tmp_path / "pyproject.toml"
84-
config_file.write_text(
85-
"""
86-
[tool.pylint."messages control"]
87-
disable = "logging-not-lazy,logging-format-interpolation"
88-
jobs = "10"
89-
reports = "yes"
90-
"""
91-
)
92-
run = get_runner_from_config_file(config_file)
93-
check_configuration_file_reader(run)
94-
95-
96-
def test_can_read_toml_rich_types(tmp_path: Path) -> None:
97-
# Check that we can read a TOML file where lists, integers and
98-
# booleans are expressed as such (and not as strings), using TOML
99-
# type system.
100-
config_file = tmp_path / "pyproject.toml"
101-
config_file.write_text(
102-
"""
103-
[tool.pylint."messages control"]
104-
disable = [
105-
"logging-not-lazy",
106-
"logging-format-interpolation",
107-
]
108-
jobs = 10
109-
reports = true
110-
"""
111-
)
112-
run = get_runner_from_config_file(config_file)
113-
check_configuration_file_reader(run)
114-
115-
116-
def test_can_read_toml_env_variable(tmp_path: Path) -> None:
25+
def test_can_read_toml_env_variable(tmp_path: Path, file_to_lint_path: str) -> None:
11726
"""We can read and open a properly formatted toml file."""
11827
config_file = tmp_path / "pyproject.toml"
11928
config_file.write_text(
@@ -126,5 +35,8 @@ def test_can_read_toml_env_variable(tmp_path: Path) -> None:
12635
)
12736
env_var = "tmp_path_env"
12837
os.environ[env_var] = str(config_file)
129-
run = get_runner_from_config_file(f"${env_var}")
130-
check_configuration_file_reader(run)
38+
mock_exit, _, runner = run_using_a_configuration_file(
39+
f"${env_var}", file_to_lint_path
40+
)
41+
mock_exit.assert_called_once_with(0)
42+
check_configuration_file_reader(runner)

0 commit comments

Comments
 (0)