Skip to content

Commit b30cdda

Browse files
author
Aleksey Petryankin
committed
Add support for per-directory configuration files
- Create additional Namespaces for subdirectories with configuration files - Open checkers per-file, so they use values from local config during opening
1 parent 1bd7a53 commit b30cdda

File tree

5 files changed

+112
-25
lines changed

5 files changed

+112
-25
lines changed

pylint/config/arguments_manager.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ def __init__(
8181
self._directory_namespaces: DirectoryNamespaceDict = {}
8282
"""Mapping of directories and their respective namespace objects."""
8383

84+
self._cli_args: list[str] = []
85+
"""Options that were passed as command line arguments and have highest priority."""
86+
8487
@property
8588
def config(self) -> argparse.Namespace:
8689
"""Namespace for all options."""
@@ -226,6 +229,8 @@ def _parse_command_line_configuration(
226229
) -> list[str]:
227230
"""Parse the arguments found on the command line into the namespace."""
228231
arguments = sys.argv[1:] if arguments is None else arguments
232+
if not self._cli_args:
233+
self._cli_args = list(arguments)
229234

230235
self.config, parsed_args = self._arg_parser.parse_known_args(
231236
arguments, self.config

pylint/config/config_initialization.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from pylint.lint import PyLinter
2424

2525

26+
# pylint: disable = too-many-statements
2627
def _config_initialization(
2728
linter: PyLinter,
2829
args_list: list[str],
@@ -141,7 +142,8 @@ def _config_initialization(
141142
linter._parse_error_mode()
142143

143144
# Link the base Namespace object on the current directory
144-
linter._directory_namespaces[Path(".").resolve()] = (linter.config, {})
145+
if Path(".").resolve() not in linter._directory_namespaces:
146+
linter._directory_namespaces[Path(".").resolve()] = (linter.config, {})
145147

146148
# parsed_args_list should now only be a list of inputs to lint.
147149
# All other options have been removed from the list.

pylint/config/find_default_config_files.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,17 +64,19 @@ def _cfg_has_config(path: Path | str) -> bool:
6464
return any(section.startswith("pylint.") for section in parser.sections())
6565

6666

67-
def _yield_default_files() -> Iterator[Path]:
67+
def _yield_default_files(basedir: Path | str = ".") -> Iterator[Path]:
6868
"""Iterate over the default config file names and see if they exist."""
69+
basedir = Path(basedir)
6970
for config_name in CONFIG_NAMES:
71+
config_file = basedir / config_name
7072
try:
71-
if config_name.is_file():
72-
if config_name.suffix == ".toml" and not _toml_has_config(config_name):
73+
if config_file.is_file():
74+
if config_file.suffix == ".toml" and not _toml_has_config(config_file):
7375
continue
74-
if config_name.suffix == ".cfg" and not _cfg_has_config(config_name):
76+
if config_file.suffix == ".cfg" and not _cfg_has_config(config_file):
7577
continue
7678

77-
yield config_name.resolve()
79+
yield config_file.resolve()
7880
except OSError:
7981
pass
8082

@@ -142,3 +144,8 @@ def find_default_config_files() -> Iterator[Path]:
142144
yield Path("/etc/pylintrc").resolve()
143145
except OSError:
144146
pass
147+
148+
149+
def find_subdirectory_config_files(basedir: Path | str) -> Iterator[Path]:
150+
"""Find config file in arbitrary subdirectory."""
151+
yield from _yield_default_files(basedir)

pylint/lint/base_options.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,25 @@ def _make_linter_options(linter: PyLinter) -> Options:
414414
"Useful if running pylint in a server-like mode.",
415415
},
416416
),
417+
(
418+
"use-local-configs",
419+
{
420+
"default": False,
421+
"type": "yn",
422+
"metavar": "<y or n>",
423+
"help": "When some of the linted files or modules have pylint config in the same directory, "
424+
"use their local configs for checking these files.",
425+
},
426+
),
427+
(
428+
"use-parent-configs",
429+
{
430+
"default": False,
431+
"type": "yn",
432+
"metavar": "<y or n>",
433+
"help": "Search for local pylint configs up until current working directory or root.",
434+
},
435+
),
417436
)
418437

419438

pylint/lint/pylinter.py

Lines changed: 73 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import argparse
88
import collections
99
import contextlib
10+
import copy
1011
import functools
1112
import os
1213
import sys
@@ -26,6 +27,8 @@
2627
from pylint import checkers, exceptions, interfaces, reporters
2728
from pylint.checkers.base_checker import BaseChecker
2829
from pylint.config.arguments_manager import _ArgumentsManager
30+
from pylint.config.config_initialization import _config_initialization
31+
from pylint.config.find_default_config_files import find_subdirectory_config_files
2932
from pylint.constants import (
3033
MAIN_CHECKER_NAME,
3134
MSG_TYPES,
@@ -615,6 +618,43 @@ def initialize(self) -> None:
615618
if not msg.may_be_emitted(self.config.py_version):
616619
self._msgs_state[msg.msgid] = False
617620

621+
def register_local_config(self, file_or_dir: str) -> None:
622+
if os.path.isdir(file_or_dir):
623+
basedir = Path(file_or_dir)
624+
else:
625+
basedir = Path(os.path.dirname(file_or_dir))
626+
627+
if self.config.use_parent_configs is False:
628+
# exit loop after first iteration
629+
scan_root_dir = basedir
630+
elif _is_relative_to(basedir, Path(os.getcwd())):
631+
scan_root_dir = Path(os.getcwd())
632+
else:
633+
scan_root_dir = Path("/")
634+
635+
while basedir.resolve() not in self._directory_namespaces and _is_relative_to(
636+
basedir, scan_root_dir
637+
):
638+
local_conf = next(find_subdirectory_config_files(basedir), None)
639+
if local_conf is not None:
640+
# in order to avoid creating new PyLinter objects, _config_initialization modifies
641+
# existing self.config, so we need to save original self.config to restore it later
642+
original_config_ref = self.config
643+
self.config = copy.deepcopy(self.config)
644+
_config_initialization(self, self._cli_args, config_file=local_conf)
645+
self._directory_namespaces[basedir.resolve()] = (self.config, {})
646+
# keep dict keys reverse-sorted so that
647+
# iteration over keys in _get_namespace_for_file gets the most nested path first
648+
self._directory_namespaces = dict(
649+
sorted(self._directory_namespaces.items(), reverse=True)
650+
)
651+
self.config = original_config_ref
652+
break
653+
if basedir.parent != basedir:
654+
basedir = basedir.parent
655+
else:
656+
break
657+
618658
def _discover_files(self, files_or_modules: Sequence[str]) -> Iterator[str]:
619659
"""Discover python modules and packages in sub-directory.
620660
@@ -665,12 +705,12 @@ def check(self, files_or_modules: Sequence[str]) -> None:
665705
"Missing filename required for --from-stdin"
666706
)
667707

668-
extra_packages_paths = list(
669-
{
708+
extra_packages_paths_set = set()
709+
for file_or_module in files_or_modules:
710+
extra_packages_paths_set.add(
670711
discover_package_path(file_or_module, self.config.source_roots)
671-
for file_or_module in files_or_modules
672-
}
673-
)
712+
)
713+
extra_packages_paths = list(extra_packages_paths_set)
674714

675715
# TODO: Move the parallel invocation into step 3 of the checking process
676716
if not self.config.from_stdin and self.config.jobs > 1:
@@ -693,14 +733,16 @@ def check(self, files_or_modules: Sequence[str]) -> None:
693733
fileitems = self._iterate_file_descrs(files_or_modules)
694734
data = None
695735

696-
# The contextmanager also opens all checkers and sets up the PyLinter class
697736
with augmented_sys_path(extra_packages_paths):
698-
with self._astroid_module_checker() as check_astroid_module:
699-
# 2) Get the AST for each FileItem
700-
ast_per_fileitem = self._get_asts(fileitems, data)
701-
702-
# 3) Lint each ast
703-
self._lint_files(ast_per_fileitem, check_astroid_module)
737+
# 2) Get the AST for each FileItem
738+
ast_per_fileitem = self._get_asts(fileitems, data)
739+
# 3) Lint each ast
740+
if self.config.use_local_configs is False:
741+
# The contextmanager also opens all checkers and sets up the PyLinter class
742+
with self._astroid_module_checker() as check_astroid_module:
743+
self._lint_files(ast_per_fileitem, check_astroid_module)
744+
else:
745+
self._lint_files(ast_per_fileitem, None)
704746

705747
def _get_asts(
706748
self, fileitems: Iterator[FileItem], data: str | None
@@ -710,6 +752,7 @@ def _get_asts(
710752

711753
for fileitem in fileitems:
712754
self.set_current_module(fileitem.name, fileitem.filepath)
755+
self._set_astroid_options()
713756

714757
try:
715758
ast_per_fileitem[fileitem] = self.get_ast(
@@ -741,7 +784,7 @@ def check_single_file_item(self, file: FileItem) -> None:
741784
def _lint_files(
742785
self,
743786
ast_mapping: dict[FileItem, nodes.Module | None],
744-
check_astroid_module: Callable[[nodes.Module], bool | None],
787+
check_astroid_module: Callable[[nodes.Module], bool | None] | None,
745788
) -> None:
746789
"""Lint all AST modules from a mapping.."""
747790
for fileitem, module in ast_mapping.items():
@@ -765,7 +808,7 @@ def _lint_file(
765808
self,
766809
file: FileItem,
767810
module: nodes.Module,
768-
check_astroid_module: Callable[[nodes.Module], bool | None],
811+
check_astroid_module: Callable[[nodes.Module], bool | None] | None,
769812
) -> None:
770813
"""Lint a file using the passed utility function check_astroid_module).
771814
@@ -784,7 +827,13 @@ def _lint_file(
784827
self.current_file = module.file
785828

786829
try:
787-
check_astroid_module(module)
830+
# call _astroid_module_checker after set_current_module, when
831+
# self.config is the right config for current module
832+
if check_astroid_module is None:
833+
with self._astroid_module_checker() as local_check_astroid_module:
834+
local_check_astroid_module(module)
835+
else:
836+
check_astroid_module(module)
788837
except Exception as e:
789838
raise astroid.AstroidError from e
790839

@@ -907,7 +956,8 @@ def set_current_module(self, modname: str, filepath: str | None = None) -> None:
907956
self.stats.init_single_module(modname or "")
908957

909958
# If there is an actual filepath we might need to update the config attribute
910-
if filepath:
959+
if filepath and self.config.use_local_configs:
960+
self.register_local_config(filepath)
911961
namespace = self._get_namespace_for_file(
912962
Path(filepath), self._directory_namespaces
913963
)
@@ -917,6 +967,7 @@ def set_current_module(self, modname: str, filepath: str | None = None) -> None:
917967
def _get_namespace_for_file(
918968
self, filepath: Path, namespaces: DirectoryNamespaceDict
919969
) -> argparse.Namespace | None:
970+
filepath = filepath.resolve()
920971
for directory in namespaces:
921972
if _is_relative_to(filepath, directory):
922973
namespace = self._get_namespace_for_file(
@@ -1068,16 +1119,19 @@ def _check_astroid_module(
10681119
walker.walk(node)
10691120
return True
10701121

1071-
def open(self) -> None:
1072-
"""Initialize counters."""
1122+
def _set_astroid_options(self) -> None:
1123+
"""Pass some config values to astroid.MANAGER object."""
10731124
MANAGER.always_load_extensions = self.config.unsafe_load_any_extension
10741125
MANAGER.max_inferable_values = self.config.limit_inference_results
10751126
MANAGER.extension_package_whitelist.update(self.config.extension_pkg_allow_list)
10761127
if self.config.extension_pkg_whitelist:
10771128
MANAGER.extension_package_whitelist.update(
10781129
self.config.extension_pkg_whitelist
10791130
)
1080-
self.stats.reset_message_count()
1131+
1132+
def open(self) -> None:
1133+
"""Initialize self as main checker for one or more modules."""
1134+
self._set_astroid_options()
10811135

10821136
def generate_reports(self, verbose: bool = False) -> int | None:
10831137
"""Close the whole package /module, it's time to make reports !

0 commit comments

Comments
 (0)