From 1bd7a5309dbfa45abf727a6a69a9d0e0ca02cc27 Mon Sep 17 00:00:00 2001
From: Aleksey Petryankin <al.petryankin@gmail.com>
Date: Thu, 25 Jan 2024 17:12:25 +0000
Subject: [PATCH 01/11] Fix formatting check when running tox

---
 requirements_test.txt | 1 +
 1 file changed, 1 insertion(+)

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

From b30cdda3cd35e07a89fde869d1b03f17df80d22a Mon Sep 17 00:00:00 2001
From: Aleksey Petryankin <al.petryankin@gmail.com>
Date: Sun, 28 Jan 2024 01:12:13 +0000
Subject: [PATCH 02/11] 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
---
 pylint/config/arguments_manager.py         |  5 ++
 pylint/config/config_initialization.py     |  4 +-
 pylint/config/find_default_config_files.py | 17 ++--
 pylint/lint/base_options.py                | 19 +++++
 pylint/lint/pylinter.py                    | 92 +++++++++++++++++-----
 5 files changed, 112 insertions(+), 25 deletions(-)

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_initialization.py b/pylint/config/config_initialization.py
index 6fa7b6b895..c656b8f11e 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],
@@ -141,7 +142,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..05734b90bd 100644
--- a/pylint/lint/base_options.py
+++ b/pylint/lint/base_options.py
@@ -414,6 +414,25 @@ def _make_linter_options(linter: PyLinter) -> Options:
                 "Useful if running pylint in a server-like mode.",
             },
         ),
+        (
+            "use-local-configs",
+            {
+                "default": False,
+                "type": "yn",
+                "metavar": "<y or n>",
+                "help": "When some of the linted files or modules have pylint config in the same directory, "
+                "use their local configs for checking these files.",
+            },
+        ),
+        (
+            "use-parent-configs",
+            {
+                "default": False,
+                "type": "yn",
+                "metavar": "<y or n>",
+                "help": "Search for local pylint configs up until current working directory or root.",
+            },
+        ),
     )
 
 
diff --git a/pylint/lint/pylinter.py b/pylint/lint/pylinter.py
index 30250154e6..01afec8e4f 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,
@@ -615,6 +618,43 @@ 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 self.config.use_parent_configs is False:
+            # exit loop after first iteration
+            scan_root_dir = basedir
+        elif _is_relative_to(basedir, Path(os.getcwd())):
+            scan_root_dir = Path(os.getcwd())
+        else:
+            scan_root_dir = Path("/")
+
+        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, config_file=local_conf)
+                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 +705,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 +733,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 +752,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(
@@ -741,7 +784,7 @@ def check_single_file_item(self, file: FileItem) -> None:
     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():
@@ -765,7 +808,7 @@ 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 +827,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
 
@@ -907,7 +956,8 @@ def set_current_module(self, modname: str, filepath: str | None = None) -> None:
         self.stats.init_single_module(modname or "")
 
         # If there is an actual filepath we might need to update the config attribute
-        if filepath:
+        if filepath and self.config.use_local_configs:
+            self.register_local_config(filepath)
             namespace = self._get_namespace_for_file(
                 Path(filepath), self._directory_namespaces
             )
@@ -917,6 +967,7 @@ def set_current_module(self, modname: str, filepath: str | None = None) -> None:
     def _get_namespace_for_file(
         self, filepath: Path, namespaces: DirectoryNamespaceDict
     ) -> argparse.Namespace | None:
+        filepath = filepath.resolve()
         for directory in namespaces:
             if _is_relative_to(filepath, directory):
                 namespace = self._get_namespace_for_file(
@@ -1068,8 +1119,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,7 +1128,10 @@ 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 !

From 3bf07762fcd0df3abbdfd6944f295cb29542f6bf Mon Sep 17 00:00:00 2001
From: Aleksey Petryankin <al.petryankin@gmail.com>
Date: Fri, 2 Feb 2024 14:17:19 +0000
Subject: [PATCH 03/11] Add tests for different configs in different
 directories

---
 tests/config/test_per_directory_config.py | 141 ++++++++++++++++++++++
 1 file changed, 141 insertions(+)

diff --git a/tests/config/test_per_directory_config.py b/tests/config/test_per_directory_config.py
index b888dd405e..05da7af3ec 100644
--- a/tests/config/test_per_directory_config.py
+++ b/tests/config/test_per_directory_config.py
@@ -2,8 +2,16 @@
 # 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 pathlib import Path
 
+import pytest
+from pytest import CaptureFixture
+
+from pylint.lint import Run as LintRun
 from pylint.testutils._run import _Run as Run
 
 
@@ -21,3 +29,136 @@ 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, Path, Path, 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"
+    subdir = level1_dir / "sub"
+    level2_init = subdir / "__init__.py"
+    conf_file2 = subdir / "pylintrc"
+    test_file2 = subdir / "b.py"
+    os.makedirs(subdir)
+    level1_init.touch()
+    level2_init.touch()
+    test_file_text = "#LEVEL1\n#LEVEL2\n#ALL_LEVELS\n#TODO\n"
+    test_file1.write_text(test_file_text)
+    test_file2.write_text(test_file_text)
+    test_file3.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
+
+
+# check that use-parent-configs doesn't break anything
+@pytest.mark.parametrize(
+    "local_config_args",
+    [["--use-local-configs=y"], ["--use-local-configs=y", "--use-parent-configs=y"]],
+)
+# check files and configs 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", [".", ".."])
+def test_subconfig_vs_root_config(
+    _create_subconfig_test_fs: tuple[Path, ...],
+    capsys: CaptureFixture,
+    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()
+
+    orig_cwd = os.getcwd()
+    output = [f"{start_dir = }, {test_file = }"]
+    os.chdir(start_dir)
+    for _ in range(2):
+        # _Run adds --rcfile, which overrides config from cwd, so we need original Run here
+        LintRun([*local_config_args, str(test_file)], exit=False)
+        output.append(capsys.readouterr().out.replace("\\n", "\n"))
+
+        test_file = test_file.parent
+    os.chdir(orig_cwd)
+
+    expected_note = f"LEVEL{(test_file_index%2)+1}"
+    assert (
+        expected_note in output[1]
+    ), f"readable debug output:\n{output[0]}\n{output[1]}"
+    assert (
+        expected_note in output[2]
+    ), f"readable debug output:\n{output[0]}\n{output[2]}"
+
+    if test_file_index == 0:
+        # 'pylint level1_dir/' should use config from subpackage when checking level1_dir/sub/b.py
+        assert (
+            "LEVEL2" in output[2]
+        ), f"readable debug output:\n{output[0]}\n{output[2]}"
+    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 (
+            "LEVEL1" not in output[1]
+        ), f"readable debug output:\n{output[0]}\n{output[1]}"
+        assert (
+            "LEVEL1" not in output[2]
+        ), f"readable debug output:\n{output[0]}\n{output[2]}"
+
+
+@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 args have priority over subconfigs."""
+    test_root, *tmp_files = _create_subconfig_test_fs
+    test_file = tmp_files[test_file_index]
+    orig_cwd = os.getcwd()
+    os.chdir(test_root)
+    LintRun(["--notes=FIXME", "--use-local-configs=y", str(test_file)], exit=False)
+    output = capsys.readouterr().out.replace("\\n", "\n")
+    os.chdir(orig_cwd)
+
+    # check that cli arg overrides default value
+    assert "TODO" not in output
+    # notes=FIXME in cli 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)
+    orig_cwd = os.getcwd()
+    os.chdir(tmp_path)
+    LintRun(
+        ["--use-parent-configs=y", "--use-local-configs=y", str(test_file)], exit=False
+    )
+    output1 = capsys.readouterr().out.replace("\\n", "\n")
+    os.chdir(orig_cwd)
+
+    # check that file is linted with config from ../, which is not a cwd
+    assert "TODO" not in output1
+    assert "LEVEL1" in output1

From b1f0e9a8b7d8e8b8bfad3182c3a4426f335c2e51 Mon Sep 17 00:00:00 2001
From: Aleksey Petryankin <al.petryankin@gmail.com>
Date: Sun, 28 Jan 2024 03:00:08 +0000
Subject: [PATCH 04/11] Add docs for new options providing per-directory
 configuration

---
 doc/user_guide/configuration/all-options.rst | 18 ++++++++++++++++++
 doc/whatsnew/fragments/618.feature           | 15 +++++++++++++++
 2 files changed, 33 insertions(+)
 create mode 100644 doc/whatsnew/fragments/618.feature

diff --git a/doc/user_guide/configuration/all-options.rst b/doc/user_guide/configuration/all-options.rst
index 94d2c1775e..223e7640cb 100644
--- a/doc/user_guide/configuration/all-options.rst
+++ b/doc/user_guide/configuration/all-options.rst
@@ -216,6 +216,20 @@ Standard Checkers
 **Default:**  ``False``
 
 
+--use-local-configs
+"""""""""""""""""""
+*When some of the linted files or modules have pylint config in the same directory, use their local configs for checking these files.*
+
+**Default:**  ``False``
+
+
+--use-parent-configs
+""""""""""""""""""""
+*Search for local pylint configs up until current working directory or root.*
+
+**Default:**  ``False``
+
+
 
 .. raw:: html
 
@@ -285,6 +299,10 @@ Standard Checkers
 
    unsafe-load-any-extension = false
 
+   use-local-configs = false
+
+   use-parent-configs = false
+
 
 
 .. raw:: html
diff --git a/doc/whatsnew/fragments/618.feature b/doc/whatsnew/fragments/618.feature
new file mode 100644
index 0000000000..f8122f9bbf
--- /dev/null
+++ b/doc/whatsnew/fragments/618.feature
@@ -0,0 +1,15 @@
+Add 2 new command line options: use-local-configs and use-parent-configs.
+
+use-local-configs enables searching for local pylint configurations in the same directory where linted file is located.
+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.
+
+use-parent-configs enables searching for local pylint configurations upwards from the directory where linted file is located.
+For example:
+if there exists package/pylintrc, and doesn't exist package/subpackage/pylintrc, then
+pylint --use-local-configs=y --use-parent-configs=y package/subpackage/file.py
+will use package/pylintrc instead of default config from $PWD.
+
+Closes #618

From 4f9b049d36d6692bb21614e5f866892be370501d Mon Sep 17 00:00:00 2001
From: Aleksey Petryankin <al.petryankin@gmail.com>
Date: Mon, 12 Feb 2024 05:06:17 +0000
Subject: [PATCH 05/11] Increase test coverage for local configs

- plus minor changes for github spellchecker
- plus fix for root directory on Windows
---
 .pyenchant_pylint_custom_dict.txt         |   3 +
 pylint/lint/pylinter.py                   |   2 +-
 tests/config/test_per_directory_config.py | 113 +++++++++++++++++-----
 3 files changed, 93 insertions(+), 25 deletions(-)

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/pylint/lint/pylinter.py b/pylint/lint/pylinter.py
index 01afec8e4f..057f07e204 100644
--- a/pylint/lint/pylinter.py
+++ b/pylint/lint/pylinter.py
@@ -630,7 +630,7 @@ def register_local_config(self, file_or_dir: str) -> None:
         elif _is_relative_to(basedir, Path(os.getcwd())):
             scan_root_dir = Path(os.getcwd())
         else:
-            scan_root_dir = Path("/")
+            scan_root_dir = Path(basedir.parts[0])
 
         while basedir.resolve() not in self._directory_namespaces and _is_relative_to(
             basedir, scan_root_dir
diff --git a/tests/config/test_per_directory_config.py b/tests/config/test_per_directory_config.py
index 05da7af3ec..f7ff547c87 100644
--- a/tests/config/test_per_directory_config.py
+++ b/tests/config/test_per_directory_config.py
@@ -32,28 +32,34 @@ def test_fall_back_on_base_config(tmp_path: Path) -> None:
 
 
 @pytest.fixture
-def _create_subconfig_test_fs(tmp_path: Path) -> tuple[Path, Path, Path, Path]:
+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\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
+    return level1_dir, test_file1, test_file2, test_file3, test_file4
 
 
 # check that use-parent-configs doesn't break anything
@@ -61,10 +67,12 @@ def _create_subconfig_test_fs(tmp_path: Path) -> tuple[Path, Path, Path, Path]:
     "local_config_args",
     [["--use-local-configs=y"], ["--use-local-configs=y", "--use-parent-configs=y"]],
 )
-# check files and configs from top-level package or subpackage
+# 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", [".", ".."])
+@pytest.mark.parametrize(
+    "start_dir_modificator", [".", "..", "../level1_dir_without_config"]
+)
 def test_subconfig_vs_root_config(
     _create_subconfig_test_fs: tuple[Path, ...],
     capsys: CaptureFixture,
@@ -80,7 +88,7 @@ def test_subconfig_vs_root_config(
     start_dir = (level1_dir / start_dir_modificator).resolve()
 
     orig_cwd = os.getcwd()
-    output = [f"{start_dir = }, {test_file = }"]
+    output = [f"{start_dir = }\n{test_file = }\n"]
     os.chdir(start_dir)
     for _ in range(2):
         # _Run adds --rcfile, which overrides config from cwd, so we need original Run here
@@ -90,28 +98,70 @@ def test_subconfig_vs_root_config(
         test_file = test_file.parent
     os.chdir(orig_cwd)
 
-    expected_note = f"LEVEL{(test_file_index%2)+1}"
-    assert (
-        expected_note in output[1]
-    ), f"readable debug output:\n{output[0]}\n{output[1]}"
-    assert (
-        expected_note in output[2]
-    ), f"readable debug output:\n{output[0]}\n{output[2]}"
+    expected_note = "LEVEL1"
+    if test_file_index == 1:
+        expected_note = "LEVEL2"
+    assert_message = f"Wrong note after checking FILE. Readable debug output:\n{output[0]}\n{output[1]}"
+    assert expected_note in output[1], assert_message
+    assert_message = f"Wrong note after checking DIRECTORY. 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 (
-            "LEVEL2" in output[2]
-        ), f"readable debug output:\n{output[0]}\n{output[2]}"
+        assert_message = f"Wrong note after checking DIRECTORY. 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 (
-            "LEVEL1" not in output[1]
-        ), f"readable debug output:\n{output[0]}\n{output[1]}"
-        assert (
-            "LEVEL1" not in output[2]
-        ), f"readable debug output:\n{output[0]}\n{output[2]}"
+        assert_message = f"Wrong note after checking FILE. Readable debug output:\n{output[0]}\n{output[1]}"
+        assert "LEVEL1" not in output[1], assert_message
+        assert_message = f"Wrong note after checking DIRECTORY. Readable debug output:\n{output[0]}\n{output[2]}"
+        assert "LEVEL1" not in output[2], assert_message
+
+
+# check that use-parent-configs doesn't break anything
+@pytest.mark.parametrize(
+    "local_config_args",
+    [["--use-local-configs=y"], ["--use-local-configs=y", "--use-parent-configs=y"]],
+)
+# 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, ...],
+    capsys: CaptureFixture,
+    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()
+
+    orig_cwd = os.getcwd()
+    output = [f"{start_dir = }\n{test_file = }\n"]
+    os.chdir(start_dir)
+    for _ in range(2):
+        # _Run adds --rcfile, which overrides config from cwd, so we need original Run here
+        LintRun([*local_config_args, str(test_file)], exit=False)
+        output.append(capsys.readouterr().out.replace("\\n", "\n"))
+
+        test_file = test_file.parent
+    os.chdir(orig_cwd)
+
+    # from default config
+    expected_note = "TODO"
+    if start_dir_modificator == ".":
+        # from config in level1_dir
+        expected_note = "LEVEL1"
+    assert_message = f"Wrong note after checking FILE. Readable debug output:\n{output[0]}\n{output[1]}"
+    assert expected_note in output[1], assert_message
+    assert_message = f"Wrong note after checking DIRECTORY. 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])
@@ -120,7 +170,7 @@ def test_subconfig_vs_cli_arg(
     capsys: CaptureFixture,
     test_file_index: int,
 ) -> None:
-    """Test that cli args have priority over subconfigs."""
+    """Test that CLI arguments have priority over subconfigs."""
     test_root, *tmp_files = _create_subconfig_test_fs
     test_file = tmp_files[test_file_index]
     orig_cwd = os.getcwd()
@@ -129,9 +179,9 @@ def test_subconfig_vs_cli_arg(
     output = capsys.readouterr().out.replace("\\n", "\n")
     os.chdir(orig_cwd)
 
-    # check that cli arg overrides default value
+    # check that CLI argument overrides default value
     assert "TODO" not in output
-    # notes=FIXME in cli should override all pylintrc configs
+    # notes=FIXME in arguments should override all pylintrc configs
     assert "ALL_LEVELS" not in output
 
 
@@ -162,3 +212,18 @@ def test_subconfig_in_parent(tmp_path: Path, capsys: CaptureFixture) -> None:
     # 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

From a85d952c592a23830c04802d22255a423bc46645 Mon Sep 17 00:00:00 2001
From: Aleksey Petryankin <al.petryankin@gmail.com>
Date: Mon, 12 Feb 2024 05:14:31 +0000
Subject: [PATCH 06/11] Unify use-parent-configs and use-local-configs

---
 doc/user_guide/configuration/all-options.rst | 11 +----------
 doc/whatsnew/fragments/618.feature           |  8 +++-----
 pylint/lint/base_options.py                  | 13 ++-----------
 pylint/lint/pylinter.py                      |  5 +----
 tests/config/test_per_directory_config.py    | 12 ++++--------
 5 files changed, 11 insertions(+), 38 deletions(-)

diff --git a/doc/user_guide/configuration/all-options.rst b/doc/user_guide/configuration/all-options.rst
index 223e7640cb..33c8b72ab6 100644
--- a/doc/user_guide/configuration/all-options.rst
+++ b/doc/user_guide/configuration/all-options.rst
@@ -218,14 +218,7 @@ Standard Checkers
 
 --use-local-configs
 """""""""""""""""""
-*When some of the linted files or modules have pylint config in the same directory, use their local configs for checking these files.*
-
-**Default:**  ``False``
-
-
---use-parent-configs
-""""""""""""""""""""
-*Search for local pylint configs up until current working directory or root.*
+*When some of the linted modules have a pylint config in the same directory (or one of the parent directories), use this config for checking these files.*
 
 **Default:**  ``False``
 
@@ -301,8 +294,6 @@ Standard Checkers
 
    use-local-configs = false
 
-   use-parent-configs = false
-
 
 
 .. raw:: html
diff --git a/doc/whatsnew/fragments/618.feature b/doc/whatsnew/fragments/618.feature
index f8122f9bbf..8e861578b6 100644
--- a/doc/whatsnew/fragments/618.feature
+++ b/doc/whatsnew/fragments/618.feature
@@ -1,15 +1,13 @@
-Add 2 new command line options: use-local-configs and use-parent-configs.
+Add new command line option: use-local-configs.
 
-use-local-configs enables searching for local pylint configurations in the same directory where linted file is located.
+use-local-configs enables searching for local pylint configurations in the same directory where linted file is 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.
 
-use-parent-configs enables searching for local pylint configurations upwards from the directory where linted file is located.
-For example:
 if there exists package/pylintrc, and doesn't exist package/subpackage/pylintrc, then
-pylint --use-local-configs=y --use-parent-configs=y package/subpackage/file.py
+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/lint/base_options.py b/pylint/lint/base_options.py
index 05734b90bd..03c770d4ef 100644
--- a/pylint/lint/base_options.py
+++ b/pylint/lint/base_options.py
@@ -420,17 +420,8 @@ def _make_linter_options(linter: PyLinter) -> Options:
                 "default": False,
                 "type": "yn",
                 "metavar": "<y or n>",
-                "help": "When some of the linted files or modules have pylint config in the same directory, "
-                "use their local configs for checking these files.",
-            },
-        ),
-        (
-            "use-parent-configs",
-            {
-                "default": False,
-                "type": "yn",
-                "metavar": "<y or n>",
-                "help": "Search for local pylint configs up until current working directory or root.",
+                "help": "When some of the linted modules have a pylint config in the same directory "
+                "(or one of the parent directories), use this config for checking these files.",
             },
         ),
     )
diff --git a/pylint/lint/pylinter.py b/pylint/lint/pylinter.py
index 057f07e204..540c32c80a 100644
--- a/pylint/lint/pylinter.py
+++ b/pylint/lint/pylinter.py
@@ -624,10 +624,7 @@ def register_local_config(self, file_or_dir: str) -> None:
         else:
             basedir = Path(os.path.dirname(file_or_dir))
 
-        if self.config.use_parent_configs is False:
-            # exit loop after first iteration
-            scan_root_dir = basedir
-        elif _is_relative_to(basedir, Path(os.getcwd())):
+        if _is_relative_to(basedir, Path(os.getcwd())):
             scan_root_dir = Path(os.getcwd())
         else:
             scan_root_dir = Path(basedir.parts[0])
diff --git a/tests/config/test_per_directory_config.py b/tests/config/test_per_directory_config.py
index f7ff547c87..8be42ab844 100644
--- a/tests/config/test_per_directory_config.py
+++ b/tests/config/test_per_directory_config.py
@@ -62,10 +62,9 @@ def _create_subconfig_test_fs(tmp_path: Path) -> tuple[Path, ...]:
     return level1_dir, test_file1, test_file2, test_file3, test_file4
 
 
-# check that use-parent-configs doesn't break anything
 @pytest.mark.parametrize(
     "local_config_args",
-    [["--use-local-configs=y"], ["--use-local-configs=y", "--use-parent-configs=y"]],
+    [["--use-local-configs=y"]],
 )
 # check modules and use of configuration files from top-level package or subpackage
 @pytest.mark.parametrize("test_file_index", [0, 1, 2])
@@ -119,10 +118,9 @@ def test_subconfig_vs_root_config(
         assert "LEVEL1" not in output[2], assert_message
 
 
-# check that use-parent-configs doesn't break anything
 @pytest.mark.parametrize(
     "local_config_args",
-    [["--use-local-configs=y"], ["--use-local-configs=y", "--use-parent-configs=y"]],
+    [["--use-local-configs=y"]],
 )
 # check cases when test_file without local config belongs to cwd subtree or not
 @pytest.mark.parametrize(
@@ -203,9 +201,7 @@ def test_subconfig_in_parent(tmp_path: Path, capsys: CaptureFixture) -> None:
     test_file = _create_parent_subconfig_fs(tmp_path)
     orig_cwd = os.getcwd()
     os.chdir(tmp_path)
-    LintRun(
-        ["--use-parent-configs=y", "--use-local-configs=y", str(test_file)], exit=False
-    )
+    LintRun(["--use-local-configs=y", str(test_file)], exit=False)
     output1 = capsys.readouterr().out.replace("\\n", "\n")
     os.chdir(orig_cwd)
 
@@ -226,4 +222,4 @@ def test_register_local_config_accepts_directory(
     # 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
+    assert level1_dir in linter._directory_namespaces.keys()

From 6417627eaacb67ede51a8f526959ddb29aca7eb9 Mon Sep 17 00:00:00 2001
From: Aleksey Petryankin <al.petryankin@gmail.com>
Date: Sat, 24 Feb 2024 18:59:02 +0000
Subject: [PATCH 07/11] Review-changes 1: replace chdir with _test_cwd in tests

---
 tests/config/test_per_directory_config.py | 46 ++++++++++-------------
 1 file changed, 19 insertions(+), 27 deletions(-)

diff --git a/tests/config/test_per_directory_config.py b/tests/config/test_per_directory_config.py
index 8be42ab844..ba7a8f2a1d 100644
--- a/tests/config/test_per_directory_config.py
+++ b/tests/config/test_per_directory_config.py
@@ -13,6 +13,7 @@
 
 from pylint.lint import Run as LintRun
 from pylint.testutils._run import _Run as Run
+from pylint.testutils.utils import _test_cwd
 
 
 def test_fall_back_on_base_config(tmp_path: Path) -> None:
@@ -86,16 +87,13 @@ def test_subconfig_vs_root_config(
     test_file = tmp_files[test_file_index]
     start_dir = (level1_dir / start_dir_modificator).resolve()
 
-    orig_cwd = os.getcwd()
     output = [f"{start_dir = }\n{test_file = }\n"]
-    os.chdir(start_dir)
-    for _ in range(2):
-        # _Run adds --rcfile, which overrides config from cwd, so we need original Run here
-        LintRun([*local_config_args, str(test_file)], exit=False)
-        output.append(capsys.readouterr().out.replace("\\n", "\n"))
-
-        test_file = test_file.parent
-    os.chdir(orig_cwd)
+    with _test_cwd(start_dir):
+        for _ in range(2):
+            # _Run adds --rcfile, which overrides config from cwd, so we need original Run here
+            LintRun([*local_config_args, str(test_file)], exit=False)
+            output.append(capsys.readouterr().out.replace("\\n", "\n"))
+            test_file = test_file.parent
 
     expected_note = "LEVEL1"
     if test_file_index == 1:
@@ -140,16 +138,14 @@ def test_missing_local_config(
     test_file = tmp_files[3]
     start_dir = (level1_dir / start_dir_modificator).resolve()
 
-    orig_cwd = os.getcwd()
     output = [f"{start_dir = }\n{test_file = }\n"]
-    os.chdir(start_dir)
-    for _ in range(2):
-        # _Run adds --rcfile, which overrides config from cwd, so we need original Run here
-        LintRun([*local_config_args, str(test_file)], exit=False)
-        output.append(capsys.readouterr().out.replace("\\n", "\n"))
+    with _test_cwd(start_dir):
+        for _ in range(2):
+            # _Run adds --rcfile, which overrides config from cwd, so we need original Run here
+            LintRun([*local_config_args, str(test_file)], exit=False)
+            output.append(capsys.readouterr().out.replace("\\n", "\n"))
 
-        test_file = test_file.parent
-    os.chdir(orig_cwd)
+            test_file = test_file.parent
 
     # from default config
     expected_note = "TODO"
@@ -171,11 +167,9 @@ def test_subconfig_vs_cli_arg(
     """Test that CLI arguments have priority over subconfigs."""
     test_root, *tmp_files = _create_subconfig_test_fs
     test_file = tmp_files[test_file_index]
-    orig_cwd = os.getcwd()
-    os.chdir(test_root)
-    LintRun(["--notes=FIXME", "--use-local-configs=y", str(test_file)], exit=False)
-    output = capsys.readouterr().out.replace("\\n", "\n")
-    os.chdir(orig_cwd)
+    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
@@ -199,11 +193,9 @@ def _create_parent_subconfig_fs(tmp_path: Path) -> Path:
 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)
-    orig_cwd = os.getcwd()
-    os.chdir(tmp_path)
-    LintRun(["--use-local-configs=y", str(test_file)], exit=False)
-    output1 = capsys.readouterr().out.replace("\\n", "\n")
-    os.chdir(orig_cwd)
+    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

From 949346f5fa85b8ae4e5b684316b44173e8d23c94 Mon Sep 17 00:00:00 2001
From: Aleksey Petryankin <al.petryankin@gmail.com>
Date: Sun, 25 Feb 2024 23:49:10 +0000
Subject: [PATCH 08/11] Review-changes 2: fix --use-local-configs when used
 with --jobs

- fixed assert on _worker_linter.reporter
- added another run of per_directory_config tests with --jobs argument
- rephrasing of assertion messages to better indicate intention of checks
---
 pylint/lint/pylinter.py                   |  5 +-
 tests/config/test_per_directory_config.py | 67 ++++++++++++++++-------
 2 files changed, 50 insertions(+), 22 deletions(-)

diff --git a/pylint/lint/pylinter.py b/pylint/lint/pylinter.py
index 540c32c80a..36bc77feb9 100644
--- a/pylint/lint/pylinter.py
+++ b/pylint/lint/pylinter.py
@@ -638,7 +638,9 @@ def register_local_config(self, file_or_dir: str) -> None:
                 # 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, config_file=local_conf)
+                _config_initialization(
+                    self, self._cli_args, reporter=self.reporter, config_file=local_conf
+                )
                 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
@@ -775,6 +777,7 @@ 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)
 
diff --git a/tests/config/test_per_directory_config.py b/tests/config/test_per_directory_config.py
index ba7a8f2a1d..4f13153e0f 100644
--- a/tests/config/test_per_directory_config.py
+++ b/tests/config/test_per_directory_config.py
@@ -6,6 +6,7 @@
 
 import os
 import os.path
+from io import StringIO
 from pathlib import Path
 
 import pytest
@@ -13,7 +14,7 @@
 
 from pylint.lint import Run as LintRun
 from pylint.testutils._run import _Run as Run
-from pylint.testutils.utils import _test_cwd
+from pylint.testutils.utils import _patch_streams, _test_cwd
 
 
 def test_fall_back_on_base_config(tmp_path: Path) -> None:
@@ -65,7 +66,7 @@ def _create_subconfig_test_fs(tmp_path: Path) -> tuple[Path, ...]:
 
 @pytest.mark.parametrize(
     "local_config_args",
-    [["--use-local-configs=y"]],
+    [["--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])
@@ -75,7 +76,6 @@ def _create_subconfig_test_fs(tmp_path: Path) -> tuple[Path, ...]:
 )
 def test_subconfig_vs_root_config(
     _create_subconfig_test_fs: tuple[Path, ...],
-    capsys: CaptureFixture,
     test_file_index: int,
     local_config_args: list[str],
     start_dir_modificator: str,
@@ -87,38 +87,56 @@ def test_subconfig_vs_root_config(
     test_file = tmp_files[test_file_index]
     start_dir = (level1_dir / start_dir_modificator).resolve()
 
-    output = [f"{start_dir = }\n{test_file = }\n"]
+    output = [f"{start_dir = }"]
     with _test_cwd(start_dir):
         for _ in range(2):
-            # _Run adds --rcfile, which overrides config from cwd, so we need original Run here
-            LintRun([*local_config_args, str(test_file)], exit=False)
-            output.append(capsys.readouterr().out.replace("\\n", "\n"))
+            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 = f"Wrong note after checking FILE. Readable debug output:\n{output[0]}\n{output[1]}"
+    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 = f"Wrong note after checking DIRECTORY. Readable debug output:\n{output[0]}\n{output[2]}"
+    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 = f"Wrong note after checking DIRECTORY. Readable debug output:\n{output[0]}\n{output[2]}"
+        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 = f"Wrong note after checking FILE. Readable debug output:\n{output[0]}\n{output[1]}"
+        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 = f"Wrong note after checking DIRECTORY. Readable debug output:\n{output[0]}\n{output[2]}"
+        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"], ["--use-local-configs=y", "--jobs=2"]],
 )
 # check cases when test_file without local config belongs to cwd subtree or not
 @pytest.mark.parametrize(
@@ -126,7 +144,6 @@ def test_subconfig_vs_root_config(
 )
 def test_missing_local_config(
     _create_subconfig_test_fs: tuple[Path, ...],
-    capsys: CaptureFixture,
     local_config_args: list[str],
     start_dir_modificator: str,
 ) -> None:
@@ -138,13 +155,15 @@ def test_missing_local_config(
     test_file = tmp_files[3]
     start_dir = (level1_dir / start_dir_modificator).resolve()
 
-    output = [f"{start_dir = }\n{test_file = }\n"]
+    output = [f"{start_dir = }"]
     with _test_cwd(start_dir):
         for _ in range(2):
-            # _Run adds --rcfile, which overrides config from cwd, so we need original Run here
-            LintRun([*local_config_args, str(test_file)], exit=False)
-            output.append(capsys.readouterr().out.replace("\\n", "\n"))
-
+            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
@@ -152,9 +171,15 @@ def test_missing_local_config(
     if start_dir_modificator == ".":
         # from config in level1_dir
         expected_note = "LEVEL1"
-    assert_message = f"Wrong note after checking FILE. Readable debug output:\n{output[0]}\n{output[1]}"
+    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 = f"Wrong note after checking DIRECTORY. Readable debug output:\n{output[0]}\n{output[2]}"
+    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
 
 

From 655ef69c4a7e21c0396481dc41121948cc823b15 Mon Sep 17 00:00:00 2001
From: Aleksey Petryankin <al.petryankin@gmail.com>
Date: Mon, 4 Mar 2024 02:50:52 +0000
Subject: [PATCH 09/11] Review-changes 3: add verbose messages about local
 configs

---
 pylint/config/config_file_parser.py       |  2 +-
 pylint/config/config_initialization.py    |  3 +++
 pylint/lint/pylinter.py                   | 23 ++++++++++++++++-------
 tests/config/test_config.py               |  2 +-
 tests/config/test_per_directory_config.py | 10 ++++++++++
 5 files changed, 31 insertions(+), 9 deletions(-)

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 c656b8f11e..42f7c9a1a3 100644
--- a/pylint/config/config_initialization.py
+++ b/pylint/config/config_initialization.py
@@ -83,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 preprocessed Runner.verbose to config
+    linter.config.verbose = verbose_mode
+
     # Remove the positional arguments separator from the list of arguments if it exists
     try:
         parsed_args_list.remove("--")
diff --git a/pylint/lint/pylinter.py b/pylint/lint/pylinter.py
index 36bc77feb9..f17bc09ccc 100644
--- a/pylint/lint/pylinter.py
+++ b/pylint/lint/pylinter.py
@@ -639,7 +639,11 @@ def register_local_config(self, file_or_dir: str) -> None:
                 original_config_ref = self.config
                 self.config = copy.deepcopy(self.config)
                 _config_initialization(
-                    self, self._cli_args, reporter=self.reporter, config_file=local_conf
+                    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
@@ -958,24 +962,29 @@ def set_current_module(self, modname: str, filepath: str | None = None) -> None:
         # If there is an actual filepath we might need to update the config attribute
         if filepath and self.config.use_local_configs:
             self.register_local_config(filepath)
-            namespace = self._get_namespace_for_file(
+            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(
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 4f13153e0f..e6c617144d 100644
--- a/tests/config/test_per_directory_config.py
+++ b/tests/config/test_per_directory_config.py
@@ -240,3 +240,13 @@ def test_register_local_config_accepts_directory(
     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

From 6414db8123ccd672a8566faa19631cf21733b19a Mon Sep 17 00:00:00 2001
From: Aleksey Petryankin <al.petryankin@gmail.com>
Date: Sun, 14 Apr 2024 17:22:10 +0000
Subject: [PATCH 10/11] Review-changes 4: fix score when use-local-configs=y

---
 pylint/lint/pylinter.py                   | 16 ++++++-
 tests/config/test_per_directory_config.py | 57 ++++++++++++++++++++++-
 2 files changed, 70 insertions(+), 3 deletions(-)

diff --git a/pylint/lint/pylinter.py b/pylint/lint/pylinter.py
index f17bc09ccc..13e8500ae6 100644
--- a/pylint/lint/pylinter.py
+++ b/pylint/lint/pylinter.py
@@ -69,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
 
@@ -320,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)
@@ -807,6 +808,11 @@ 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,
@@ -951,12 +957,17 @@ 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
@@ -1013,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()
@@ -1147,6 +1158,7 @@ def generate_reports(self, verbose: bool = False) -> int | None:
 
         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/tests/config/test_per_directory_config.py b/tests/config/test_per_directory_config.py
index e6c617144d..0ae75ad2c8 100644
--- a/tests/config/test_per_directory_config.py
+++ b/tests/config/test_per_directory_config.py
@@ -6,13 +6,17 @@
 
 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
 
@@ -52,7 +56,7 @@ def _create_subconfig_test_fs(tmp_path: Path) -> tuple[Path, ...]:
     level1_init.touch()
     level1_init2.touch()
     level2_init.touch()
-    test_file_text = "#LEVEL1\n#LEVEL2\n#ALL_LEVELS\n#TODO\n"
+    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)
@@ -250,3 +254,54 @@ def test_local_config_verbose(
     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

From 51b343163a6c45309cb956b13795fa30a97f9e96 Mon Sep 17 00:00:00 2001
From: Aleksey Petryankin <al.petryankin@gmail.com>
Date: Mon, 15 Apr 2024 09:34:04 +0000
Subject: [PATCH 11/11] Review-changes 5: add clarifications to the docs

---
 doc/user_guide/configuration/all-options.rst |  2 +-
 doc/whatsnew/fragments/618.feature           |  2 +-
 pylint/config/config_initialization.py       |  2 +-
 pylint/lint/base_options.py                  | 11 +++++++++--
 4 files changed, 12 insertions(+), 5 deletions(-)

diff --git a/doc/user_guide/configuration/all-options.rst b/doc/user_guide/configuration/all-options.rst
index 33c8b72ab6..6364ff99f2 100644
--- a/doc/user_guide/configuration/all-options.rst
+++ b/doc/user_guide/configuration/all-options.rst
@@ -218,7 +218,7 @@ Standard Checkers
 
 --use-local-configs
 """""""""""""""""""
-*When some of the linted modules have a pylint config in the same directory (or one of the parent directories), use this config for checking these files.*
+*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``
 
diff --git a/doc/whatsnew/fragments/618.feature b/doc/whatsnew/fragments/618.feature
index 8e861578b6..db1c55ce1f 100644
--- a/doc/whatsnew/fragments/618.feature
+++ b/doc/whatsnew/fragments/618.feature
@@ -1,6 +1,6 @@
 Add new command line option: use-local-configs.
 
-use-local-configs enables searching for local pylint configurations in the same directory where linted file is located and upwards until $PWD or root.
+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
diff --git a/pylint/config/config_initialization.py b/pylint/config/config_initialization.py
index 42f7c9a1a3..5af6d6b6bb 100644
--- a/pylint/config/config_initialization.py
+++ b/pylint/config/config_initialization.py
@@ -83,7 +83,7 @@ def _config_initialization(
     args_list = _order_all_first(args_list, joined=True)
     parsed_args_list = linter._parse_command_line_configuration(args_list)
 
-    # save preprocessed Runner.verbose to config
+    # 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
diff --git a/pylint/lint/base_options.py b/pylint/lint/base_options.py
index 03c770d4ef..6dd5d7dd47 100644
--- a/pylint/lint/base_options.py
+++ b/pylint/lint/base_options.py
@@ -420,8 +420,15 @@ def _make_linter_options(linter: PyLinter) -> Options:
                 "default": False,
                 "type": "yn",
                 "metavar": "<y or n>",
-                "help": "When some of the linted modules have a pylint config in the same directory "
-                "(or one of the parent directories), use this config for checking these files.",
+                "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.",
             },
         ),
     )