diff --git a/doc/user_guide/configuration/all-options.rst b/doc/user_guide/configuration/all-options.rst
index b83a2ca083..dcc8e39b22 100644
--- a/doc/user_guide/configuration/all-options.rst
+++ b/doc/user_guide/configuration/all-options.rst
@@ -174,6 +174,13 @@ Standard Checkers
 **Default:**  ``(3, 10)``

+--source-roots
+""""""""""""""
+*Add paths to the list of the source roots. The source root is an absolute path or a path relative to the current working directory used to determine a package namespace for modules located under the source root.*
+
+**Default:**  ``()``
+
+
 --recursive
 """""""""""
 *Discover python modules and packages in the file system subtree.*
diff --git a/doc/user_guide/usage/run.rst b/doc/user_guide/usage/run.rst
index 84e1a8e2f7..cee6f362ff 100644
--- a/doc/user_guide/usage/run.rst
+++ b/doc/user_guide/usage/run.rst
@@ -45,6 +45,13 @@ directory is automatically added on top of the python path
 package (i.e. has an ``__init__.py`` file), an implicit namespace package
 or if ``directory`` is in the python path.

+With implicit namespace packages
+--------------------------------
+
+If the analyzed sources use implicit namespace packages (PEP 420), the source root(s) should
+be specified using the ``--source-roots`` option. Otherwise, the package names are
+detected incorrectly, since implicit namespace packages don't contain an ``__init__.py``.
+
 Command line options
 --------------------

diff --git a/doc/whatsnew/fragments/8154.feature b/doc/whatsnew/fragments/8154.feature
new file mode 100644
index 0000000000..9c46a1c8da
--- /dev/null
+++ b/doc/whatsnew/fragments/8154.feature
@@ -0,0 +1,3 @@
+Support implicit namespace packages (PEP 420).
+
+Closes #8154
diff --git a/examples/pylintrc b/examples/pylintrc
index 1cf9639a13..879c1d4f13 100644
--- a/examples/pylintrc
+++ b/examples/pylintrc
@@ -90,6 +90,11 @@ persistent=yes
 # the version used to run pylint.
 py-version=3.10

+# Add paths to the list of the source roots. The source root is an absolute
+# path or a path relative to the current working directory used to
+# determine a package namespace for modules located under the source root.
+source-roots=src,tests
+
 # Discover python modules and packages in the file system subtree.
 recursive=no

diff --git a/pylint/config/argument.py b/pylint/config/argument.py
index b0ff4d5de2..fd01a9b5f9 100644
--- a/pylint/config/argument.py
+++ b/pylint/config/argument.py
@@ -88,6 +88,16 @@ def _path_transformer(value: str) -> str:
     return os.path.expandvars(os.path.expanduser(value))

+def _paths_csv_transformer(value: str) -> Sequence[str]:
+    """Transforms a comma separated list of paths while expanding user and
+    variables.
+    """
+    paths: list[str] = []
+    for path in _csv_transformer(value):
+        paths.append(os.path.expandvars(os.path.expanduser(path)))
+    return paths
+
+
 def _py_version_transformer(value: str) -> tuple[int, ...]:
     """Transforms a version string into a version tuple."""
     try:
@@ -138,6 +148,7 @@ def _regexp_paths_csv_transfomer(value: str) -> Sequence[Pattern[str]]:
     "confidence": _confidence_transformer,
     "non_empty_string": _non_empty_string_transformer,
     "path": _path_transformer,
+    "paths_csv": _paths_csv_transformer,
     "py_version": _py_version_transformer,
     "regexp": _regex_transformer,
     "regexp_csv": _regexp_csv_transfomer,
diff --git a/pylint/config/option.py b/pylint/config/option.py
index 95248d6b1e..5a425f34d9 100644
--- a/pylint/config/option.py
+++ b/pylint/config/option.py
@@ -117,6 +117,7 @@ def _py_version_validator(_: Any, name: str, value: Any) -> tuple[int, int, int]
     "string": utils._unquote,
     "int": int,
     "float": float,
+    "paths_csv": _csv_validator,
     "regexp": lambda pattern: re.compile(pattern or ""),
     "regexp_csv": _regexp_csv_validator,
     "regexp_paths_csv": _regexp_paths_csv_validator,
@@ -163,6 +164,7 @@ def _validate(value: Any, optdict: Any, name: str = "") -> Any:
 # pylint: disable=no-member
 class Option(optparse.Option):
     TYPES = optparse.Option.TYPES + (
+        "paths_csv",
         "regexp",
         "regexp_csv",
         "regexp_paths_csv",
@@ -175,6 +177,7 @@ class Option(optparse.Option):
     )
     ATTRS = optparse.Option.ATTRS + ["hide", "level"]
     TYPE_CHECKER = copy.copy(optparse.Option.TYPE_CHECKER)
+    TYPE_CHECKER["paths_csv"] = _csv_validator
     TYPE_CHECKER["regexp"] = _regexp_validator
     TYPE_CHECKER["regexp_csv"] = _regexp_csv_validator
     TYPE_CHECKER["regexp_paths_csv"] = _regexp_paths_csv_validator
diff --git a/pylint/lint/__init__.py b/pylint/lint/__init__.py
index 86186ebd40..573d9c2624 100644
--- a/pylint/lint/__init__.py
+++ b/pylint/lint/__init__.py
@@ -18,6 +18,7 @@

 from pylint.config.exceptions import ArgumentPreprocessingError
 from pylint.lint.caching import load_results, save_results
+from pylint.lint.expand_modules import discover_package_path
 from pylint.lint.parallel import check_parallel
 from pylint.lint.pylinter import PyLinter
 from pylint.lint.report_functions import (
@@ -26,7 +27,12 @@
     report_total_messages_stats,
 )
 from pylint.lint.run import Run
-from pylint.lint.utils import _patch_sys_path, fix_import_path
+from pylint.lint.utils import (
+    _augment_sys_path,
+    _patch_sys_path,
+    augmented_sys_path,
+    fix_import_path,
+)

 __all__ = [
     "check_parallel",
@@ -38,6 +44,9 @@
     "ArgumentPreprocessingError",
     "_patch_sys_path",
     "fix_import_path",
+    "_augment_sys_path",
+    "augmented_sys_path",
+    "discover_package_path",
     "save_results",
     "load_results",
 ]
diff --git a/pylint/lint/base_options.py b/pylint/lint/base_options.py
index 1c37eac2fb..8540340cda 100644
--- a/pylint/lint/base_options.py
+++ b/pylint/lint/base_options.py
@@ -343,6 +343,17 @@ def _make_linter_options(linter: PyLinter) -> Options:
                 ),
             },
         ),
+        (
+            "source-roots",
+            {
+                "type": "paths_csv",
+                "metavar": "<path>[,<path>...]",
+                "default": (),
+                "help": "Add paths to the list of the source roots. The source root is an absolute "
+                "path or a path relative to the current working directory used to "
+                "determine a package namespace for modules located under the source root.",
+            },
+        ),
         (
             "recursive",
             {
diff --git a/pylint/lint/expand_modules.py b/pylint/lint/expand_modules.py
index 8259e25ada..bb25986e4e 100644
--- a/pylint/lint/expand_modules.py
+++ b/pylint/lint/expand_modules.py
@@ -6,6 +6,7 @@

 import os
 import sys
+import warnings
 from collections.abc import Sequence
 from re import Pattern

@@ -24,14 +25,31 @@ def _is_package_cb(inner_path: str, parts: list[str]) -> bool:

 def get_python_path(filepath: str) -> str:
-    """TODO This get the python path with the (bad) assumption that there is always
-    an __init__.py.
+    # TODO: Remove deprecated function
+    warnings.warn(
+        "get_python_path has been deprecated because assumption that there's always an __init__.py "
+        "is not true since python 3.3 and is causing problems, particularly with PEP 420."
+        "Use discover_package_path and pass source root(s).",
+        DeprecationWarning,
+        stacklevel=2,
+    )
+    return discover_package_path(filepath, [])

-    This is not true since python 3.3 and is causing problem.
-    """
-    dirname = os.path.realpath(os.path.expanduser(filepath))
+
+def discover_package_path(modulepath: str, source_roots: Sequence[str]) -> str:
+    """Discover package path from one its modules and source roots."""
+    dirname = os.path.realpath(os.path.expanduser(modulepath))
     if not os.path.isdir(dirname):
         dirname = os.path.dirname(dirname)
+
+    # Look for a source root that contains the module directory
+    for source_root in source_roots:
+        source_root = os.path.realpath(os.path.expanduser(source_root))
+        if os.path.commonpath([source_root, dirname]) == source_root:
+            return source_root
+
+    # Fall back to legacy discovery by looking for __init__.py upwards as
+    # it's the only way given that source root was not found or was not provided
     while True:
         if not os.path.exists(os.path.join(dirname, "__init__.py")):
             return dirname
@@ -64,6 +82,7 @@ def _is_ignored_file(
 # pylint: disable = too-many-locals, too-many-statements
 def expand_modules(
     files_or_modules: Sequence[str],
+    source_roots: Sequence[str],
     ignore_list: list[str],
     ignore_list_re: list[Pattern[str]],
     ignore_list_paths_re: list[Pattern[str]],
@@ -81,8 +100,8 @@ def expand_modules(
             something, ignore_list, ignore_list_re, ignore_list_paths_re
         ):
             continue
-        module_path = get_python_path(something)
-        additional_search_path = [".", module_path] + path
+        module_package_path = discover_package_path(something, source_roots)
+        additional_search_path = [".", module_package_path] + path
         if os.path.exists(something):
             # this is a file or a directory
             try:
diff --git a/pylint/lint/parallel.py b/pylint/lint/parallel.py
index 9730914b75..544a256d33 100644
--- a/pylint/lint/parallel.py
+++ b/pylint/lint/parallel.py
@@ -13,7 +13,7 @@
 import dill

 from pylint import reporters
-from pylint.lint.utils import _patch_sys_path
+from pylint.lint.utils import _augment_sys_path
 from pylint.message import Message
 from pylint.typing import FileItem
 from pylint.utils import LinterStats, merge_stats
@@ -37,12 +37,12 @@

 def _worker_initialize(
-    linter: bytes, arguments: None | str | Sequence[str] = None
+    linter: bytes, extra_packages_paths: Sequence[str] | None = None
 ) -> None:
     """Function called to initialize a worker for a Process within a concurrent Pool.

     :param linter: A linter-class (PyLinter) instance pickled with dill
-    :param arguments: File or module name(s) to lint and to be added to sys.path
+    :param extra_packages_paths: Extra entries to be added to sys.path
     """
     global _worker_linter  # pylint: disable=global-statement
     _worker_linter = dill.loads(linter)
@@ -53,8 +53,8 @@ def _worker_initialize(
     _worker_linter.set_reporter(reporters.CollectingReporter())
     _worker_linter.open()

-    # Patch sys.path so that each argument is importable just like in single job mode
-    _patch_sys_path(arguments or ())
+    if extra_packages_paths:
+        _augment_sys_path(extra_packages_paths)

 def _worker_check_single_file(
@@ -130,7 +130,7 @@ def check_parallel(
     linter: PyLinter,
     jobs: int,
     files: Iterable[FileItem],
-    arguments: None | str | Sequence[str] = None,
+    extra_packages_paths: Sequence[str] | None = None,
 ) -> None:
     """Use the given linter to lint the files with given amount of workers (jobs).

@@ -140,7 +140,9 @@ def check_parallel(
     # The linter is inherited by all the pool's workers, i.e. the linter
     # is identical to the linter object here. This is required so that
     # a custom PyLinter object can be used.
-    initializer = functools.partial(_worker_initialize, arguments=arguments)
+    initializer = functools.partial(
+        _worker_initialize, extra_packages_paths=extra_packages_paths
+    )
     with ProcessPoolExecutor(
         max_workers=jobs, initializer=initializer, initargs=(dill.dumps(linter),)
     ) as executor:
diff --git a/pylint/lint/pylinter.py b/pylint/lint/pylinter.py
index 4d1bede9a6..5b749d5b2f 100644
--- a/pylint/lint/pylinter.py
+++ b/pylint/lint/pylinter.py
@@ -36,7 +36,11 @@
 from pylint.interfaces import HIGH
 from pylint.lint.base_options import _make_linter_options
 from pylint.lint.caching import load_results, save_results
-from pylint.lint.expand_modules import _is_ignored_file, expand_modules
+from pylint.lint.expand_modules import (
+    _is_ignored_file,
+    discover_package_path,
+    expand_modules,
+)
 from pylint.lint.message_state_handler import _MessageStateHandler
 from pylint.lint.parallel import check_parallel
 from pylint.lint.report_functions import (
@@ -46,7 +50,7 @@
 )
 from pylint.lint.utils import (
     _is_relative_to,
-    fix_import_path,
+    augmented_sys_path,
     get_fatal_error_message,
     prepare_crash_report,
 )
@@ -675,6 +679,13 @@ def check(self, files_or_modules: Sequence[str] | str) -> None:
                     "Missing filename required for --from-stdin"
                 )

+        extra_packages_paths = list(
+            {
+                discover_package_path(file_or_module, self.config.source_roots)
+                for file_or_module in files_or_modules
+            }
+        )
+
         # TODO: Move the parallel invocation into step 5 of the checking process
         if not self.config.from_stdin and self.config.jobs > 1:
             original_sys_path = sys.path[:]
@@ -682,13 +693,13 @@ def check(self, files_or_modules: Sequence[str] | str) -> None:
                 self,
                 self.config.jobs,
                 self._iterate_file_descrs(files_or_modules),
-                files_or_modules,  # this argument patches sys.path
+                extra_packages_paths,
             )
             sys.path = original_sys_path
             return

         # 3) Get all FileItems
-        with fix_import_path(files_or_modules):
+        with augmented_sys_path(extra_packages_paths):
             if self.config.from_stdin:
                 fileitems = self._get_file_descr_from_stdin(files_or_modules[0])
                 data: str | None = _read_stdin()
@@ -697,7 +708,7 @@ def check(self, files_or_modules: Sequence[str] | str) -> None:
                 data = None

         # The contextmanager also opens all checkers and sets up the PyLinter class
-        with fix_import_path(files_or_modules):
+        with augmented_sys_path(extra_packages_paths):
             with self._astroid_module_checker() as check_astroid_module:
                 # 4) Get the AST for each FileItem
                 ast_per_fileitem = self._get_asts(fileitems, data)
@@ -884,10 +895,13 @@ def _iterate_file_descrs(
             if self.should_analyze_file(name, filepath, is_argument=is_arg):
                 yield FileItem(name, filepath, descr["basename"])

-    def _expand_files(self, modules: Sequence[str]) -> dict[str, ModuleDescriptionDict]:
+    def _expand_files(
+        self, files_or_modules: Sequence[str]
+    ) -> dict[str, ModuleDescriptionDict]:
         """Get modules and errors from a list of modules and handle errors."""
         result, errors = expand_modules(
-            modules,
+            files_or_modules,
+            self.config.source_roots,
             self.config.ignore,
             self.config.ignore_patterns,
             self._ignore_paths,
diff --git a/pylint/lint/utils.py b/pylint/lint/utils.py
index d4ad131f32..98fb8087a7 100644
--- a/pylint/lint/utils.py
+++ b/pylint/lint/utils.py
@@ -7,12 +7,13 @@
 import contextlib
 import sys
 import traceback
+import warnings
 from collections.abc import Iterator, Sequence
 from datetime import datetime
 from pathlib import Path

 from pylint.config import PYLINT_HOME
-from pylint.lint.expand_modules import get_python_path
+from pylint.lint.expand_modules import discover_package_path

 def prepare_crash_report(ex: Exception, filepath: str, crash_file_path: str) -> Path:
@@ -73,14 +74,26 @@ def get_fatal_error_message(filepath: str, issue_template_path: Path) -> str:

 def _patch_sys_path(args: Sequence[str]) -> list[str]:
+    # TODO: Remove deprecated function
+    warnings.warn(
+        "_patch_sys_path has been deprecated because it relies on auto-magic package path "
+        "discovery which is implemented by get_python_path that is deprecated. "
+        "Use _augment_sys_path and pass additional sys.path entries as an argument obtained from "
+        "discover_package_path.",
+        DeprecationWarning,
+        stacklevel=2,
+    )
+    return _augment_sys_path([discover_package_path(arg, []) for arg in args])
+
+
+def _augment_sys_path(additional_paths: Sequence[str]) -> list[str]:
     original = list(sys.path)
     changes = []
     seen = set()
-    for arg in args:
-        path = get_python_path(arg)
-        if path not in seen:
-            changes.append(path)
-            seen.add(path)
+    for additional_path in additional_paths:
+        if additional_path not in seen:
+            changes.append(additional_path)
+            seen.add(additional_path)

     sys.path[:] = changes + sys.path
     return original
@@ -95,7 +108,23 @@ def fix_import_path(args: Sequence[str]) -> Iterator[None]:
     We avoid adding duplicate directories to sys.path.
     `sys.path` is reset to its original value upon exiting this context.
     """
-    original = _patch_sys_path(args)
+    # TODO: Remove deprecated function
+    warnings.warn(
+        "fix_import_path has been deprecated because it relies on auto-magic package path "
+        "discovery which is implemented by get_python_path that is deprecated. "
+        "Use augmented_sys_path and pass additional sys.path entries as an argument obtained from "
+        "discover_package_path.",
+        DeprecationWarning,
+        stacklevel=2,
+    )
+    with augmented_sys_path([discover_package_path(arg, []) for arg in args]):
+        yield
+
+
+@contextlib.contextmanager
+def augmented_sys_path(additional_paths: Sequence[str]) -> Iterator[None]:
+    """Augment 'sys.path' by adding non-existent entries from additional_paths."""
+    original = _augment_sys_path(additional_paths)
     try:
         yield
     finally:
diff --git a/pylint/pyreverse/main.py b/pylint/pyreverse/main.py
index 8a2055c411..13669d5b4a 100644
--- a/pylint/pyreverse/main.py
+++ b/pylint/pyreverse/main.py
@@ -13,7 +13,8 @@
 from pylint import constants
 from pylint.config.arguments_manager import _ArgumentsManager
 from pylint.config.arguments_provider import _ArgumentsProvider
-from pylint.lint.utils import fix_import_path
+from pylint.lint import discover_package_path
+from pylint.lint.utils import augmented_sys_path
 from pylint.pyreverse import writer
 from pylint.pyreverse.diadefslib import DiadefsHandler
 from pylint.pyreverse.inspector import Linker, project_from_files
@@ -203,6 +204,17 @@
             "help": "set the output directory path.",
         },
     ),
+    (
+        "source-roots",
+        {
+            "type": "paths_csv",
+            "metavar": "<path>[,<path>...]",
+            "default": (),
+            "help": "Add paths to the list of the source roots. The source root is an absolute "
+            "path or a path relative to the current working directory used to "
+            "determine a package namespace for modules located under the source root.",
+        },
+    ),
 )

@@ -235,7 +247,10 @@ def run(self, args: list[str]) -> int:
         if not args:
             print(self.help())
             return 1
-        with fix_import_path(args):
+        extra_packages_paths = list(
+            {discover_package_path(arg, self.config.source_roots) for arg in args}
+        )
+        with augmented_sys_path(extra_packages_paths):
             project = project_from_files(
                 args,
                 project_name=self.config.project,

diff --git a/pylint/testutils/pyreverse.py b/pylint/testutils/pyreverse.py
index fc20b5453f..7a61ff5fe6 100644
--- a/pylint/testutils/pyreverse.py
+++ b/pylint/testutils/pyreverse.py
@@ -67,6 +67,7 @@ def __init__(

 class TestFileOptions(TypedDict):
+    source_roots: list[str]
     output_formats: list[str]
     command_line_args: list[str]

@@ -97,7 +98,11 @@ def get_functional_test_files(
             test_files.append(
                 FunctionalPyreverseTestfile(
                     source=path,
-                    options={"output_formats": ["mmd"], "command_line_args": []},
+                    options={
+                        "source_roots": [],
+                        "output_formats": ["mmd"],
+                        "command_line_args": [],
+                    },
                 )
             )
     return test_files
@@ -106,7 +111,9 @@ def get_functional_test_files(
 def _read_config(config_file: Path) -> TestFileOptions:
     config = configparser.ConfigParser()
     config.read(str(config_file))
+    source_roots = config.get("testoptions", "source_roots", fallback=None)
     return {
+        "source_roots": source_roots.split(",") if source_roots else [],
         "output_formats": config.get(
             "testoptions", "output_formats", fallback="mmd"
         ).split(","),
diff --git a/tests/lint/unittest_expand_modules.py b/tests/lint/unittest_expand_modules.py
index 1c3f23b00c..a8c46498cb 100644
--- a/tests/lint/unittest_expand_modules.py
+++ b/tests/lint/unittest_expand_modules.py
@@ -151,6 +151,7 @@ def test_expand_modules(
         ignore_list_re: list[re.Pattern[str]] = []
         modules, errors = expand_modules(
             files_or_modules,
+            [],
             ignore_list,
             ignore_list_re,
             self.linter.config.ignore_paths,
@@ -180,6 +181,7 @@ def test_expand_modules_deduplication(
         ignore_list_re: list[re.Pattern[str]] = []
         modules, errors = expand_modules(
             files_or_modules,
+            [],
             ignore_list,
             ignore_list_re,
             self.linter.config.ignore_paths,
@@ -209,6 +211,7 @@ def test_expand_modules_with_ignore(
         ignore_list_re: list[re.Pattern[str]] = []
         modules, errors = expand_modules(
             files_or_modules,
+            [],
             ignore_list,
             ignore_list_re,
             self.linter.config.ignore_paths,
diff --git a/tests/lint/unittest_lint.py b/tests/lint/unittest_lint.py
index 3c91eba7f3..23cde49674 100644
--- a/tests/lint/unittest_lint.py
+++ b/tests/lint/unittest_lint.py
@@ -39,8 +39,7 @@
     _warn_about_old_home,
 )
 from pylint.exceptions import InvalidMessageError
-from pylint.lint import PyLinter
-from pylint.lint.utils import fix_import_path
+from pylint.lint import PyLinter, expand_modules
 from pylint.message import Message
 from pylint.reporters import text
 from pylint.testutils import create_files
@@ -117,8 +116,17 @@ def fake_path() -> Iterator[list[str]]:
     sys.path[:] = orig

+def test_deprecated() -> None:
+    """Test that fix_import_path() and get_python_path() are deprecated"""
+    with tempdir():
+        create_files(["__init__.py"])
+        with pytest.deprecated_call():
+            with lint.fix_import_path([""]):
+                expand_modules.get_python_path("__init__.py")
+
+
 def test_no_args(fake_path: list[str]) -> None:
-    with lint.fix_import_path([]):
+    with lint.augmented_sys_path([]):
         assert sys.path == fake_path
     assert sys.path == fake_path

@@ -131,8 +139,12 @@ def test_one_arg(fake_path: list[str], case: list[str]) -> None:
         create_files(["a/b/__init__.py"])
         expected = [join(chroot, "a")] + fake_path

+        extra_sys_paths = [
+            expand_modules.discover_package_path(arg, []) for arg in case
+        ]
+
         assert sys.path == fake_path
-        with lint.fix_import_path(case):
+        with lint.augmented_sys_path(extra_sys_paths):
             assert sys.path == expected
         assert sys.path == fake_path

@@ -151,8 +163,12 @@ def test_two_similar_args(fake_path: list[str], case: list[str]) -> None:
         create_files(["a/b/__init__.py", "a/c/__init__.py"])
         expected = [join(chroot, "a")] + fake_path

+        extra_sys_paths = [
+            expand_modules.discover_package_path(arg, []) for arg in case
+        ]
+
         assert sys.path == fake_path
-        with lint.fix_import_path(case):
+        with lint.augmented_sys_path(extra_sys_paths):
             assert sys.path == expected
         assert sys.path == fake_path

@@ -173,8 +189,12 @@ def test_more_args(fake_path: list[str], case: list[str]) -> None:
             for suffix in (sep.join(("a", "b")), "a", sep.join(("a", "e")))
         ] + fake_path

+        extra_sys_paths = [
+            expand_modules.discover_package_path(arg, []) for arg in case
+        ]
+
         assert sys.path == fake_path
-        with lint.fix_import_path(case):
+        with lint.augmented_sys_path(extra_sys_paths):
             assert sys.path == expected
         assert sys.path == fake_path

@@ -1188,6 +1208,39 @@ def test_recursive_ignore(ignore_parameter: str, ignore_parameter_value: str) ->
     assert module in linted_file_paths

+def test_recursive_implicit_namespace() -> None:
+    run = Run(
+        [
+            "--verbose",
+            "--recursive",
+            "y",
+            "--source-roots",
+            join(REGRTEST_DATA_DIR, "pep420", "basic", "project"),
+            join(REGRTEST_DATA_DIR, "pep420", "basic"),
+        ],
+        exit=False,
+    )
+    run.linter.set_reporter(testutils.GenericTestReporter())
+    run.linter.check([join(REGRTEST_DATA_DIR, "pep420", "basic")])
+    assert run.linter.file_state.base_name == "namespace.package"
+
+
+def test_recursive_implicit_namespace_wrapper() -> None:
+    run = Run(
+        [
+            "--recursive",
+            "y",
+            "--source-roots",
+            join(REGRTEST_DATA_DIR, "pep420", "wrapper", "project"),
+            join(REGRTEST_DATA_DIR, "pep420", "wrapper"),
+        ],
+        exit=False,
+    )
+    run.linter.set_reporter(testutils.GenericTestReporter())
+    run.linter.check([join(REGRTEST_DATA_DIR, "pep420", "wrapper")])
+    assert run.linter.reporter.messages == []
+
+
 def test_relative_imports(initialized_linter: PyLinter) -> None:
     """Regression test for https://github.com/PyCQA/pylint/issues/3651"""
     linter = initialized_linter
@@ -1235,8 +1288,10 @@ def test_import_sibling_module_from_namespace(initialized_linter: PyLinter) -> N
 """
             )
         os.chdir("namespace")
+        extra_sys_paths = [expand_modules.discover_package_path(tmpdir, [])]
+
         # Add the parent directory to sys.path
-        with fix_import_path([tmpdir]):
+        with lint.augmented_sys_path(extra_sys_paths):
             linter.check(["submodule2.py"])
     assert not linter.stats.by_msg

@@ -1257,6 +1312,7 @@ def test_lint_namespace_package_under_dir_on_path(initialized_linter: PyLinter)
     with tempdir() as tmpdir:
         create_files(["namespace_on_path/submodule1.py"])
         os.chdir(tmpdir)
-        with fix_import_path([tmpdir]):
+        extra_sys_paths = [expand_modules.discover_package_path(tmpdir, [])]
+        with lint.augmented_sys_path(extra_sys_paths):
             linter.check(["namespace_on_path"])
     assert linter.file_state.base_name == "namespace_on_path"
diff --git a/tests/primer/packages_to_prime.json b/tests/primer/packages_to_prime.json
index 547c3ea584..fa14577730 100644
--- a/tests/primer/packages_to_prime.json
+++ b/tests/primer/packages_to_prime.json
@@ -61,7 +61,8 @@
   },
   "poetry-core": {
     "branch": "main",
-    "directories": ["src/poetry/core"],
-    "url": "https://github.com/python-poetry/poetry-core"
+    "directories": ["src"],
+    "url": "https://github.com/python-poetry/poetry-core",
+    "pylint_additional_args": ["--recursive=y", "--source-roots=src"]
   }
 }
diff --git a/tests/pyreverse/conftest.py b/tests/pyreverse/conftest.py
index a37e4bde1a..02a19e6139 100644
--- a/tests/pyreverse/conftest.py
+++ b/tests/pyreverse/conftest.py
@@ -9,7 +9,7 @@
 import pytest
 from astroid.nodes.scoped_nodes import Module

-from pylint.lint import fix_import_path
+from pylint.lint import augmented_sys_path, discover_package_path
 from pylint.pyreverse.inspector import Project, project_from_files
 from pylint.testutils.pyreverse import PyreverseConfig
 from pylint.typing import GetProjectCallable
@@ -74,7 +74,7 @@ def _get_project(module: str, name: str | None = "No Name") -> Project:
         def _astroid_wrapper(func: Callable[[str], Module], modname: str) -> Module:
             return func(modname)

-        with fix_import_path([module]):
+        with augmented_sys_path([discover_package_path(module, [])]):
             project = project_from_files([module], _astroid_wrapper, project_name=name)
         return project

diff --git a/tests/pyreverse/functional/class_diagrams/namespaces/pep420/pep420.dot b/tests/pyreverse/functional/class_diagrams/namespaces/pep420/pep420.dot
new file mode 100644
index 0000000000..b01a46f387
--- /dev/null
+++ b/tests/pyreverse/functional/class_diagrams/namespaces/pep420/pep420.dot
@@ -0,0 +1,5 @@
+digraph "classes" {
+rankdir=BT
+charset="utf-8"
+"namespaces.pep420.pep420.PEP420" [color="black", fontcolor="black", label=<{PEP420|<br ALIGN="LEFT"/>|}>, shape="record", style="solid"];
+}
diff --git a/tests/pyreverse/functional/class_diagrams/namespaces/pep420/pep420.mmd b/tests/pyreverse/functional/class_diagrams/namespaces/pep420/pep420.mmd
new file mode 100644
index 0000000000..4deda423bd
--- /dev/null
+++ b/tests/pyreverse/functional/class_diagrams/namespaces/pep420/pep420.mmd
@@ -0,0 +1,3 @@
+classDiagram
+  class PEP420 {
+  }
diff --git a/tests/pyreverse/functional/class_diagrams/namespaces/pep420/pep420.puml b/tests/pyreverse/functional/class_diagrams/namespaces/pep420/pep420.puml
new file mode 100644
index 0000000000..5819158ae4
--- /dev/null
+++ b/tests/pyreverse/functional/class_diagrams/namespaces/pep420/pep420.puml
@@ -0,0 +1,5 @@
+@startuml classes
+set namespaceSeparator none
+class "PEP420" as namespaces.pep420.pep420.PEP420 {
+}
+@enduml
diff --git a/tests/pyreverse/functional/class_diagrams/namespaces/pep420/pep420.py b/tests/pyreverse/functional/class_diagrams/namespaces/pep420/pep420.py
new file mode 100644
index 0000000000..f2d568c47b
--- /dev/null
+++ b/tests/pyreverse/functional/class_diagrams/namespaces/pep420/pep420.py
@@ -0,0 +1,2 @@
+class PEP420:
+    pass
diff --git a/tests/pyreverse/functional/class_diagrams/namespaces/pep420/pep420.rc b/tests/pyreverse/functional/class_diagrams/namespaces/pep420/pep420.rc
new file mode 100644
index 0000000000..0ae3403a91
--- /dev/null
+++ b/tests/pyreverse/functional/class_diagrams/namespaces/pep420/pep420.rc
@@ -0,0 +1,3 @@
+[testoptions]
+source_roots=../..
+output_formats=mmd,dot,puml
diff --git a/tests/pyreverse/test_main.py b/tests/pyreverse/test_main.py
index 4cc0573d64..1e6196270a 100644
--- a/tests/pyreverse/test_main.py
+++ b/tests/pyreverse/test_main.py
@@ -16,7 +16,7 @@
 from _pytest.capture import CaptureFixture
 from _pytest.fixtures import SubRequest

-from pylint.lint import fix_import_path
+from pylint.lint import augmented_sys_path, discover_package_path
 from pylint.pyreverse import main

 TEST_DATA_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "data"))
@@ -61,7 +61,7 @@ def test_project_root_in_sys_path() -> None:
     """Test the context manager adds the project root directory to sys.path.
     This should happen when pyreverse is run from any directory
     """
-    with fix_import_path([TEST_DATA_DIR]):
+    with augmented_sys_path([discover_package_path(TEST_DATA_DIR, [])]):
         assert sys.path == [PROJECT_ROOT_DIR]

diff --git a/tests/pyreverse/test_pyreverse_functional.py b/tests/pyreverse/test_pyreverse_functional.py
index 15fd1978b6..aab500b818 100644
--- a/tests/pyreverse/test_pyreverse_functional.py
+++ b/tests/pyreverse/test_pyreverse_functional.py
@@ -1,7 +1,7 @@
 # Licensed under the GPL: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
 # For details: https://github.com/PyCQA/pylint/blob/main/LICENSE
 # Copyright (c) https://github.com/PyCQA/pylint/blob/main/CONTRIBUTORS.txt
-
+import os
 from pathlib import Path

 import pytest
@@ -13,7 +13,8 @@
 )

 FUNCTIONAL_DIR = Path(__file__).parent / "functional"
-CLASS_DIAGRAM_TESTS = get_functional_test_files(FUNCTIONAL_DIR / "class_diagrams")
+CLASS_DIAGRAMS_DIR = FUNCTIONAL_DIR / "class_diagrams"
+CLASS_DIAGRAM_TESTS = get_functional_test_files(CLASS_DIAGRAMS_DIR)
 CLASS_DIAGRAM_TEST_IDS = [testfile.source.stem for testfile in CLASS_DIAGRAM_TESTS]

@@ -24,9 +25,23 @@
 )
 def test_class_diagrams(testfile: FunctionalPyreverseTestfile, tmp_path: Path) -> None:
     input_file = testfile.source
+    input_path = os.path.dirname(input_file)
+    if testfile.options["source_roots"]:
+        source_roots = ",".join(
+            [
+                os.path.realpath(
+                    os.path.expanduser(os.path.join(input_path, source_root))
+                )
+                for source_root in testfile.options["source_roots"]
+            ]
+        )
+    else:
+        source_roots = ""
     for output_format in testfile.options["output_formats"]:
         with pytest.raises(SystemExit) as sys_exit:
             args = ["-o", f"{output_format}", "-d", str(tmp_path)]
+            if source_roots:
+                args += ["--source-roots", source_roots]
             args.extend(testfile.options["command_line_args"])
             args += [str(input_file)]
             Run(args)
diff --git a/tests/regrtest_data/pep420/basic/project/namespace/package/__init__.py b/tests/regrtest_data/pep420/basic/project/namespace/package/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/tests/regrtest_data/pep420/wrapper/project/namespace/package/logging/__init__.py b/tests/regrtest_data/pep420/wrapper/project/namespace/package/logging/__init__.py
new file mode 100644
index 0000000000..8bd4f6df2e
--- /dev/null
+++ b/tests/regrtest_data/pep420/wrapper/project/namespace/package/logging/__init__.py
@@ -0,0 +1,5 @@
+"""
+A drop-in replacement for `logging`.
+"""
+
+from .wrapper import *  # noqa: F403
diff --git a/tests/regrtest_data/pep420/wrapper/project/namespace/package/logging/wrapper/__init__.py b/tests/regrtest_data/pep420/wrapper/project/namespace/package/logging/wrapper/__init__.py
new file mode 100644
index 0000000000..50a2ac8141
--- /dev/null
+++ b/tests/regrtest_data/pep420/wrapper/project/namespace/package/logging/wrapper/__init__.py
@@ -0,0 +1,5 @@
+"""
+A wrapper for original logging module
+"""
+
+from logging import *  # noqa: F403
diff --git a/tests/test_check_parallel.py b/tests/test_check_parallel.py
index 96e67517ec..9134d485bc 100644
--- a/tests/test_check_parallel.py
+++ b/tests/test_check_parallel.py
@@ -10,6 +10,7 @@

 import argparse
 import os
+import sys
 from concurrent.futures import ProcessPoolExecutor
 from concurrent.futures.process import BrokenProcessPool
 from pickle import PickleError
@@ -21,7 +22,7 @@
 import pylint.interfaces
 import pylint.lint.parallel
 from pylint.checkers import BaseRawFileChecker
-from pylint.lint import PyLinter
+from pylint.lint import PyLinter, augmented_sys_path
 from pylint.lint.parallel import _worker_check_single_file as worker_check_single_file
 from pylint.lint.parallel import _worker_initialize as worker_initialize
 from pylint.lint.parallel import check_parallel
@@ -173,6 +174,14 @@ def test_worker_initialize(self) -> None:
         worker_initialize(linter=dill.dumps(linter))
         assert isinstance(pylint.lint.parallel._worker_linter, type(linter))

+    def test_worker_initialize_with_package_paths(self) -> None:
+        linter = PyLinter(reporter=Reporter())
+        with augmented_sys_path([]):
+            worker_initialize(
+                linter=dill.dumps(linter), extra_packages_paths=["fake-path"]
+            )
+            assert "fake-path" in sys.path
+
     @pytest.mark.needs_two_cores
     def test_worker_initialize_pickling(self) -> None:
         """Test that we can pickle objects that standard pickling in multiprocessing can't.
@@ -324,7 +333,6 @@ def test_sequential_checkers_work(self) -> None:
             linter,
             jobs=1,
             files=iter(single_file_container),
-            arguments=["--enable", "R9999"],
         )
         assert len(linter.get_checkers()) == 2, (
             "We should only have the 'main' and 'sequential-checker' "
@@ -390,7 +398,9 @@ def test_invoke_single_job(self) -> None:
         # Invoke the lint process in a multi-process way, although we only specify one
         # job.
         check_parallel(
-            linter, jobs=1, files=iter(single_file_container), arguments=None
+            linter,
+            jobs=1,
+            files=iter(single_file_container),
         )

         assert {
@@ -504,7 +514,6 @@ def test_compare_workers_to_single_proc(
                     linter,
                     jobs=num_jobs,
                     files=file_infos,
-                    arguments=None,
                 )
                 stats_check_parallel = linter.stats
                 assert linter.msg_status == 0, "We should not fail the lint"
@@ -572,7 +581,6 @@ def test_map_reduce(self, num_files: int, num_jobs: int, num_checkers: int) -> N
                     linter,
                     jobs=num_jobs,
                     files=file_infos,
-                    arguments=None,
                 )
                 stats_check_parallel = linter.stats
         assert str(stats_single_proc.by_msg) == str(
@@ -600,5 +608,5 @@ def test_no_deadlock_due_to_initializer_error(self) -> None:
                 files=iter(single_file_container),
                 # This will trigger an exception in the initializer for the parallel jobs
                 # because arguments has to be an Iterable.
-                arguments=1,  # type: ignore[arg-type]
+                extra_packages_paths=1,  # type: ignore[arg-type]
             )
diff --git a/tests/test_deprecation.py b/tests/test_deprecation.py
index d30e69b85d..ed57c73069 100644
--- a/tests/test_deprecation.py
+++ b/tests/test_deprecation.py
@@ -11,6 +11,7 @@
 import pytest
 from astroid import nodes

+from pylint import lint
 from pylint.checkers import BaseChecker
 from pylint.checkers.mapreduce_checker import MapReduceMixin
 from pylint.config import load_results, save_results
@@ -106,3 +107,16 @@ def test_collectblocklines() -> None:
     state = FileState("foo", MessageDefinitionStore())
     with pytest.warns(DeprecationWarning):
         state.collect_block_lines(MessageDefinitionStore(), nodes.Module("foo"))
+
+
+def test_patch_sys_path() -> None:
+    """Test that _patch_sys_path() is deprecated"""
+    with pytest.deprecated_call():
+        lint._patch_sys_path([])
+
+
+def test_fix_import_path() -> None:
+    """Test that fix_import_path() is deprecated"""
+    with pytest.deprecated_call():
+        with lint.fix_import_path([]):
+            pass

diff --git a/doc/user_guide/configuration/all-options.rst b/doc/user_guide/configuration/all-options.rst
index b83a2ca08..dcc8e39b2 100644
--- a/doc/user_guide/configuration/all-options.rst
+++ b/doc/user_guide/configuration/all-options.rst
@@ -174,6 +174,13 @@ Standard Checkers
 **Default:**  ``(3, 10)``

+--source-roots
+""""""""""""""
+*Add paths to the list of the source roots. The source root is an absolute path or a path relative to the current working directory used to determine a package namespace for modules located under the source root.*
+
+**Default:**  ``()``
+
+
 --recursive
 """""""""""
 *Discover python modules and packages in the file system subtree.*

diff --git a/doc/user_guide/usage/run.rst b/doc/user_guide/usage/run.rst
index 84e1a8e2f..cee6f362f 100644
--- a/doc/user_guide/usage/run.rst
+++ b/doc/user_guide/usage/run.rst
@@ -45,6 +45,13 @@ directory is automatically added on top of the python path
 package (i.e. has an ``__init__.py`` file), an implicit namespace package
 or if ``directory`` is in the python path.

+With implicit namespace packages
+--------------------------------
+
+If the analyzed sources use implicit namespace packages (PEP 420), the source root(s) should
+be specified using the ``--source-roots`` option. Otherwise, the package names are
+detected incorrectly, since implicit namespace packages don't contain an ``__init__.py``.
+
 Command line options
 --------------------

diff --git a/doc/whatsnew/fragments/####.feature b/doc/whatsnew/fragments/####.feature
new file mode 100644
index 000000000..208d9bcae
--- /dev/null
+++ b/doc/whatsnew/fragments/####.feature
@@ -0,0 +1,1 @@
+Support implicit namespace packages (PEP 420).
\ No newline at end of file

If completing this task requires creating new files, classes, fields, or error messages, you may consider using the following suggested entity names:
[{'type': 'field', 'name': 'augmented_sys_path'}, {'type': 'field', 'name': 'extra_packages_paths'}, {'type': 'field', 'name': 'discover_package_path'}, {'type': 'field', 'name': 'source_roots'}, {'type': 'field', 'name': 'source_roots'}, {'type': 'field', 'name': 'arg'}, {'type': 'field', 'name': 'discover_package_path'}, {'type': 'field', 'name': 'extra_packages_paths'}, {'type': 'function', 'name': 'augmented_sys_path'}, {'type': 'field', 'name': 'discover_package_path'}, {'type': 'field', 'name': 'extra_packages_paths'}, {'type': 'field', 'name': 'discover_package_path'}, {'type': 'field', 'name': 'discover_package_path'}, {'type': 'field', 'name': 'source_root'}, {'type': 'field', 'name': 'augmented_sys_path'}, {'type': 'field', 'name': 'augmented_sys_path'}, {'type': 'field', 'name': 'expand_modules'}, {'type': 'field', 'name': 'augmented_sys_path'}, {'type': 'field', 'name': 'source_roots'}, {'type': 'function', 'name': 'discover_package_path'}]


diff --git a/doc/whatsnew/fragments/8181.breaking b/doc/whatsnew/fragments/8181.breaking
new file mode 100644
index 0000000000..cd5b361d15
--- /dev/null
+++ b/doc/whatsnew/fragments/8181.breaking
@@ -0,0 +1,5 @@
+Add new option (``--show-stdlib``, ``-L``) to ``pyreverse``.
+This is similar to the behavior of ``--show-builtin`` in that standard library
+modules are now not included by default, and this option will include them.
+
+Closes #8181
diff --git a/pylint/pyreverse/diadefslib.py b/pylint/pyreverse/diadefslib.py
index 85b23052e1..5f12eaf7c4 100644
--- a/pylint/pyreverse/diadefslib.py
+++ b/pylint/pyreverse/diadefslib.py
@@ -12,6 +12,7 @@

 import astroid
 from astroid import nodes
+from astroid.modutils import is_stdlib_module

 from pylint.pyreverse.diagrams import ClassDiagram, PackageDiagram
 from pylint.pyreverse.inspector import Linker, Project
@@ -67,10 +68,14 @@ def _get_levels(self) -> tuple[int, int]:
         return self.anc_level, self.association_level

     def show_node(self, node: nodes.ClassDef) -> bool:
-        """True if builtins and not show_builtins."""
-        if self.config.show_builtin:
-            return True
-        return node.root().name != "builtins"  # type: ignore[no-any-return]
+        """Determine if node should be shown based on config."""
+        if node.root().name == "builtins":
+            return self.config.show_builtin  # type: ignore[no-any-return]
+
+        if is_stdlib_module(node.root().name):
+            return self.config.show_stdlib  # type: ignore[no-any-return]
+
+        return True

     def add_class(self, node: nodes.ClassDef) -> None:
         """Visit one class and add it to diagram."""
diff --git a/pylint/pyreverse/main.py b/pylint/pyreverse/main.py
index a2dc947348..bdd1eb4a9d 100644
--- a/pylint/pyreverse/main.py
+++ b/pylint/pyreverse/main.py
@@ -137,6 +137,15 @@
             "help": "include builtin objects in representation of classes",
         },
     ),
+    (
+        "show-stdlib",
+        {
+            "short": "L",
+            "action": "store_true",
+            "default": False,
+            "help": "include standard library objects in representation of classes",
+        },
+    ),
     (
         "module-names",
         {

diff --git a/pylint/testutils/pyreverse.py b/pylint/testutils/pyreverse.py
index 20dce84dd7..74395524bf 100644
--- a/pylint/testutils/pyreverse.py
+++ b/pylint/testutils/pyreverse.py
@@ -38,6 +38,7 @@ def __init__(
         show_associated: int | None = None,
         all_associated: bool | None = None,
         show_builtin: bool = False,
+        show_stdlib: bool = False,
         module_names: bool | None = None,
         only_classnames: bool = False,
         output_format: str = "dot",
@@ -59,6 +60,7 @@ def __init__(
         self.show_associated = show_associated
         self.all_associated = all_associated
         self.show_builtin = show_builtin
+        self.show_stdlib = show_stdlib
         self.module_names = module_names
         self.only_classnames = only_classnames
         self.output_format = output_format
diff --git a/tests/pyreverse/test_diadefs.py b/tests/pyreverse/test_diadefs.py
index 5dba9466d5..73d8adfccf 100644
--- a/tests/pyreverse/test_diadefs.py
+++ b/tests/pyreverse/test_diadefs.py
@@ -13,7 +13,7 @@
 from pathlib import Path

 import pytest
-from astroid import nodes
+from astroid import extract_node, nodes

 from pylint.pyreverse.diadefslib import (
     ClassDiadefGenerator,
@@ -97,6 +97,50 @@ def test_default_values() -> None:
     # TODO : should test difference between default values for package or class diagrams

+class TestShowOptions:
+    def test_show_stdlib(self) -> None:
+        example = extract_node(
+            '''
+            import collections
+
+            class CustomDict(collections.OrderedDict):
+                """docstring"""
+            '''
+        )
+
+        config = PyreverseConfig()
+        dd_gen = DiaDefGenerator(Linker(PROJECT), DiadefsHandler(config))
+
+        # Default behavior
+        assert not list(dd_gen.get_ancestors(example, 1))
+
+        # Show standard library enabled
+        config.show_stdlib = True
+        ancestors = list(dd_gen.get_ancestors(example, 1))
+        assert len(ancestors) == 1
+        assert ancestors[0].name == "OrderedDict"
+
+    def test_show_builtin(self) -> None:
+        example = extract_node(
+            '''
+            class CustomError(Exception):
+                """docstring"""
+            '''
+        )
+
+        config = PyreverseConfig()
+        dd_gen = DiaDefGenerator(Linker(PROJECT), DiadefsHandler(config))
+
+        # Default behavior
+        assert not list(dd_gen.get_ancestors(example, 1))
+
+        # Show builtin enabled
+        config.show_builtin = True
+        ancestors = list(dd_gen.get_ancestors(example, 1))
+        assert len(ancestors) == 1
+        assert ancestors[0].name == "Exception"
+
+
 class TestDefaultDiadefGenerator:
     _should_rels = [
         ("aggregation", "DoNothing2", "Specialization"),
diff --git a/tests/pyreverse/test_main.py b/tests/pyreverse/test_main.py
index 86401e05be..564394abb1 100644
--- a/tests/pyreverse/test_main.py
+++ b/tests/pyreverse/test_main.py
@@ -138,6 +138,7 @@ def test_graphviz_unsupported_image_format(capsys: CaptureFixture) -> None:
         ("show_associated", None),
         ("all_associated", None),
         ("show_builtin", 0),
+        ("show_stdlib", 0),
         ("module_names", None),
         ("output_format", "dot"),
         ("colorized", 0),
diff --git a/tests/pyreverse/test_writer.py b/tests/pyreverse/test_writer.py
index 805f8fab59..c5a78d3837 100644
--- a/tests/pyreverse/test_writer.py
+++ b/tests/pyreverse/test_writer.py
@@ -32,6 +32,7 @@
     "all_associated": None,
     "mode": "PUB_ONLY",
     "show_builtin": False,
+    "show_stdlib": False,
     "only_classnames": False,
     "output_directory": "",
 }

diff --git a/doc/whatsnew/fragments/####.breaking b/doc/whatsnew/fragments/####.breaking
new file mode 100644
index 000000000..f2c8c9319
--- /dev/null
+++ b/doc/whatsnew/fragments/####.breaking
@@ -0,0 +1,3 @@
+Add new option (``--show-stdlib``, ``-L``) to ``pyreverse``.
+This is similar to the behavior of ``--show-builtin`` in that standard library
+modules are now not included by default, and this option will include them.

If completing this task requires creating new files, classes, fields, or error messages, you may consider using the following suggested entity names:
[{'type': 'field', 'name': 'show_stdlib'}]


diff --git a/ChangeLog b/ChangeLog
index b3c5fbcf7d..9a20365af9 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -15,6 +15,11 @@ Release date: TBA

   Closes #4982

+* Add ability to add ``end_line`` and ``end_column`` to the ``--msg-template`` option.
+  With the standard ``TextReporter`` this will add the line and column number of the
+  end of a node to the output of Pylint. If these numbers are unknown, they are represented
+  by an empty string.
+
 * Introduced primer tests and a configuration tests framework. The helper classes available in
   ``pylint/testutil/`` are still unstable and might be modified in the near future.

diff --git a/doc/user_guide/output.rst b/doc/user_guide/output.rst
index 52959d3c71..1aa18f3b31 100644
--- a/doc/user_guide/output.rst
+++ b/doc/user_guide/output.rst
@@ -58,6 +58,10 @@ line
     line number
 column
     column number
+end_line
+    line number of the end of the node
+end_column
+    column number of the end of the node
 module
     module name
 obj
@@ -94,6 +98,9 @@ A few other examples:
 The ``--msg-template`` option can only be combined with text-based reporters (``--output-format`` either unspecified or one of: parseable, colorized or msvs).
 If both ``--output-format`` and ``--msg-template`` are specified, the ``--msg-template`` option will take precedence over the default line format defined by the reporter class.

+If ``end_line`` or ``end_column`` are ``None``, they will be represented as an empty string
+by the default ``TextReporter``.
+
 .. _Python new format syntax: https://docs.python.org/2/library/string.html#formatstrings

 Source code analysis section
diff --git a/doc/whatsnew/2.12.rst b/doc/whatsnew/2.12.rst
index dae98d4853..473a3ba028 100644
--- a/doc/whatsnew/2.12.rst
+++ b/doc/whatsnew/2.12.rst
@@ -205,6 +205,11 @@ Other Changes

   Partially closes #5321

+* Add ability to add ``end_line`` and ``end_column`` to the ``--msg-template`` option.
+  With the standard ``TextReporter`` this will add the line and column number of the
+  end of a node to the output of Pylint. If these numbers are unknown, they are represented
+  by an empty string.
+
 * Introduced primer tests and a configuration tests framework. The helper classes available in
   ``pylint/testutil/`` are still unstable and might be modified in the near future.

diff --git a/pylint/reporters/text.py b/pylint/reporters/text.py
index d4e7cf70cc..b8de25dc06 100644
--- a/pylint/reporters/text.py
+++ b/pylint/reporters/text.py
@@ -24,6 +24,7 @@
 :colorized: an ANSI colorized text reporter
 """
 import os
+import re
 import sys
 import warnings
 from typing import (
@@ -183,13 +184,38 @@ def __init__(self, output: Optional[TextIO] = None) -> None:
         super().__init__(output)
         self._modules: Set[str] = set()
         self._template = self.line_format
+        self._fixed_template = self.line_format
+        """The output format template with any unrecognized arguments removed"""

     def on_set_current_module(self, module: str, filepath: Optional[str]) -> None:
-        self._template = str(self.linter.config.msg_template or self._template)
+        """Set the format template to be used and check for unrecognized arguments."""
+        template = str(self.linter.config.msg_template or self._template)
+
+        # Return early if the template is the same as the previous one
+        if template == self._template:
+            return
+
+        # Set template to the currently selected template
+        self._template = template
+
+        # Check to see if all parameters in the template are attributes of the Message
+        arguments = re.findall(r"\{(.+?)(:.*)?\}", template)
+        for argument in arguments:
+            if argument[0] not in Message._fields:
+                warnings.warn(
+                    f"Don't recognize the argument '{argument[0]}' in the --msg-template. "
+                    "Are you sure it is supported on the current version of pylint?"
+                )
+                template = re.sub(r"\{" + argument[0] + r"(:.*?)?\}", "", template)
+        self._fixed_template = template

     def write_message(self, msg: Message) -> None:
         """Convenience method to write a formatted message with class default template"""
-        self.writeln(msg.format(self._template))
+        self_dict = msg._asdict()
+        for key in ("end_line", "end_column"):
+            self_dict[key] = self_dict[key] or ""
+
+        self.writeln(self._fixed_template.format(**self_dict))

     def handle_message(self, msg: Message) -> None:
         """manage message of different type and in the context of path"""

diff --git a/tests/unittest_reporting.py b/tests/unittest_reporting.py
index 22fdc905c5..3e6fdafd15 100644
--- a/tests/unittest_reporting.py
+++ b/tests/unittest_reporting.py
@@ -57,6 +57,72 @@ def test_template_option(linter):
     assert output.getvalue() == "************* Module 0123\nC0301:001\nC0301:002\n"

+def test_template_option_default(linter) -> None:
+    """Test the default msg-template setting"""
+    output = StringIO()
+    linter.reporter.out = output
+    linter.open()
+    linter.set_current_module("my_module")
+    linter.add_message("C0301", line=1, args=(1, 2))
+    linter.add_message("line-too-long", line=2, args=(3, 4))
+
+    out_lines = output.getvalue().split("\n")
+    assert out_lines[1] == "my_module:1:0: C0301: Line too long (1/2) (line-too-long)"
+    assert out_lines[2] == "my_module:2:0: C0301: Line too long (3/4) (line-too-long)"
+
+
+def test_template_option_end_line(linter) -> None:
+    """Test the msg-template option with end_line and end_column"""
+    output = StringIO()
+    linter.reporter.out = output
+    linter.set_option(
+        "msg-template",
+        "{path}:{line}:{column}:{end_line}:{end_column}: {msg_id}: {msg} ({symbol})",
+    )
+    linter.open()
+    linter.set_current_module("my_mod")
+    linter.add_message("C0301", line=1, args=(1, 2))
+    linter.add_message(
+        "line-too-long", line=2, end_lineno=2, end_col_offset=4, args=(3, 4)
+    )
+
+    out_lines = output.getvalue().split("\n")
+    assert out_lines[1] == "my_mod:1:0::: C0301: Line too long (1/2) (line-too-long)"
+    assert out_lines[2] == "my_mod:2:0:2:4: C0301: Line too long (3/4) (line-too-long)"
+
+
+def test_template_option_non_exisiting(linter) -> None:
+    """Test the msg-template option with a non exisiting options.
+    This makes sure that this option remains backwards compatible as new
+    parameters do not break on previous versions"""
+    output = StringIO()
+    linter.reporter.out = output
+    linter.set_option(
+        "msg-template",
+        "{path}:{line}:{a_new_option}:({a_second_new_option:03d})",
+    )
+    linter.open()
+    with pytest.warns(UserWarning) as records:
+        linter.set_current_module("my_mod")
+        assert len(records) == 2
+        assert (
+            "Don't recognize the argument 'a_new_option'" in records[0].message.args[0]
+        )
+    assert (
+        "Don't recognize the argument 'a_second_new_option'"
+        in records[1].message.args[0]
+    )
+
+    linter.add_message("C0301", line=1, args=(1, 2))
+    linter.add_message(
+        "line-too-long", line=2, end_lineno=2, end_col_offset=4, args=(3, 4)
+    )
+
+    out_lines = output.getvalue().split("\n")
+    assert out_lines[1] == "my_mod:1::()"
+    assert out_lines[2] == "my_mod:2::()"
+
+
 def test_deprecation_set_output(recwarn):
     """TODO remove in 3.0"""
     reporter = BaseReporter()

diff --git a/doc/user_guide/output.rst b/doc/user_guide/output.rst
index 52959d3c7..1aa18f3b3 100644
--- a/doc/user_guide/output.rst
+++ b/doc/user_guide/output.rst
@@ -58,6 +58,10 @@ line
     line number
 column
     column number
+end_line
+    line number of the end of the node
+end_column
+    column number of the end of the node
 module
     module name
 obj
@@ -94,6 +98,9 @@ A few other examples:
 The ``--msg-template`` option can only be combined with text-based reporters (``--output-format`` either unspecified or one of: parseable, colorized or msvs).
 If both ``--output-format`` and ``--msg-template`` are specified, the ``--msg-template`` option will take precedence over the default line format defined by the reporter class.

+If ``end_line`` or ``end_column`` are ``None``, they will be represented as an empty string
+by the default ``TextReporter``.
+
 .. _Python new format syntax: https://docs.python.org/2/library/string.html#formatstrings

 Source code analysis section

diff --git a/doc/whatsnew/2.12.rst b/doc/whatsnew/2.12.rst
index dae98d485..473a3ba02 100644
--- a/doc/whatsnew/2.12.rst
+++ b/doc/whatsnew/2.12.rst
@@ -205,6 +205,11 @@ Other Changes

   Partially closes #5321

+* Add ability to add ``end_line`` and ``end_column`` to the ``--msg-template`` option.
+  With the standard ``TextReporter`` this will add the line and column number of the
+  end of a node to the output of Pylint. If these numbers are unknown, they are represented
+  by an empty string.
+
 * Introduced primer tests and a configuration tests framework. The helper classes available in
   ``pylint/testutil/`` are still unstable and might be modified in the near future.


diff --git a/doc/whatsnew/fragments/5362.feature b/doc/whatsnew/fragments/5362.feature
new file mode 100644
index 0000000000..b83d9397de
--- /dev/null
+++ b/doc/whatsnew/fragments/5362.feature
@@ -0,0 +1,3 @@
+Added the ``no-header`` output format. If enabled with ``--output-format=no-header``, it will not include the module name in the output.
+
+Closes #5362
diff --git a/pylint/reporters/text.py b/pylint/reporters/text.py
index bd6d4e9d1f..bc11ac4faf 100644
--- a/pylint/reporters/text.py
+++ b/pylint/reporters/text.py
@@ -150,6 +150,10 @@ def colorize_ansi(
     return msg

+def make_header(msg: Message) -> str:
+    return f"************* Module {msg.module}"
+
+
 class TextReporter(BaseReporter):
     """Reports messages and layouts in plain text."""

@@ -199,11 +203,8 @@ def write_message(self, msg: Message) -> None:
     def handle_message(self, msg: Message) -> None:
         """Manage message of different type and in the context of path."""
         if msg.module not in self._modules:
-            if msg.module:
-                self.writeln(f"************* Module {msg.module}")
-                self._modules.add(msg.module)
-            else:
-                self.writeln("************* ")
+            self.writeln(make_header(msg))
+            self._modules.add(msg.module)
         self.write_message(msg)

     def _display(self, layout: Section) -> None:
@@ -212,6 +213,18 @@ def _display(self, layout: Section) -> None:
         TextWriter().format(layout, self.out)

+class NoHeaderReporter(TextReporter):
+    """Reports messages and layouts in plain text without a module header."""
+
+    name = "no-header"
+
+    def handle_message(self, msg: Message) -> None:
+        """Write message(s) without module header."""
+        if msg.module not in self._modules:
+            self._modules.add(msg.module)
+        self.write_message(msg)
+
+
 class ParseableTextReporter(TextReporter):
     """A reporter very similar to TextReporter, but display messages in a form
     recognized by most text editors :
@@ -296,10 +309,7 @@ def handle_message(self, msg: Message) -> None:
         """
         if msg.module not in self._modules:
             msg_style = self._get_decoration("S")
-            if msg.module:
-                modsep = colorize_ansi(f"************* Module {msg.module}", msg_style)
-            else:
-                modsep = colorize_ansi(f"************* {msg.module}", msg_style)
+            modsep = colorize_ansi(make_header(msg), msg_style)
             self.writeln(modsep)
             self._modules.add(msg.module)
         msg_style = self._get_decoration(msg.C)
@@ -313,6 +323,7 @@ def handle_message(self, msg: Message) -> None:

 def register(linter: PyLinter) -> None:
     linter.register_reporter(TextReporter)
+    linter.register_reporter(NoHeaderReporter)
     linter.register_reporter(ParseableTextReporter)
     linter.register_reporter(VSTextReporter)
     linter.register_reporter(ColorizedTextReporter)

diff --git a/tests/test_self.py b/tests/test_self.py
index 61b417f0c0..9cec1b1c93 100644
--- a/tests/test_self.py
+++ b/tests/test_self.py
@@ -1278,6 +1278,18 @@ def test_line_too_long_useless_suppression(self) -> None:

         self._test_output([module, "--enable=all"], expected_output=expected)

+    def test_output_no_header(self) -> None:
+        module = join(HERE, "data", "clientmodule_test.py")
+        expected = "Unused variable 'local_variable'"
+        not_expected = textwrap.dedent(
+            """************* Module data.clientmodule_test"""
+        )
+
+        args = [module, "--output-format=no-header"]
+        self._test_output(
+            args, expected_output=expected, unexpected_output=not_expected
+        )
+

 class TestCallbackOptions:
     """Test for all callback options we support."""

tests/test_self.py::TestCallbackOptions::test_output_of_callback_options[command5-Pylint global options and switches]

tests/test_self.py::TestCallbackOptions::test_help_msg[args2---help-msg: expected at least one argumen-True]

tests/test_self.py::TestCallbackOptions::test_output_of_callback_options[command0-Emittable messages with current interpreter:]

tests/test_self.py::TestCallbackOptions::test_help_msg[args1-No such message id-False]

tests/test_self.py::TestRunTC::test_output_file_can_be_combined_with_output_format_option[text-tests/regrtest_data/unused_variable.py:4:4: W0612: Unused variable 'variable' (unused-variable)]

tests/test_self.py::TestRunTC::test_output_file_can_be_combined_with_output_format_option[parseable-tests/regrtest_data/unused_variable.py:4: [W0612(unused-variable), test] Unused variable 'variable']

diff --git a/doc/whatsnew/fragments/####.feature b/doc/whatsnew/fragments/####.feature
new file mode 100644
index 000000000..55cea6f59
--- /dev/null
+++ b/doc/whatsnew/fragments/####.feature
@@ -0,0 +1,1 @@
+Added the ``no-header`` output format. If enabled with ``--output-format=no-header``, it will not include the module name in the output.
\ No newline at end of file


diff --git a/ChangeLog b/ChangeLog
index 726ccc134e..52cb627f2e 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -54,6 +54,11 @@ Release date: TBA

   closes #5722

+* New extension ``import-private-name``: indicate imports of external private packages
+  and objects (prefixed with ``_``). It can be loaded using ``load-plugins=pylint.extensions.private_import``.
+
+  Closes #5463
+
 * Fixed crash from ``arguments-differ`` and ``arguments-renamed`` when methods were
   defined outside the top level of a class.

diff --git a/doc/whatsnew/2.13.rst b/doc/whatsnew/2.13.rst
index 130a9ac69b..d277d0d82d 100644
--- a/doc/whatsnew/2.13.rst
+++ b/doc/whatsnew/2.13.rst
@@ -76,6 +76,10 @@ Removed checkers

 Extensions
 ==========
+* New extension ``import-private-name``: indicate imports of external private packages
+  and objects (prefixed with ``_``). It can be loaded using ``load-plugins=pylint.extensions.private_import``.
+
+  Closes #5463

 * Pyreverse - add output in mermaid-js format and html which is an mermaid js diagram with html boilerplate

diff --git a/pylint/checkers/__init__.py b/pylint/checkers/__init__.py
index 160bad8af2..61ce67ffef 100644
--- a/pylint/checkers/__init__.py
+++ b/pylint/checkers/__init__.py
@@ -45,7 +45,8 @@
 24: non-ascii-names
 25: unicode
 26: unsupported_version
-27-50: not yet used: reserved for future internal checkers.
+27: private-import
+28-50: not yet used: reserved for future internal checkers.
 This file is not updated. Use
    script/get_unused_message_id_category.py
 to get the next free checker id.
diff --git a/pylint/checkers/utils.py b/pylint/checkers/utils.py
index 4e775e3d24..f53ae88456 100644
--- a/pylint/checkers/utils.py
+++ b/pylint/checkers/utils.py
@@ -1664,6 +1664,11 @@ def is_typing_guard(node: nodes.If) -> bool:
     ) and node.test.as_string().endswith("TYPE_CHECKING")

+def is_node_in_typing_guarded_import_block(node: nodes.NodeNG) -> bool:
+    """Return True if node is part for guarded `typing.TYPE_CHECKING` if block."""
+    return isinstance(node.parent, nodes.If) and is_typing_guard(node.parent)
+
+
 def is_node_in_guarded_import_block(node: nodes.NodeNG) -> bool:
     """Return True if node is part for guarded if block.

diff --git a/pylint/extensions/private_import.py b/pylint/extensions/private_import.py
new file mode 100644
index 0000000000..88033fa1e5
--- /dev/null
+++ b/pylint/extensions/private_import.py
@@ -0,0 +1,248 @@
+"""Check for imports on private external modules and names."""
+from pathlib import Path
+from typing import TYPE_CHECKING, Dict, List, Union
+
+from astroid import nodes
+
+from pylint.checkers import BaseChecker, utils
+from pylint.interfaces import HIGH, IAstroidChecker
+
+if TYPE_CHECKING:
+    from pylint.lint.pylinter import PyLinter
+
+
+class PrivateImportChecker(BaseChecker):
+
+    __implements__ = (IAstroidChecker,)
+    name = "import-private-name"
+    msgs = {
+        "C2701": (
+            "Imported private %s (%s)",
+            "import-private-name",
+            "Used when a private module or object prefixed with _ is imported. "
+            "PEP8 guidance on Naming Conventions states that public attributes with "
+            "leading underscores should be considered private.",
+        ),
+    }
+
+    def __init__(self, linter: "PyLinter") -> None:
+        BaseChecker.__init__(self, linter)
+
+        # A mapping of private names used as a type annotation to whether it is an acceptable import
+        self.all_used_type_annotations: Dict[str, bool] = {}
+        self.populated_annotations = False
+
+    @utils.check_messages("import-private-name")
+    def visit_import(self, node: nodes.Import) -> None:
+        if utils.is_node_in_typing_guarded_import_block(node):
+            return
+        names = [name[0] for name in node.names]
+        private_names = self._get_private_imports(names)
+        private_names = self._get_type_annotation_names(node, private_names)
+        if private_names:
+            imported_identifier = "modules" if len(private_names) > 1 else "module"
+            private_name_string = ", ".join(private_names)
+            self.add_message(
+                "import-private-name",
+                node=node,
+                args=(imported_identifier, private_name_string),
+                confidence=HIGH,
+            )
+
+    @utils.check_messages("import-private-name")
+    def visit_importfrom(self, node: nodes.ImportFrom) -> None:
+        if utils.is_node_in_typing_guarded_import_block(node):
+            return
+        # Only check imported names if the module is external
+        if self.same_root_dir(node, node.modname):
+            return
+
+        names = [n[0] for n in node.names]
+
+        # Check the imported objects first. If they are all valid type annotations, the package can be private
+        private_names = self._get_type_annotation_names(node, names)
+        if not private_names:
+            return
+
+        # There are invalid imported objects, so check the name of the package
+        private_module_imports = self._get_private_imports([node.modname])
+        private_module_imports = self._get_type_annotation_names(
+            node, private_module_imports
+        )
+        if private_module_imports:
+            self.add_message(
+                "import-private-name",
+                node=node,
+                args=("module", private_module_imports[0]),
+                confidence=HIGH,
+            )
+            return  # Do not emit messages on the objects if the package is private
+
+        private_names = self._get_private_imports(private_names)
+
+        if private_names:
+            imported_identifier = "objects" if len(private_names) > 1 else "object"
+            private_name_string = ", ".join(private_names)
+            self.add_message(
+                "import-private-name",
+                node=node,
+                args=(imported_identifier, private_name_string),
+                confidence=HIGH,
+            )
+
+    def _get_private_imports(self, names: List[str]) -> List[str]:
+        """Returns the private names from input names by a simple string check."""
+        return [name for name in names if self._name_is_private(name)]
+
+    @staticmethod
+    def _name_is_private(name: str) -> bool:
+        """Returns true if the name exists, starts with `_`, and if len(name) > 4
+        it is not a dunder, i.e. it does not begin and end with two underscores.
+        """
+        return (
+            bool(name)
+            and name[0] == "_"
+            and (len(name) <= 4 or name[1] != "_" or name[-2:] != "__")
+        )
+
+    def _get_type_annotation_names(
+        self, node: nodes.Import, names: List[str]
+    ) -> List[str]:
+        """Removes from names any names that are used as type annotations with no other illegal usages."""
+        if names and not self.populated_annotations:
+            self._populate_type_annotations(node.root(), self.all_used_type_annotations)
+            self.populated_annotations = True
+
+        return [
+            n
+            for n in names
+            if n not in self.all_used_type_annotations
+            or (
+                n in self.all_used_type_annotations
+                and not self.all_used_type_annotations[n]
+            )
+        ]
+
+    def _populate_type_annotations(
+        self, node: nodes.LocalsDictNodeNG, all_used_type_annotations: Dict[str, bool]
+    ) -> None:
+        """Adds into the dict `all_used_type_annotations` the names of all names ever used as a type annotation
+        in the scope and nested scopes of node and whether these names are only used for type checking.
+        """
+        for name in node.locals:
+            # If we find a private type annotation, make sure we do not mask illegal usages
+            private_name = None
+            # All the assignments using this variable that we might have to check for illegal usages later
+            name_assignments = []
+            for usage_node in node.locals[name]:
+                if isinstance(usage_node, nodes.AssignName) and isinstance(
+                    usage_node.parent, (nodes.AnnAssign, nodes.Assign)
+                ):
+                    assign_parent = usage_node.parent
+                    if isinstance(assign_parent, nodes.AnnAssign):
+                        name_assignments.append(assign_parent)
+                        private_name = self._populate_type_annotations_annotation(
+                            usage_node.parent.annotation, all_used_type_annotations
+                        )
+                    elif isinstance(assign_parent, nodes.Assign):
+                        name_assignments.append(assign_parent)
+
+                if isinstance(usage_node, nodes.FunctionDef):
+                    self._populate_type_annotations_function(
+                        usage_node, all_used_type_annotations
+                    )
+                if isinstance(usage_node, nodes.LocalsDictNodeNG):
+                    self._populate_type_annotations(
+                        usage_node, all_used_type_annotations
+                    )
+            if private_name is not None:
+                # Found a new private annotation, make sure we are not accessing it elsewhere
+                all_used_type_annotations[
+                    private_name
+                ] = self._assignments_call_private_name(name_assignments, private_name)
+
+    def _populate_type_annotations_function(
+        self, node: nodes.FunctionDef, all_used_type_annotations: Dict[str, bool]
+    ) -> None:
+        """Adds into the dict `all_used_type_annotations` the names of all names used as a type annotation
+        in the arguments and return type of the function node.
+        """
+        if node.args and node.args.annotations:
+            for annotation in node.args.annotations:
+                self._populate_type_annotations_annotation(
+                    annotation, all_used_type_annotations
+                )
+        if node.returns:
+            self._populate_type_annotations_annotation(
+                node.returns, all_used_type_annotations
+            )
+
+    def _populate_type_annotations_annotation(
+        self,
+        node: Union[nodes.Attribute, nodes.Subscript, nodes.Name],
+        all_used_type_annotations: Dict[str, bool],
+    ) -> Union[str, None]:
+        """Handles the possiblity of an annotation either being a Name, i.e. just type,
+        or a Subscript e.g. `Optional[type]` or an Attribute, e.g. `pylint.lint.linter`.
+        """
+        if isinstance(node, nodes.Name) and node.name not in all_used_type_annotations:
+            all_used_type_annotations[node.name] = True
+            return node.name
+        if isinstance(node, nodes.Subscript):  # e.g. Optional[List[str]]
+            # slice is the next nested type
+            self._populate_type_annotations_annotation(
+                node.slice, all_used_type_annotations
+            )
+            # value is the current type name: could be a Name or Attribute
+            return self._populate_type_annotations_annotation(
+                node.value, all_used_type_annotations
+            )
+        if isinstance(node, nodes.Attribute):
+            # An attribute is a type like `pylint.lint.pylinter`. node.expr is the next level up, could be another attribute
+            return self._populate_type_annotations_annotation(
+                node.expr, all_used_type_annotations
+            )
+        return None
+
+    @staticmethod
+    def _assignments_call_private_name(
+        assignments: List[Union[nodes.AnnAssign, nodes.Assign]], private_name: str
+    ) -> bool:
+        """Returns True if no assignments involve accessing `private_name`."""
+        if all(not assignment.value for assignment in assignments):
+            # Variable annotated but unassigned is unallowed because there may be a possible illegal access elsewhere
+            return False
+        for assignment in assignments:
+            current_attribute = None
+            if isinstance(assignment.value, nodes.Call):
+                current_attribute = assignment.value.func
+            elif isinstance(assignment.value, nodes.Attribute):
+                current_attribute = assignment.value
+            elif isinstance(assignment.value, nodes.Name):
+                current_attribute = assignment.value.name
+            if not current_attribute:
+                continue
+            while isinstance(current_attribute, (nodes.Attribute, nodes.Call)):
+                if isinstance(current_attribute, nodes.Call):
+                    current_attribute = current_attribute.func
+                current_attribute = current_attribute.expr
+            if (
+                isinstance(current_attribute, nodes.Name)
+                and current_attribute.name == private_name
+            ):
+                return False
+        return True
+
+    @staticmethod
+    def same_root_dir(node: nodes.Import, import_mod_name: str) -> bool:
+        """Does the node's file's path contain the base name of `import_mod_name`?"""
+        if not import_mod_name:  # from . import ...
+            return True
+
+        base_import_package = import_mod_name.split(".")[0]
+
+        return base_import_package in Path(node.root().file).parent.parts
+
+
+def register(linter: "PyLinter") -> None:
+    linter.register_checker(PrivateImportChecker(linter))

diff --git a/tests/extensions/test_private_import.py b/tests/extensions/test_private_import.py
new file mode 100644
index 0000000000..10648a8f91
--- /dev/null
+++ b/tests/extensions/test_private_import.py
@@ -0,0 +1,69 @@
+"""Tests the local module directory comparison logic which requires mocking file directories"""
+
+from unittest.mock import patch
+
+import astroid
+
+from pylint.extensions import private_import
+from pylint.interfaces import HIGH
+from pylint.testutils import CheckerTestCase, MessageTest
+
+
+class TestPrivateImport(CheckerTestCase):
+    """The mocked dirname is the directory of the file being linted, the node is code inside that file"""
+
+    CHECKER_CLASS = private_import.PrivateImportChecker
+
+    @patch("pathlib.Path.parent")
+    def test_internal_module(self, parent) -> None:
+        parent.parts = ("", "dir", "module")
+        import_from = astroid.extract_node("""from module import _file""")
+
+        with self.assertNoMessages():
+            self.checker.visit_importfrom(import_from)
+
+    @patch("pathlib.Path.parent")
+    def test_external_module_nested(self, parent) -> None:
+        parent.parts = ("", "dir", "module", "module_files", "util")
+
+        import_from = astroid.extract_node("""from module import _file""")
+
+        with self.assertNoMessages():
+            self.checker.visit_importfrom(import_from)
+
+    @patch("pathlib.Path.parent")
+    def test_external_module_dot_import(self, parent) -> None:
+        parent.parts = ("", "dir", "outer", "inner", "module_files", "util")
+
+        import_from = astroid.extract_node("""from outer.inner import _file""")
+
+        with self.assertNoMessages():
+            self.checker.visit_importfrom(import_from)
+
+    @patch("pathlib.Path.parent")
+    def test_external_module_dot_import_outer_only(self, parent) -> None:
+        parent.parts = ("", "dir", "outer", "extensions")
+
+        import_from = astroid.extract_node("""from outer.inner import _file""")
+
+        with self.assertNoMessages():
+            self.checker.visit_importfrom(import_from)
+
+    @patch("pathlib.Path.parent")
+    def test_external_module(self, parent) -> None:
+        parent.parts = ("", "dir", "other")
+
+        import_from = astroid.extract_node("""from module import _file""")
+
+        msg = MessageTest(
+            msg_id="import-private-name",
+            node=import_from,
+            line=1,
+            col_offset=0,
+            end_line=1,
+            end_col_offset=24,
+            args=("object", "_file"),
+            confidence=HIGH,
+        )
+        with self.assertAddsMessages(msg):
+            self.checker.visit_importfrom(import_from)
diff --git a/tests/functional/ext/private_import/private_import.py b/tests/functional/ext/private_import/private_import.py
new file mode 100644
index 0000000000..52ef405c90
--- /dev/null
+++ b/tests/functional/ext/private_import/private_import.py
@@ -0,0 +1,121 @@
+"""Tests for import-private-name."""
+# pylint: disable=unused-import, missing-docstring, reimported, import-error, wrong-import-order
+# pylint: disable=no-name-in-module, multiple-imports, ungrouped-imports, misplaced-future
+# pylint: disable=wrong-import-position
+
+# Basic cases
+from _world import hello  # [import-private-name]
+from _world import _hello  # [import-private-name]
+from city import _house  # [import-private-name]
+from city import a, _b, c, _d  # [import-private-name]
+from _city import a, _b, c, _d  # [import-private-name]
+from city import a, b, c, _d  # [import-private-name]
+import house
+import _house  # [import-private-name]
+import _house, _chair, _stair  # [import-private-name]
+import house, _chair, _stair  # [import-private-name]
+
+# Ignore dunders
+import __asd__
+import __future__
+from __future__ import print_function
+from __future__ import __print_function__
+
+# Ignore local modules
+# The check for local modules compares directory names in the path of the file being linted to
+# the name of the module we are importing from. The use of `__init__.py` to indicate Python modules
+# is deprecated so this is a heuristic solution.
+# If we were importing from `pylint`, it would be counted as a valid internal private import
+# and not emit a message as long as this file has a parent directory called `pylint`, even though
+# we are not importing from that directory. (We would be importing from `pylint/pylint`.)
+from private_import import _private  # pylint: disable=import-self
+from private_import.other_file import _private
+from . import _private
+from astroid import _private  # [import-private-name]
+from sys import _private  # [import-private-name]
+
+# Ignore typecheck
+from typing import TYPE_CHECKING, List, Optional
+
+if TYPE_CHECKING:
+    import _TreeType
+    from types import _TreeType
+    from _types import TreeType
+    from _types import _TreeType
+
+# No error since imports are used as type annotations
+from classes import _PrivateClassA, safe_get_A
+from classes import _PrivateClassB
+from classes import _PrivateClassC
+
+a_var: _PrivateClassA = safe_get_A()
+
+def b_func(class_b: _PrivateClassB):
+    print(class_b)
+
+def c_func() -> _PrivateClassC:
+    return None
+
+# Used as typing in slices
+from classes import _SubScriptA
+from classes import _SubScriptB
+
+a: Optional[_SubScriptA]
+b: Optional[_SubScriptB[List]]
+
+import _TypeContainerA
+import _TypeContainerB
+import _TypeContainerC
+
+import SafeContainerA
+a2: _TypeContainerA.A = SafeContainerA.safe_get_a()
+
+def b_func2(class_b2: _TypeContainerB.B):
+    print(class_b2)
+
+def c2_func() -> _TypeContainerC.C:
+    return None
+
+# This is allowed since all the imports are used for typing
+from _TypeContainerExtra import TypeExtraA, TypeExtraB
+from MakerContainerExtra import GetA, GetB
+extraA: TypeExtraA = GetA()
+extraB: TypeExtraB = GetB()
+
+# This is not allowed because there is an import not used for typing
+from _TypeContainerExtra2 import TypeExtra2, NotTypeExtra # [import-private-name]
+extra2: TypeExtra2
+
+# Try many cases to ensure that type annotation usages of a private import
+# do not mask other illegal usages of the import
+import _private_module # [import-private-name]
+my_var: _private_module.Thing = _private_module.Thing()
+
+import _private_module2 # [import-private-name]
+my_var2: _private_module2.Thing2
+my_var2 = _private_module2.Thing2()
+
+import _private_module3 # [import-private-name]
+my_var3: _private_module3.Thing3
+my_var3 = _private_module3.Thing3
+my_var3_2: _private_module3.Thing3
+
+import _private_module4 # [import-private-name]
+my_var4: _private_module4.Thing4
+my_var4 = _private_module4.get_callers().get_thing4()
+
+from _private_module5 import PrivateClass # [import-private-name]
+my_var5: PrivateClass
+my_var5 = PrivateClass()
+
+from _private_module6 import PrivateClass2 # [import-private-name]
+my_var6: PrivateClass2 = PrivateClass2()
+
+from public_module import _PrivateClass3 # [import-private-name]
+my_var7: _PrivateClass3 = _PrivateClass3()
+
+# Even though we do not see the private call, the type check does not keep us from emitting
+# because we do not use that variable
+import _private_module_unreachable # [import-private-name]
+my_var8: _private_module_unreachable.Thing8
+_private_module_unreachable.Thing8()
diff --git a/tests/functional/ext/private_import/private_import.rc b/tests/functional/ext/private_import/private_import.rc
new file mode 100644
index 0000000000..c9bbc23f1f
--- /dev/null
+++ b/tests/functional/ext/private_import/private_import.rc
@@ -0,0 +1,2 @@
+[MASTER]
+load-plugins=pylint.extensions.private_import,
diff --git a/tests/functional/ext/private_import/private_import.txt b/tests/functional/ext/private_import/private_import.txt
new file mode 100644
index 0000000000..f618d58587
--- /dev/null
+++ b/tests/functional/ext/private_import/private_import.txt
@@ -0,0 +1,20 @@
+import-private-name:7:0:7:24::Imported private module (_world):HIGH
+import-private-name:8:0:8:25::Imported private module (_world):HIGH
+import-private-name:9:0:9:23::Imported private object (_house):HIGH
+import-private-name:10:0:10:29::Imported private objects (_b, _d):HIGH
+import-private-name:11:0:11:30::Imported private module (_city):HIGH
+import-private-name:12:0:12:28::Imported private object (_d):HIGH
+import-private-name:14:0:14:13::Imported private module (_house):HIGH
+import-private-name:15:0:15:29::Imported private modules (_house, _chair, _stair):HIGH
+import-private-name:16:0:16:28::Imported private modules (_chair, _stair):HIGH
+import-private-name:34:0:34:28::Imported private object (_private):HIGH
+import-private-name:35:0:35:24::Imported private object (_private):HIGH
+import-private-name:86:0:86:57::Imported private module (_TypeContainerExtra2):HIGH
+import-private-name:91:0:91:22::Imported private module (_private_module):HIGH
+import-private-name:94:0:94:23::Imported private module (_private_module2):HIGH
+import-private-name:98:0:98:23::Imported private module (_private_module3):HIGH
+import-private-name:103:0:103:23::Imported private module (_private_module4):HIGH
+import-private-name:107:0:107:41::Imported private module (_private_module5):HIGH
+import-private-name:111:0:111:42::Imported private module (_private_module6):HIGH
+import-private-name:114:0:114:40::Imported private object (_PrivateClass3):HIGH
+import-private-name:119:0:119:34::Imported private module (_private_module_unreachable):HIGH

diff --git a/doc/whatsnew/2.13.rst b/doc/whatsnew/2.13.rst
index 130a9ac69..56f718726 100644
--- a/doc/whatsnew/2.13.rst
+++ b/doc/whatsnew/2.13.rst
@@ -76,6 +76,8 @@ Removed checkers

 Extensions
 ==========
+* New extension ``import-private-name``: indicate imports of external private packages
+  and objects (prefixed with ``_``). It can be loaded using ``load-plugins=pylint.extensions.private_import``.

 * Pyreverse - add output in mermaid-js format and html which is an mermaid js diagram with html boilerplate

If completing this task requires creating new files, classes, fields, or error messages, you may consider using the following suggested entity names:
[{'type': 'field', 'name': 'parts'}, {'type': 'field', 'name': 'typing'}, {'type': 'field', 'name': 'PrivateImportChecker'}, {'type': 'field', 'name': 'astroid'}, {'type': 'field', 'name': 'node'}, {'type': 'field', 'name': 'self'}, {'type': 'field', 'name': 'parent'}, {'type': 'field', 'name': 'HIGH'}, {'type': 'function', 'name': 'visit_importfrom'}, {'type': 'field', 'name': 'args'}, {'type': 'file', 'name': 'pylint/extensions/private_import.py'}, {'type': 'field', 'name': 'confidence'}, {'type': 'field', 'name': 'List'}, {'type': 'class', 'name': 'PrivateImportChecker'}, {'type': 'field', 'name': 'interfaces'}, {'type': 'field', 'name': 'TYPE_CHECKING'}]