Skip to content

Commit 451b675

Browse files
committed
feat: Support linting in out-of-source directories
1 parent 2879d0e commit 451b675

File tree

11 files changed

+90
-19
lines changed

11 files changed

+90
-19
lines changed

CONTRIBUTORS.txt

+1
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,7 @@ contributors:
339339
- kdestin <[email protected]>
340340
- jaydesl <[email protected]>
341341
342+
342343
- gracejiang16 <[email protected]>
343344
- glmdgrielson <[email protected]>
344345
- glegoux <[email protected]>

doc/user_guide/usage/run.rst

+14-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ you can give it a file name if it's possible to guess a module name from the fil
3939
path using the python path. Some examples:
4040

4141
``pylint mymodule.py`` should always work since the current working
42-
directory is automatically added on top of the python path
42+
directory is automatically added on top of the python path.
4343

4444
``pylint directory/mymodule.py`` will work if: ``directory`` is a python
4545
package (i.e. has an ``__init__.py`` file), an implicit namespace package
@@ -52,6 +52,19 @@ If the analyzed sources use implicit namespace packages (PEP 420), the source ro
5252
be specified using the ``--source-roots`` option. Otherwise, the package names are
5353
detected incorrectly, since implicit namespace packages don't contain an ``__init__.py``.
5454

55+
In out-of-source directories
56+
----------------------------
57+
58+
If you are analyzing a file that is not located under the main source directory of your
59+
project but needs to import modules from there, for instance and most prominantly a test
60+
file in ``tests/``, you can use ``--pythonpath`` to add the main source directory to the
61+
python path.
62+
For example, if your project features a directory layout with a dedicated source
63+
directory ``src/`` and a test directory ``tests/`` at the top level, you can use
64+
``--pythonpath=src`` (or the appropriate configuration setting) to successfully lint
65+
your tests.
66+
67+
5568
Globbing support
5669
----------------
5770

doc/whatsnew/fragments/9507.feature

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Support linting in out-of-source directories with new main.pythonpath argument that adds relative or absolute paths to sys.path.
2+
3+
Refs #9507
4+
Refs #7357
5+
Refs #5644

pylint/lint/__init__.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@
2727
report_total_messages_stats,
2828
)
2929
from pylint.lint.run import Run
30-
from pylint.lint.utils import _augment_sys_path, augmented_sys_path
30+
from pylint.lint.utils import (
31+
_augment_sys_path,
32+
augmented_sys_path,
33+
realpath_transformer,
34+
)
3135

3236
__all__ = [
3337
"check_parallel",
@@ -39,6 +43,7 @@
3943
"ArgumentPreprocessingError",
4044
"_augment_sys_path",
4145
"augmented_sys_path",
46+
"realpath_transformer",
4247
"discover_package_path",
4348
"save_results",
4449
"load_results",

pylint/lint/base_options.py

+10
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,16 @@ def _make_linter_options(linter: PyLinter) -> Options:
377377
),
378378
},
379379
),
380+
(
381+
"pythonpath",
382+
{
383+
"type": "glob_paths_csv",
384+
"metavar": "<path>[,<path>...]",
385+
"default": (),
386+
"help": "Add paths to sys.path. Supports globbing patterns. Paths are absolute "
387+
"or relative to the current working directory.",
388+
},
389+
),
380390
(
381391
"ignored-modules",
382392
{

pylint/lint/parallel.py

+6-8
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,12 @@
3636

3737

3838
def _worker_initialize(
39-
linter: bytes, extra_packages_paths: Sequence[str] | None = None
39+
linter: bytes, extra_sys_paths: Sequence[str] | None = None
4040
) -> None:
4141
"""Function called to initialize a worker for a Process within a concurrent Pool.
4242
4343
:param linter: A linter-class (PyLinter) instance pickled with dill
44-
:param extra_packages_paths: Extra entries to be added to `sys.path`
44+
:param extra_sys_paths: Extra entries to be added to `sys.path`
4545
"""
4646
global _worker_linter # pylint: disable=global-statement
4747
_worker_linter = dill.loads(linter)
@@ -57,8 +57,8 @@ def _worker_initialize(
5757
_worker_linter.load_plugin_modules(_worker_linter._dynamic_plugins, force=True)
5858
_worker_linter.load_plugin_configuration()
5959

60-
if extra_packages_paths:
61-
_augment_sys_path(extra_packages_paths)
60+
if extra_sys_paths:
61+
_augment_sys_path(extra_sys_paths)
6262

6363

6464
def _worker_check_single_file(
@@ -125,7 +125,7 @@ def check_parallel(
125125
linter: PyLinter,
126126
jobs: int,
127127
files: Iterable[FileItem],
128-
extra_packages_paths: Sequence[str] | None = None,
128+
extra_sys_paths: Sequence[str] | None = None,
129129
) -> None:
130130
"""Use the given linter to lint the files with given amount of workers (jobs).
131131
@@ -135,9 +135,7 @@ def check_parallel(
135135
# The linter is inherited by all the pool's workers, i.e. the linter
136136
# is identical to the linter object here. This is required so that
137137
# a custom PyLinter object can be used.
138-
initializer = functools.partial(
139-
_worker_initialize, extra_packages_paths=extra_packages_paths
140-
)
138+
initializer = functools.partial(_worker_initialize, extra_sys_paths=extra_sys_paths)
141139
with ProcessPoolExecutor(
142140
max_workers=jobs, initializer=initializer, initargs=(dill.dumps(linter),)
143141
) as executor:

pylint/lint/pylinter.py

+8-3
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
augmented_sys_path,
5353
get_fatal_error_message,
5454
prepare_crash_report,
55+
realpath_transformer,
5556
)
5657
from pylint.message import Message, MessageDefinition, MessageDefinitionStore
5758
from pylint.reporters.base_reporter import BaseReporter
@@ -671,6 +672,10 @@ def check(self, files_or_modules: Sequence[str]) -> None:
671672
for file_or_module in files_or_modules
672673
}
673674
)
675+
# Prefer package paths detected per module over user-defined PYTHONPATH additions
676+
extra_sys_paths = extra_packages_paths + realpath_transformer(
677+
self.config.pythonpath
678+
)
674679

675680
# TODO: Move the parallel invocation into step 3 of the checking process
676681
if not self.config.from_stdin and self.config.jobs > 1:
@@ -679,13 +684,13 @@ def check(self, files_or_modules: Sequence[str]) -> None:
679684
self,
680685
self.config.jobs,
681686
self._iterate_file_descrs(files_or_modules),
682-
extra_packages_paths,
687+
extra_sys_paths,
683688
)
684689
sys.path = original_sys_path
685690
return
686691

687692
# 1) Get all FileItems
688-
with augmented_sys_path(extra_packages_paths):
693+
with augmented_sys_path(extra_sys_paths):
689694
if self.config.from_stdin:
690695
fileitems = self._get_file_descr_from_stdin(files_or_modules[0])
691696
data: str | None = _read_stdin()
@@ -694,7 +699,7 @@ def check(self, files_or_modules: Sequence[str]) -> None:
694699
data = None
695700

696701
# The contextmanager also opens all checkers and sets up the PyLinter class
697-
with augmented_sys_path(extra_packages_paths):
702+
with augmented_sys_path(extra_sys_paths):
698703
with self._astroid_module_checker() as check_astroid_module:
699704
# 2) Get the AST for each FileItem
700705
ast_per_fileitem = self._get_asts(fileitems, data)

pylint/lint/utils.py

+5
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,11 @@ def get_fatal_error_message(filepath: str, issue_template_path: Path) -> str:
112112
)
113113

114114

115+
def realpath_transformer(paths: Sequence[str]) -> list[str]:
116+
"""Transforms paths to real paths while expanding user vars."""
117+
return [str(Path(path).resolve().expanduser()) for path in paths]
118+
119+
115120
def _augment_sys_path(additional_paths: Sequence[str]) -> list[str]:
116121
original = list(sys.path)
117122
changes = []

pylint/pyreverse/main.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from pylint.config.arguments_manager import _ArgumentsManager
1515
from pylint.config.arguments_provider import _ArgumentsProvider
1616
from pylint.lint import discover_package_path
17-
from pylint.lint.utils import augmented_sys_path
17+
from pylint.lint.utils import augmented_sys_path, realpath_transformer
1818
from pylint.pyreverse import writer
1919
from pylint.pyreverse.diadefslib import DiadefsHandler
2020
from pylint.pyreverse.inspector import Linker, project_from_files
@@ -304,7 +304,11 @@ def run(self, args: list[str]) -> int:
304304
extra_packages_paths = list(
305305
{discover_package_path(arg, self.config.source_roots) for arg in args}
306306
)
307-
with augmented_sys_path(extra_packages_paths):
307+
# Prefer package paths detected per module over global PYTHONPATH additions
308+
extra_sys_paths = extra_packages_paths + realpath_transformer(
309+
self.config.pythonpath
310+
)
311+
with augmented_sys_path(extra_sys_paths):
308312
project = project_from_files(
309313
args,
310314
project_name=self.config.project,

tests/lint/unittest_lint.py

+27
Original file line numberDiff line numberDiff line change
@@ -1250,6 +1250,33 @@ def test_import_sibling_module_from_namespace(initialized_linter: PyLinter) -> N
12501250
assert not linter.stats.by_msg
12511251

12521252

1253+
def test_import_external_module_with_relative_pythonpath_config(
1254+
initialized_linter: PyLinter,
1255+
) -> None:
1256+
"""Given a module that imports an external module, ensure that the external module
1257+
is found when the path to the external module is configured in `main.pythonpath`.
1258+
1259+
Note: The setup is similar to `test_import_sibling_module_from_namespace` but the
1260+
manual sys.path setup is replaced with a `main.pythonpath` configuration.
1261+
"""
1262+
linter = initialized_linter
1263+
with tempdir() as tmpdir:
1264+
create_files(["namespace_main/module.py", "namespace_ext/ext_module.py"])
1265+
main_path = Path("namespace_main/module.py")
1266+
with open(main_path, "w", encoding="utf-8") as f:
1267+
f.write(
1268+
"""\"\"\"This module imports ext_module.\"\"\"
1269+
import ext_module
1270+
print(ext_module)
1271+
"""
1272+
)
1273+
1274+
os.chdir(tmpdir)
1275+
linter.config.pythonpath = ["namespace_ext"]
1276+
linter.check(["namespace_main/module.py"])
1277+
assert not linter.stats.by_msg
1278+
1279+
12531280
def test_lint_namespace_package_under_dir(initialized_linter: PyLinter) -> None:
12541281
"""Regression test for https://github.com/pylint-dev/pylint/issues/1667."""
12551282
linter = initialized_linter

tests/test_check_parallel.py

+2-4
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,7 @@ def test_worker_initialize(self) -> None:
186186
def test_worker_initialize_with_package_paths(self) -> None:
187187
linter = PyLinter(reporter=Reporter())
188188
with augmented_sys_path([]):
189-
worker_initialize(
190-
linter=dill.dumps(linter), extra_packages_paths=["fake-path"]
191-
)
189+
worker_initialize(linter=dill.dumps(linter), extra_sys_paths=["fake-path"])
192190
assert "fake-path" in sys.path
193191

194192
def test_worker_initialize_reregisters_custom_plugins(self) -> None:
@@ -629,7 +627,7 @@ def test_no_deadlock_due_to_initializer_error(self) -> None:
629627
files=iter(single_file_container),
630628
# This will trigger an exception in the initializer for the parallel jobs
631629
# because arguments has to be an Iterable.
632-
extra_packages_paths=1, # type: ignore[arg-type]
630+
extra_sys_paths=1, # type: ignore[arg-type]
633631
)
634632

635633
@pytest.mark.needs_two_cores

0 commit comments

Comments
 (0)