diff --git a/.pyenchant_pylint_custom_dict.txt b/.pyenchant_pylint_custom_dict.txt index fd4fed00c3..6f5c2815e1 100644 --- a/.pyenchant_pylint_custom_dict.txt +++ b/.pyenchant_pylint_custom_dict.txt @@ -61,6 +61,7 @@ codecs col's conf config +configs const Const contextlib @@ -310,11 +311,13 @@ str stringified subclasses subcommands +subconfigs subdicts subgraphs sublists submodule submodules +subpackage subparsers subparts subprocess diff --git a/doc/user_guide/configuration/all-options.rst b/doc/user_guide/configuration/all-options.rst index 94d2c1775e..6364ff99f2 100644 --- a/doc/user_guide/configuration/all-options.rst +++ b/doc/user_guide/configuration/all-options.rst @@ -216,6 +216,13 @@ Standard Checkers **Default:** ``False`` +--use-local-configs +""""""""""""""""""" +*When some of the modules to be linted have a pylint config in their directory or any of their parent directories, all checkers use this local config to check those modules. If present, local config replaces entirely a config from current working directory (cwd). Modules that don't have local pylint config are still checked using config from cwd. When pylint starts, it always loads base config from the cwd first. Some options in base config can prevent local configs from loading (e.g. disable=all). Some options for Main checker will work only in base config: evaluation, exit_zero, fail_under, from_stdin, jobs, persistent, recursive, reports, score.* + +**Default:** ``False`` + + .. raw:: html @@ -285,6 +292,8 @@ Standard Checkers unsafe-load-any-extension = false + use-local-configs = false + .. raw:: html diff --git a/doc/whatsnew/fragments/618.feature b/doc/whatsnew/fragments/618.feature new file mode 100644 index 0000000000..db1c55ce1f --- /dev/null +++ b/doc/whatsnew/fragments/618.feature @@ -0,0 +1,13 @@ +Add new command line option: use-local-configs. + +use-local-configs enables loading of local pylint configurations in addition to the base pylint config from $PWD. Local configurations are searched in the same directories where linted files are located and upwards until $PWD or root. +For example: +if there exists package/pylintrc, then +pylint --use-local-configs=y package/file.py +will use package/pylintrc instead of default config from $PWD. + +if there exists package/pylintrc, and doesn't exist package/subpackage/pylintrc, then +pylint --use-local-configs=y package/subpackage/file.py +will use package/pylintrc instead of default config from $PWD. + +Closes #618 diff --git a/pylint/config/arguments_manager.py b/pylint/config/arguments_manager.py index b99c9476ff..c5d2bbc70b 100644 --- a/pylint/config/arguments_manager.py +++ b/pylint/config/arguments_manager.py @@ -81,6 +81,9 @@ def __init__( self._directory_namespaces: DirectoryNamespaceDict = {} """Mapping of directories and their respective namespace objects.""" + self._cli_args: list[str] = [] + """Options that were passed as command line arguments and have highest priority.""" + @property def config(self) -> argparse.Namespace: """Namespace for all options.""" @@ -226,6 +229,8 @@ def _parse_command_line_configuration( ) -> list[str]: """Parse the arguments found on the command line into the namespace.""" arguments = sys.argv[1:] if arguments is None else arguments + if not self._cli_args: + self._cli_args = list(arguments) self.config, parsed_args = self._arg_parser.parse_known_args( arguments, self.config diff --git a/pylint/config/config_file_parser.py b/pylint/config/config_file_parser.py index efc085e590..a992b08e05 100644 --- a/pylint/config/config_file_parser.py +++ b/pylint/config/config_file_parser.py @@ -106,7 +106,7 @@ def parse_config_file( raise OSError(f"The config file {file_path} doesn't exist!") if verbose: - print(f"Using config file {file_path}", file=sys.stderr) + print(f"Loading config file {file_path}", file=sys.stderr) if file_path.suffix == ".toml": return _RawConfParser.parse_toml_file(file_path) diff --git a/pylint/config/config_initialization.py b/pylint/config/config_initialization.py index 6fa7b6b895..5af6d6b6bb 100644 --- a/pylint/config/config_initialization.py +++ b/pylint/config/config_initialization.py @@ -23,6 +23,7 @@ from pylint.lint import PyLinter +# pylint: disable = too-many-statements def _config_initialization( linter: PyLinter, args_list: list[str], @@ -82,6 +83,9 @@ def _config_initialization( args_list = _order_all_first(args_list, joined=True) parsed_args_list = linter._parse_command_line_configuration(args_list) + # save Runner.verbose to make this preprocessed option visible from other modules + linter.config.verbose = verbose_mode + # Remove the positional arguments separator from the list of arguments if it exists try: parsed_args_list.remove("--") @@ -141,7 +145,8 @@ def _config_initialization( linter._parse_error_mode() # Link the base Namespace object on the current directory - linter._directory_namespaces[Path(".").resolve()] = (linter.config, {}) + if Path(".").resolve() not in linter._directory_namespaces: + linter._directory_namespaces[Path(".").resolve()] = (linter.config, {}) # parsed_args_list should now only be a list of inputs to lint. # All other options have been removed from the list. diff --git a/pylint/config/find_default_config_files.py b/pylint/config/find_default_config_files.py index 346393cf9a..3ba3961e2f 100644 --- a/pylint/config/find_default_config_files.py +++ b/pylint/config/find_default_config_files.py @@ -64,17 +64,19 @@ def _cfg_has_config(path: Path | str) -> bool: return any(section.startswith("pylint.") for section in parser.sections()) -def _yield_default_files() -> Iterator[Path]: +def _yield_default_files(basedir: Path | str = ".") -> Iterator[Path]: """Iterate over the default config file names and see if they exist.""" + basedir = Path(basedir) for config_name in CONFIG_NAMES: + config_file = basedir / config_name try: - if config_name.is_file(): - if config_name.suffix == ".toml" and not _toml_has_config(config_name): + if config_file.is_file(): + if config_file.suffix == ".toml" and not _toml_has_config(config_file): continue - if config_name.suffix == ".cfg" and not _cfg_has_config(config_name): + if config_file.suffix == ".cfg" and not _cfg_has_config(config_file): continue - yield config_name.resolve() + yield config_file.resolve() except OSError: pass @@ -142,3 +144,8 @@ def find_default_config_files() -> Iterator[Path]: yield Path("/etc/pylintrc").resolve() except OSError: pass + + +def find_subdirectory_config_files(basedir: Path | str) -> Iterator[Path]: + """Find config file in arbitrary subdirectory.""" + yield from _yield_default_files(basedir) diff --git a/pylint/lint/base_options.py b/pylint/lint/base_options.py index 3d5ba5d0db..6dd5d7dd47 100644 --- a/pylint/lint/base_options.py +++ b/pylint/lint/base_options.py @@ -414,6 +414,23 @@ def _make_linter_options(linter: PyLinter) -> Options: "Useful if running pylint in a server-like mode.", }, ), + ( + "use-local-configs", + { + "default": False, + "type": "yn", + "metavar": "", + "help": "When some of the modules to be linted have a pylint config in their directory " + "or any of their parent directories, all checkers use this local config to check " + "those modules. " + "If present, local config replaces entirely a config from current working directory (cwd). " + "Modules that don't have local pylint config are still checked using config from cwd. " + "When pylint starts, it always loads base config from the cwd first. Some options in " + "base config can prevent local configs from loading (e.g. disable=all). " + "Some options for Main checker will work only in base config: " + "evaluation, exit_zero, fail_under, from_stdin, jobs, persistent, recursive, reports, score.", + }, + ), ) diff --git a/pylint/lint/pylinter.py b/pylint/lint/pylinter.py index 30250154e6..13e8500ae6 100644 --- a/pylint/lint/pylinter.py +++ b/pylint/lint/pylinter.py @@ -7,6 +7,7 @@ import argparse import collections import contextlib +import copy import functools import os import sys @@ -26,6 +27,8 @@ from pylint import checkers, exceptions, interfaces, reporters from pylint.checkers.base_checker import BaseChecker from pylint.config.arguments_manager import _ArgumentsManager +from pylint.config.config_initialization import _config_initialization +from pylint.config.find_default_config_files import find_subdirectory_config_files from pylint.constants import ( MAIN_CHECKER_NAME, MSG_TYPES, @@ -66,7 +69,7 @@ ModuleDescriptionDict, Options, ) -from pylint.utils import ASTWalker, FileState, LinterStats, utils +from pylint.utils import ASTWalker, FileState, LinterStats, merge_stats, utils MANAGER = astroid.MANAGER @@ -317,6 +320,7 @@ def __init__( # Attributes related to stats self.stats = LinterStats() + self.all_stats: list[LinterStats] = [] # Attributes related to (command-line) options and their parsing self.options: Options = options + _make_linter_options(self) @@ -615,6 +619,46 @@ def initialize(self) -> None: if not msg.may_be_emitted(self.config.py_version): self._msgs_state[msg.msgid] = False + def register_local_config(self, file_or_dir: str) -> None: + if os.path.isdir(file_or_dir): + basedir = Path(file_or_dir) + else: + basedir = Path(os.path.dirname(file_or_dir)) + + if _is_relative_to(basedir, Path(os.getcwd())): + scan_root_dir = Path(os.getcwd()) + else: + scan_root_dir = Path(basedir.parts[0]) + + while basedir.resolve() not in self._directory_namespaces and _is_relative_to( + basedir, scan_root_dir + ): + local_conf = next(find_subdirectory_config_files(basedir), None) + if local_conf is not None: + # in order to avoid creating new PyLinter objects, _config_initialization modifies + # existing self.config, so we need to save original self.config to restore it later + original_config_ref = self.config + self.config = copy.deepcopy(self.config) + _config_initialization( + self, + self._cli_args, + reporter=self.reporter, + config_file=local_conf, + verbose_mode=self.config.verbose, + ) + self._directory_namespaces[basedir.resolve()] = (self.config, {}) + # keep dict keys reverse-sorted so that + # iteration over keys in _get_namespace_for_file gets the most nested path first + self._directory_namespaces = dict( + sorted(self._directory_namespaces.items(), reverse=True) + ) + self.config = original_config_ref + break + if basedir.parent != basedir: + basedir = basedir.parent + else: + break + def _discover_files(self, files_or_modules: Sequence[str]) -> Iterator[str]: """Discover python modules and packages in sub-directory. @@ -665,12 +709,12 @@ def check(self, files_or_modules: Sequence[str]) -> None: "Missing filename required for --from-stdin" ) - extra_packages_paths = list( - { + extra_packages_paths_set = set() + for file_or_module in files_or_modules: + extra_packages_paths_set.add( discover_package_path(file_or_module, self.config.source_roots) - for file_or_module in files_or_modules - } - ) + ) + extra_packages_paths = list(extra_packages_paths_set) # TODO: Move the parallel invocation into step 3 of the checking process if not self.config.from_stdin and self.config.jobs > 1: @@ -693,14 +737,16 @@ def check(self, files_or_modules: Sequence[str]) -> None: fileitems = self._iterate_file_descrs(files_or_modules) data = None - # The contextmanager also opens all checkers and sets up the PyLinter class with augmented_sys_path(extra_packages_paths): - with self._astroid_module_checker() as check_astroid_module: - # 2) Get the AST for each FileItem - ast_per_fileitem = self._get_asts(fileitems, data) - - # 3) Lint each ast - self._lint_files(ast_per_fileitem, check_astroid_module) + # 2) Get the AST for each FileItem + ast_per_fileitem = self._get_asts(fileitems, data) + # 3) Lint each ast + if self.config.use_local_configs is False: + # The contextmanager also opens all checkers and sets up the PyLinter class + with self._astroid_module_checker() as check_astroid_module: + self._lint_files(ast_per_fileitem, check_astroid_module) + else: + self._lint_files(ast_per_fileitem, None) def _get_asts( self, fileitems: Iterator[FileItem], data: str | None @@ -710,6 +756,7 @@ def _get_asts( for fileitem in fileitems: self.set_current_module(fileitem.name, fileitem.filepath) + self._set_astroid_options() try: ast_per_fileitem[fileitem] = self.get_ast( @@ -735,13 +782,14 @@ def check_single_file_item(self, file: FileItem) -> None: initialize() should be called before calling this method """ + self.set_current_module(file.name, file.filepath) with self._astroid_module_checker() as check_astroid_module: self._check_file(self.get_ast, check_astroid_module, file) def _lint_files( self, ast_mapping: dict[FileItem, nodes.Module | None], - check_astroid_module: Callable[[nodes.Module], bool | None], + check_astroid_module: Callable[[nodes.Module], bool | None] | None, ) -> None: """Lint all AST modules from a mapping..""" for fileitem, module in ast_mapping.items(): @@ -760,12 +808,17 @@ def _lint_files( ) else: self.add_message("fatal", args=msg, confidence=HIGH) + # current self.stats is needed in merge - it contains stats from last module + finished_run_stats = merge_stats([*self.all_stats, self.stats]) + # after _lint_files linter.stats is aggregate stats from all modules, like after check_parallel + self.all_stats = [] + self.stats = finished_run_stats def _lint_file( self, file: FileItem, module: nodes.Module, - check_astroid_module: Callable[[nodes.Module], bool | None], + check_astroid_module: Callable[[nodes.Module], bool | None] | None, ) -> None: """Lint a file using the passed utility function check_astroid_module). @@ -784,7 +837,13 @@ def _lint_file( self.current_file = module.file try: - check_astroid_module(module) + # call _astroid_module_checker after set_current_module, when + # self.config is the right config for current module + if check_astroid_module is None: + with self._astroid_module_checker() as local_check_astroid_module: + local_check_astroid_module(module) + else: + check_astroid_module(module) except Exception as e: raise astroid.AstroidError from e @@ -898,33 +957,45 @@ def _expand_files( def set_current_module(self, modname: str, filepath: str | None = None) -> None: """Set the name of the currently analyzed module and init statistics for it. + + Save current stats before init to make sure no counters for + error, statement, etc are missed. """ if not modname and filepath is None: return self.reporter.on_set_current_module(modname or "", filepath) self.current_name = modname self.current_file = filepath or modname + self.all_stats.append(self.stats) + self.stats = LinterStats() self.stats.init_single_module(modname or "") # If there is an actual filepath we might need to update the config attribute - if filepath: - namespace = self._get_namespace_for_file( + if filepath and self.config.use_local_configs: + self.register_local_config(filepath) + config_path, namespace = self._get_namespace_for_file( Path(filepath), self._directory_namespaces ) if namespace: - self.config = namespace or self._base_config + self.config = namespace + if self.config.verbose: + print( + f"Using config from {config_path} for {filepath}", + file=sys.stderr, + ) def _get_namespace_for_file( self, filepath: Path, namespaces: DirectoryNamespaceDict - ) -> argparse.Namespace | None: + ) -> tuple[Path | None, argparse.Namespace | None]: + filepath = filepath.resolve() for directory in namespaces: if _is_relative_to(filepath, directory): - namespace = self._get_namespace_for_file( + _, namespace = self._get_namespace_for_file( filepath, namespaces[directory][1] ) if namespace is None: - return namespaces[directory][0] - return None + return directory, namespaces[directory][0] + return None, None @contextlib.contextmanager def _astroid_module_checker( @@ -953,7 +1024,7 @@ def _astroid_module_checker( rawcheckers=rawcheckers, ) - # notify global end + # notify end of module if jobs>1 or use-local-configs=y, global end otherwise self.stats.statement = walker.nbstatements for checker in reversed(_checkers): checker.close() @@ -1068,8 +1139,8 @@ def _check_astroid_module( walker.walk(node) return True - def open(self) -> None: - """Initialize counters.""" + def _set_astroid_options(self) -> None: + """Pass some config values to astroid.MANAGER object.""" MANAGER.always_load_extensions = self.config.unsafe_load_any_extension MANAGER.max_inferable_values = self.config.limit_inference_results MANAGER.extension_package_whitelist.update(self.config.extension_pkg_allow_list) @@ -1077,13 +1148,17 @@ def open(self) -> None: MANAGER.extension_package_whitelist.update( self.config.extension_pkg_whitelist ) - self.stats.reset_message_count() + + def open(self) -> None: + """Initialize self as main checker for one or more modules.""" + self._set_astroid_options() def generate_reports(self, verbose: bool = False) -> int | None: """Close the whole package /module, it's time to make reports ! if persistent run, pickle results for later comparison """ + self.config = self._base_config # Display whatever messages are left on the reporter. self.reporter.display_messages(report_nodes.Section()) if not self.file_state._is_base_filestate: diff --git a/requirements_test.txt b/requirements_test.txt index 9ca30a61b1..e229d1561f 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -9,3 +9,4 @@ six # Type packages for mypy types-pkg_resources==0.1.3 tox>=3 +pre-commit diff --git a/tests/config/test_config.py b/tests/config/test_config.py index 872b568a61..4400d05c06 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -178,7 +178,7 @@ def test_short_verbose(capsys: CaptureFixture) -> None: """Check that we correctly handle the -v flag.""" Run([str(EMPTY_MODULE), "-v"], exit=False) output = capsys.readouterr() - assert "Using config file" in output.err + assert "Loading config file" in output.err def test_argument_separator() -> None: diff --git a/tests/config/test_per_directory_config.py b/tests/config/test_per_directory_config.py index b888dd405e..0ae75ad2c8 100644 --- a/tests/config/test_per_directory_config.py +++ b/tests/config/test_per_directory_config.py @@ -2,9 +2,23 @@ # For details: https://github.com/pylint-dev/pylint/blob/main/LICENSE # Copyright (c) https://github.com/pylint-dev/pylint/blob/main/CONTRIBUTORS.txt +from __future__ import annotations + +import os +import os.path +from argparse import Namespace +from io import StringIO from pathlib import Path +from typing import Any +from unittest.mock import patch + +import pytest +from pytest import CaptureFixture +from pylint.lint import Run as LintRun +from pylint.lint.pylinter import PyLinter from pylint.testutils._run import _Run as Run +from pylint.testutils.utils import _patch_streams, _test_cwd def test_fall_back_on_base_config(tmp_path: Path) -> None: @@ -21,3 +35,273 @@ def test_fall_back_on_base_config(tmp_path: Path) -> None: f.write("1") Run([str(test_file)], exit=False) assert id(runner.linter.config) == id(runner.linter._base_config) + + +@pytest.fixture +def _create_subconfig_test_fs(tmp_path: Path) -> tuple[Path, ...]: + level1_dir = tmp_path / "level1_dir" + level1_init = level1_dir / "__init__.py" + conf_file1 = level1_dir / "pylintrc" + test_file1 = level1_dir / "a.py" + test_file3 = level1_dir / "z.py" + level1_dir_without_config = tmp_path / "level1_dir_without_config" + level1_init2 = level1_dir_without_config / "__init__.py" + test_file4 = level1_dir_without_config / "aa.py" + subdir = level1_dir / "sub" + level2_init = subdir / "__init__.py" + conf_file2 = subdir / "pylintrc" + test_file2 = subdir / "b.py" + os.makedirs(level1_dir_without_config) + os.makedirs(subdir) + level1_init.touch() + level1_init2.touch() + level2_init.touch() + test_file_text = "#LEVEL1\n#LEVEL2\n#ALL_LEVELS\n#TODO\nassert (1, None)\ns = 'statement without warnings'\n" + test_file1.write_text(test_file_text) + test_file2.write_text(test_file_text) + test_file3.write_text(test_file_text) + test_file4.write_text(test_file_text) + conf1 = "[MISCELLANEOUS]\nnotes=LEVEL1,ALL_LEVELS" + conf2 = "[MISCELLANEOUS]\nnotes=LEVEL2,ALL_LEVELS" + conf_file1.write_text(conf1) + conf_file2.write_text(conf2) + return level1_dir, test_file1, test_file2, test_file3, test_file4 + + +@pytest.mark.parametrize( + "local_config_args", + [["--use-local-configs=y"], ["--use-local-configs=y", "--jobs=2"]], +) +# check modules and use of configuration files from top-level package or subpackage +@pytest.mark.parametrize("test_file_index", [0, 1, 2]) +# check cases when cwd contains pylintrc or not +@pytest.mark.parametrize( + "start_dir_modificator", [".", "..", "../level1_dir_without_config"] +) +def test_subconfig_vs_root_config( + _create_subconfig_test_fs: tuple[Path, ...], + test_file_index: int, + local_config_args: list[str], + start_dir_modificator: str, +) -> None: + """Test that each checked file or module uses config + from its own directory. + """ + level1_dir, *tmp_files = _create_subconfig_test_fs + test_file = tmp_files[test_file_index] + start_dir = (level1_dir / start_dir_modificator).resolve() + + output = [f"{start_dir = }"] + with _test_cwd(start_dir): + for _ in range(2): + out = StringIO() + with _patch_streams(out): + # _Run adds --rcfile, which overrides config from cwd, so we need original Run here + LintRun([*local_config_args, str(test_file)], exit=False) + current_file_output = f"{test_file = }\n" + out.getvalue() + output.append(current_file_output) + test_file = test_file.parent + + expected_note = "LEVEL1" + if test_file_index == 1: + expected_note = "LEVEL2" + assert_message = ( + "local pylintrc was not used for checking FILE. " + f"Readable debug output:\n{output[0]}\n{output[1]}" + ) + assert expected_note in output[1], assert_message + assert_message = ( + "local pylintrc was not used for checking DIRECTORY. " + f"Readable debug output:\n{output[0]}\n{output[2]}" + ) + assert expected_note in output[2], assert_message + + if test_file_index == 0: + # 'pylint level1_dir/' should use config from subpackage when checking level1_dir/sub/b.py + assert_message = ( + "local pylintrc was not used for checking DIRECTORY. " + f"Readable debug output:\n{output[0]}\n{output[2]}" + ) + assert "LEVEL2" in output[2], assert_message + if test_file_index == 1: + # 'pylint level1_dir/sub/b.py' and 'pylint level1_dir/sub/' should use + # level1_dir/sub/pylintrc, not level1_dir/pylintrc + assert_message = ( + "parent config was used instead of local for checking FILE. " + f"Readable debug output:\n{output[0]}\n{output[1]}" + ) + assert "LEVEL1" not in output[1], assert_message + assert_message = ( + "parent config was used instead of local for checking DIRECTORY. " + f"Readable debug output:\n{output[0]}\n{output[2]}" + ) + assert "LEVEL1" not in output[2], assert_message + + +@pytest.mark.parametrize( + "local_config_args", + [["--use-local-configs=y"], ["--use-local-configs=y", "--jobs=2"]], +) +# check cases when test_file without local config belongs to cwd subtree or not +@pytest.mark.parametrize( + "start_dir_modificator", [".", "..", "../level1_dir_without_config"] +) +def test_missing_local_config( + _create_subconfig_test_fs: tuple[Path, ...], + local_config_args: list[str], + start_dir_modificator: str, +) -> None: + """Test that when checked file or module doesn't have config + in its own directory, it uses default config or config from cwd. + """ + level1_dir, *tmp_files = _create_subconfig_test_fs + # file from level1_dir_without_config + test_file = tmp_files[3] + start_dir = (level1_dir / start_dir_modificator).resolve() + + output = [f"{start_dir = }"] + with _test_cwd(start_dir): + for _ in range(2): + out = StringIO() + with _patch_streams(out): + # _Run adds --rcfile, which overrides config from cwd, so we need original Run here + LintRun([*local_config_args, str(test_file)], exit=False) + current_file_output = f"{test_file = }\n" + out.getvalue() + output.append(current_file_output) + test_file = test_file.parent + + # from default config + expected_note = "TODO" + if start_dir_modificator == ".": + # from config in level1_dir + expected_note = "LEVEL1" + assert_message = ( + "wrong config was used for checking FILE. " + f"Readable debug output:\n{output[0]}\n{output[1]}" + ) + assert expected_note in output[1], assert_message + assert_message = ( + "wrong config was used for checking DIRECTORY. " + f"Readable debug output:\n{output[0]}\n{output[2]}" + ) + assert expected_note in output[2], assert_message + + +@pytest.mark.parametrize("test_file_index", [0, 1]) +def test_subconfig_vs_cli_arg( + _create_subconfig_test_fs: tuple[Path, ...], + capsys: CaptureFixture, + test_file_index: int, +) -> None: + """Test that CLI arguments have priority over subconfigs.""" + test_root, *tmp_files = _create_subconfig_test_fs + test_file = tmp_files[test_file_index] + with _test_cwd(test_root): + LintRun(["--notes=FIXME", "--use-local-configs=y", str(test_file)], exit=False) + output = capsys.readouterr().out.replace("\\n", "\n") + + # check that CLI argument overrides default value + assert "TODO" not in output + # notes=FIXME in arguments should override all pylintrc configs + assert "ALL_LEVELS" not in output + + +def _create_parent_subconfig_fs(tmp_path: Path) -> Path: + level1_dir = tmp_path / "package" + conf_file = level1_dir / "pylintrc" + subdir = level1_dir / "sub" + test_file = subdir / "b.py" + os.makedirs(subdir) + test_file_text = "#LEVEL1\n#LEVEL2\n#TODO\n" + test_file.write_text(test_file_text) + conf = "[MISCELLANEOUS]\nnotes=LEVEL1,LEVEL2" + conf_file.write_text(conf) + return test_file + + +def test_subconfig_in_parent(tmp_path: Path, capsys: CaptureFixture) -> None: + """Test that searching local configs in parent directories works.""" + test_file = _create_parent_subconfig_fs(tmp_path) + with _test_cwd(tmp_path): + LintRun(["--use-local-configs=y", str(test_file)], exit=False) + output1 = capsys.readouterr().out.replace("\\n", "\n") + + # check that file is linted with config from ../, which is not a cwd + assert "TODO" not in output1 + assert "LEVEL1" in output1 + + +def test_register_local_config_accepts_directory( + _create_subconfig_test_fs: tuple[Path, ...] +) -> None: + """Test that register_local_config can handle directory as argument.""" + level1_dir, *tmp_files = _create_subconfig_test_fs + # init linter without local configs + linter = LintRun([str(tmp_files[0])], exit=False).linter + assert level1_dir not in linter._directory_namespaces + + # call register_local_config with directory as argument + assert level1_dir.is_dir() + linter.register_local_config(str(level1_dir)) + assert level1_dir in linter._directory_namespaces.keys() + + +def test_local_config_verbose( + _create_subconfig_test_fs: tuple[Path, ...], capsys: CaptureFixture +) -> None: + """Check --verbose flag prints message about current config for each file.""" + level1_dir, *tmp_files = _create_subconfig_test_fs + LintRun(["--verbose", "--use-local-configs=y", str(tmp_files[1])], exit=False) + output = capsys.readouterr() + assert f"Using config from {level1_dir / 'sub'}" in output.err + + +def ns_diff(ns1: Namespace, ns2: Namespace) -> str: + msg = "Namespaces not equal\n" + for k, v in ns1.__dict__.items(): + if v != ns2.__dict__[k]: + msg += f"{v} != {ns2.__dict__[k]}\n" + return msg + + +generate_reports_orig = PyLinter.generate_reports + + +def generate_reports_spy(self: PyLinter, *args: Any, **kwargs: Any) -> int: + score = generate_reports_orig(self, *args, **kwargs) + # check that generate_reports() worked with base config, not config from most recent module + assert self.config == self._base_config, ns_diff(self.config, self._base_config) + # level1_dir.a, level1_dir.z, level1_dir.sub.b from _create_subconfig_test_fs + # each has 2 statements, one of which is warning => score should be 5 + assert score is not None + assert 0 < score < 10 + return score + + +@pytest.mark.parametrize( + "local_config_args", + [["--use-local-configs=y"], ["--use-local-configs=y", "--jobs=2"]], +) +def test_subconfigs_score( + _create_subconfig_test_fs: tuple[Path, ...], + local_config_args: list[str], +) -> None: + """Check that statements from all checked modules are accounted in score: + given stats from many modules such that + total # of messages > statements in last module, + check that score is >0 and <10. + """ + level1_dir, *_ = _create_subconfig_test_fs + out = StringIO() + with patch( + "pylint.lint.run.Run.LinterClass.generate_reports", + side_effect=generate_reports_spy, + autospec=True, + ) as reports_patch, _patch_streams(out): + linter = LintRun([*local_config_args, str(level1_dir)], exit=False).linter + reports_patch.assert_called_once() + + # level1_dir.a, level1_dir.z, level1_dir.sub.b from _create_subconfig_test_fs + # each has 2 statements, one of which is warning, so 3 warnings total + assert linter.stats.statement == 6 + assert linter.stats.warning == 3