Skip to content

Commit 366c597

Browse files
committed
Support Implicit Namespace Packages (PEP 420)
1 parent afe867a commit 366c597

File tree

29 files changed

+293
-56
lines changed

29 files changed

+293
-56
lines changed

doc/user_guide/configuration/all-options.rst

+7
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,13 @@ Standard Checkers
174174
**Default:** ``(3, 10)``
175175

176176

177+
--source-roots
178+
""""""""""""""
179+
*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.*
180+
181+
**Default:** ``()``
182+
183+
177184
--recursive
178185
"""""""""""
179186
*Discover python modules and packages in the file system subtree.*

doc/user_guide/usage/run.rst

+7
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ directory is automatically added on top of the python path
4545
package (i.e. has an ``__init__.py`` file), an implicit namespace package
4646
or if ``directory`` is in the python path.
4747

48+
With implicit namespace packages
49+
--------------------------------
50+
51+
If the analyzed sources use the Implicit Namespace Packages (PEP 420), the source root(s) should
52+
be specified to Pylint using the ``--source-roots`` option. Otherwise, the package names are
53+
detected incorrectly, since the Implicit Namespace Packages don't contain the ``__init__.py``.
54+
4855
Command line options
4956
--------------------
5057

doc/whatsnew/fragments/8154.feature

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Support Implicit Namespace Packages (PEP 420).
2+
3+
Closes #8154

examples/pylintrc

+5
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ persistent=yes
9090
# the version used to run pylint.
9191
py-version=3.10
9292

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

pylint/config/argument.py

+11
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,16 @@ def _path_transformer(value: str) -> str:
8888
return os.path.expandvars(os.path.expanduser(value))
8989

9090

91+
def _paths_csv_transformer(value: str) -> Sequence[str]:
92+
"""Transforms a comma separated list of paths while expanding user and
93+
variables.
94+
"""
95+
paths: list[str] = []
96+
for path in _csv_transformer(value):
97+
paths.append(os.path.expandvars(os.path.expanduser(path)))
98+
return paths
99+
100+
91101
def _py_version_transformer(value: str) -> tuple[int, ...]:
92102
"""Transforms a version string into a version tuple."""
93103
try:
@@ -138,6 +148,7 @@ def _regexp_paths_csv_transfomer(value: str) -> Sequence[Pattern[str]]:
138148
"confidence": _confidence_transformer,
139149
"non_empty_string": _non_empty_string_transformer,
140150
"path": _path_transformer,
151+
"paths_csv": _paths_csv_transformer,
141152
"py_version": _py_version_transformer,
142153
"regexp": _regex_transformer,
143154
"regexp_csv": _regexp_csv_transfomer,

pylint/config/option.py

+3
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ def _py_version_validator(_: Any, name: str, value: Any) -> tuple[int, int, int]
117117
"string": utils._unquote,
118118
"int": int,
119119
"float": float,
120+
"paths_csv": _csv_validator,
120121
"regexp": lambda pattern: re.compile(pattern or ""),
121122
"regexp_csv": _regexp_csv_validator,
122123
"regexp_paths_csv": _regexp_paths_csv_validator,
@@ -163,6 +164,7 @@ def _validate(value: Any, optdict: Any, name: str = "") -> Any:
163164
# pylint: disable=no-member
164165
class Option(optparse.Option):
165166
TYPES = optparse.Option.TYPES + (
167+
"paths_csv",
166168
"regexp",
167169
"regexp_csv",
168170
"regexp_paths_csv",
@@ -175,6 +177,7 @@ class Option(optparse.Option):
175177
)
176178
ATTRS = optparse.Option.ATTRS + ["hide", "level"]
177179
TYPE_CHECKER = copy.copy(optparse.Option.TYPE_CHECKER)
180+
TYPE_CHECKER["paths_csv"] = _csv_validator
178181
TYPE_CHECKER["regexp"] = _regexp_validator
179182
TYPE_CHECKER["regexp_csv"] = _regexp_csv_validator
180183
TYPE_CHECKER["regexp_paths_csv"] = _regexp_paths_csv_validator

pylint/lint/__init__.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from pylint.config.exceptions import ArgumentPreprocessingError
2020
from pylint.lint.caching import load_results, save_results
21+
from pylint.lint.expand_modules import discover_package_path
2122
from pylint.lint.parallel import check_parallel
2223
from pylint.lint.pylinter import PyLinter
2324
from pylint.lint.report_functions import (
@@ -26,7 +27,12 @@
2627
report_total_messages_stats,
2728
)
2829
from pylint.lint.run import Run
29-
from pylint.lint.utils import _patch_sys_path, fix_import_path
30+
from pylint.lint.utils import (
31+
_augment_sys_path,
32+
_patch_sys_path,
33+
augmented_sys_path,
34+
fix_import_path,
35+
)
3036

3137
__all__ = [
3238
"check_parallel",
@@ -38,6 +44,9 @@
3844
"ArgumentPreprocessingError",
3945
"_patch_sys_path",
4046
"fix_import_path",
47+
"_augment_sys_path",
48+
"augmented_sys_path",
49+
"discover_package_path",
4150
"save_results",
4251
"load_results",
4352
]

pylint/lint/base_options.py

+11
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,17 @@ def _make_linter_options(linter: PyLinter) -> Options:
343343
),
344344
},
345345
),
346+
(
347+
"source-roots",
348+
{
349+
"type": "paths_csv",
350+
"metavar": "<path>[,<path>...]",
351+
"default": (),
352+
"help": "Add paths to the list of the source roots. The source root is an absolute "
353+
"path or a path relative to the current working directory used to "
354+
"determine a package namespace for modules located under the source root.",
355+
},
356+
),
346357
(
347358
"recursive",
348359
{

pylint/lint/expand_modules.py

+26-7
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import os
88
import sys
9+
import warnings
910
from collections.abc import Sequence
1011
from re import Pattern
1112

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

2526

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

30-
This is not true since python 3.3 and is causing problem.
31-
"""
32-
dirname = os.path.realpath(os.path.expanduser(filepath))
38+
39+
def discover_package_path(modulepath: str, source_roots: Sequence[str]) -> str:
40+
"""Discover package path from one its modules and source roots."""
41+
dirname = os.path.realpath(os.path.expanduser(modulepath))
3342
if not os.path.isdir(dirname):
3443
dirname = os.path.dirname(dirname)
44+
45+
# Look for a source root that contains the module directory
46+
for source_root in source_roots:
47+
source_root = os.path.realpath(os.path.expanduser(source_root))
48+
if os.path.commonpath([source_root, dirname]) == source_root:
49+
return source_root
50+
51+
# Fall back to legacy discovery by looking for __init__.py upwards as
52+
# it's the only way given that source root was not found or was not provided
3553
while True:
3654
if not os.path.exists(os.path.join(dirname, "__init__.py")):
3755
return dirname
@@ -64,6 +82,7 @@ def _is_ignored_file(
6482
# pylint: disable = too-many-locals, too-many-statements
6583
def expand_modules(
6684
files_or_modules: Sequence[str],
85+
source_roots: Sequence[str],
6786
ignore_list: list[str],
6887
ignore_list_re: list[Pattern[str]],
6988
ignore_list_paths_re: list[Pattern[str]],
@@ -81,8 +100,8 @@ def expand_modules(
81100
something, ignore_list, ignore_list_re, ignore_list_paths_re
82101
):
83102
continue
84-
module_path = get_python_path(something)
85-
additional_search_path = [".", module_path] + path
103+
module_package_path = discover_package_path(something, source_roots)
104+
additional_search_path = [".", module_package_path] + path
86105
if os.path.exists(something):
87106
# this is a file or a directory
88107
try:

pylint/lint/parallel.py

+9-7
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import dill
1414

1515
from pylint import reporters
16-
from pylint.lint.utils import _patch_sys_path
16+
from pylint.lint.utils import _augment_sys_path
1717
from pylint.message import Message
1818
from pylint.typing import FileItem
1919
from pylint.utils import LinterStats, merge_stats
@@ -37,12 +37,12 @@
3737

3838

3939
def _worker_initialize(
40-
linter: bytes, arguments: None | str | Sequence[str] = None
40+
linter: bytes, extra_packages_paths: Sequence[str] | None = None
4141
) -> None:
4242
"""Function called to initialize a worker for a Process within a concurrent Pool.
4343
4444
:param linter: A linter-class (PyLinter) instance pickled with dill
45-
:param arguments: File or module name(s) to lint and to be added to sys.path
45+
:param extra_packages_paths: Extra entries to be added to sys.path
4646
"""
4747
global _worker_linter # pylint: disable=global-statement
4848
_worker_linter = dill.loads(linter)
@@ -53,8 +53,8 @@ def _worker_initialize(
5353
_worker_linter.set_reporter(reporters.CollectingReporter())
5454
_worker_linter.open()
5555

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

5959

6060
def _worker_check_single_file(
@@ -130,7 +130,7 @@ def check_parallel(
130130
linter: PyLinter,
131131
jobs: int,
132132
files: Iterable[FileItem],
133-
arguments: None | str | Sequence[str] = None,
133+
extra_packages_paths: Sequence[str] | None = None,
134134
) -> None:
135135
"""Use the given linter to lint the files with given amount of workers (jobs).
136136
@@ -140,7 +140,9 @@ def check_parallel(
140140
# The linter is inherited by all the pool's workers, i.e. the linter
141141
# is identical to the linter object here. This is required so that
142142
# a custom PyLinter object can be used.
143-
initializer = functools.partial(_worker_initialize, arguments=arguments)
143+
initializer = functools.partial(
144+
_worker_initialize, extra_packages_paths=extra_packages_paths
145+
)
144146
with ProcessPoolExecutor(
145147
max_workers=jobs, initializer=initializer, initargs=(dill.dumps(linter),)
146148
) as executor:

pylint/lint/pylinter.py

+21-7
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@
3636
from pylint.interfaces import HIGH
3737
from pylint.lint.base_options import _make_linter_options
3838
from pylint.lint.caching import load_results, save_results
39-
from pylint.lint.expand_modules import _is_ignored_file, expand_modules
39+
from pylint.lint.expand_modules import (
40+
_is_ignored_file,
41+
discover_package_path,
42+
expand_modules,
43+
)
4044
from pylint.lint.message_state_handler import _MessageStateHandler
4145
from pylint.lint.parallel import check_parallel
4246
from pylint.lint.report_functions import (
@@ -46,7 +50,7 @@
4650
)
4751
from pylint.lint.utils import (
4852
_is_relative_to,
49-
fix_import_path,
53+
augmented_sys_path,
5054
get_fatal_error_message,
5155
prepare_crash_report,
5256
)
@@ -675,20 +679,27 @@ def check(self, files_or_modules: Sequence[str] | str) -> None:
675679
"Missing filename required for --from-stdin"
676680
)
677681

682+
extra_packages_paths = list(
683+
{
684+
discover_package_path(file_or_module, self.config.source_roots)
685+
for file_or_module in files_or_modules
686+
}
687+
)
688+
678689
# TODO: Move the parallel invocation into step 5 of the checking process
679690
if not self.config.from_stdin and self.config.jobs > 1:
680691
original_sys_path = sys.path[:]
681692
check_parallel(
682693
self,
683694
self.config.jobs,
684695
self._iterate_file_descrs(files_or_modules),
685-
files_or_modules, # this argument patches sys.path
696+
extra_packages_paths,
686697
)
687698
sys.path = original_sys_path
688699
return
689700

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

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

887-
def _expand_files(self, modules: Sequence[str]) -> dict[str, ModuleDescriptionDict]:
898+
def _expand_files(
899+
self, files_or_modules: Sequence[str]
900+
) -> dict[str, ModuleDescriptionDict]:
888901
"""Get modules and errors from a list of modules and handle errors."""
889902
result, errors = expand_modules(
890-
modules,
903+
files_or_modules,
904+
self.config.source_roots,
891905
self.config.ignore,
892906
self.config.ignore_patterns,
893907
self._ignore_paths,

0 commit comments

Comments
 (0)