Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Implicit Namespace Packages (PEP 420) #8153

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 7 additions & 0 deletions doc/user_guide/configuration/all-options.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand Down
7 changes: 7 additions & 0 deletions doc/user_guide/usage/run.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------------------

Expand Down
3 changes: 3 additions & 0 deletions doc/whatsnew/fragments/8154.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Support implicit namespace packages (PEP 420).

Closes #8154
5 changes: 5 additions & 0 deletions examples/pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions pylint/config/argument.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions pylint/config/option.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down
11 changes: 10 additions & 1 deletion pylint/lint/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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",
Expand All @@ -38,6 +44,9 @@
"ArgumentPreprocessingError",
"_patch_sys_path",
"fix_import_path",
"_augment_sys_path",
"augmented_sys_path",
"discover_package_path",
"save_results",
"load_results",
]
Expand Down
11 changes: 11 additions & 0 deletions pylint/lint/base_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
{
Expand Down
33 changes: 26 additions & 7 deletions pylint/lint/expand_modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

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

Expand All @@ -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
Expand Down Expand Up @@ -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]],
Expand All @@ -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:
Expand Down
16 changes: 9 additions & 7 deletions pylint/lint/parallel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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).
Expand All @@ -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:
Expand Down
28 changes: 21 additions & 7 deletions pylint/lint/pylinter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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,
)
Expand Down Expand Up @@ -675,20 +679,27 @@ 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[:]
check_parallel(
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()
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down