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

Patch sys.path on a per-file basis #7357

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions doc/whatsnew/fragments/7339.other
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Pylint now tries to patch ``sys.path`` on a per file basis instead of per run.

Closes #7339
20 changes: 2 additions & 18 deletions pylint/lint/expand_modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,36 +11,20 @@

from astroid import modutils

from pylint.lint.utils import get_python_path
from pylint.typing import ErrorDescriptionDict, ModuleDescriptionDict


def _modpath_from_file(filename: str, is_namespace: bool, path: list[str]) -> list[str]:
def _is_package_cb(inner_path: str, parts: list[str]) -> bool:
# with fix_import_path((inner_path,)):
return modutils.check_modpath_has_init(inner_path, parts) or is_namespace

return modutils.modpath_from_file_with_callback(
filename, path=path, is_package_cb=_is_package_cb
)


def get_python_path(filepath: str) -> str:
"""TODO This get the python path with the (bad) assumption that there is always
an __init__.py.

This is not true since python 3.3 and is causing problem.
"""
dirname = os.path.realpath(os.path.expanduser(filepath))
if not os.path.isdir(dirname):
dirname = os.path.dirname(dirname)
while True:
if not os.path.exists(os.path.join(dirname, "__init__.py")):
return dirname
old_dirname = dirname
dirname = os.path.dirname(dirname)
if old_dirname == dirname:
return os.getcwd()


def _is_in_ignore_list_re(element: str, ignore_list_re: list[Pattern[str]]) -> bool:
"""Determines if the element is matched in a regex ignore-list."""
return any(file_pattern.match(element) for file_pattern in ignore_list_re)
Expand Down
18 changes: 10 additions & 8 deletions pylint/lint/pylinter.py
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,8 @@ def check(self, files_or_modules: Sequence[str] | str) -> None:
return

# 3) Get all FileItems
# TODO: 2.16: See if this sys.path patching can be removed as we already
# do this in _expand_modules as well
with fix_import_path(files_or_modules):
if self.config.from_stdin:
fileitems = iter(
Expand All @@ -670,12 +672,11 @@ def check(self, files_or_modules: Sequence[str] | str) -> None:

# The contextmanager also opens all checkers and sets up the PyLinter class
with self._astroid_module_checker() as check_astroid_module:
with fix_import_path(files_or_modules):
# 4) Get the AST for each FileItem
ast_per_fileitem = self._get_asts(fileitems, data)
# 4) Get the AST for each FileItem
ast_per_fileitem = self._get_asts(fileitems, data)

# 5) Lint each ast
self._lint_files(ast_per_fileitem, check_astroid_module)
# 5) Lint each ast
self._lint_files(ast_per_fileitem, check_astroid_module)

def _get_asts(
self, fileitems: Iterator[FileItem], data: str | None
Expand Down Expand Up @@ -1026,9 +1027,10 @@ def check_astroid_module(
"""
before_check_statements = walker.nbstatements

retval = self._check_astroid_module(
ast_node, walker, rawcheckers, tokencheckers
)
with fix_import_path((ast_node.file or "",)):
retval = self._check_astroid_module(
ast_node, walker, rawcheckers, tokencheckers
)

# TODO: 3.0: Remove unnecessary assertion
assert self.current_name
Expand Down
20 changes: 19 additions & 1 deletion pylint/lint/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,32 @@
from __future__ import annotations

import contextlib
import os
import sys
import traceback
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


def get_python_path(filepath: str) -> str:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we cache this and/or fix_import_path now that we launch the function a lot ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix_import_path is an iterator that changes sys.path so I don't think we can cache that.

I'll cache get_python_path.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh never mind, I don't think get_python_path ever gets called with the same argument, as it is part of the exploration of modules and we don't "explore the same module twice".

"""TODO This get the python path with the (bad) assumption that there is always
an __init__.py.

This is not true since python 3.3 and is causing problem.
"""
dirname = os.path.realpath(os.path.expanduser(filepath))
if not os.path.isdir(dirname):
dirname = os.path.dirname(dirname)
while True:
if not os.path.exists(os.path.join(dirname, "__init__.py")):
return dirname
old_dirname = dirname
dirname = os.path.dirname(dirname)
if old_dirname == dirname:
return os.getcwd()


def prepare_crash_report(ex: Exception, filepath: str, crash_file_path: str) -> Path:
Expand Down
30 changes: 30 additions & 0 deletions tests/lint/unittest_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import re
import sys
import tempfile
import textwrap
from collections.abc import Iterable, Iterator
from contextlib import contextmanager
from importlib import reload
Expand Down Expand Up @@ -986,3 +987,32 @@ def test_lint_namespace_package_under_dir_on_path(initialized_linter: PyLinter)
with fix_import_path([tmpdir]):
linter.check(["namespace_on_path"])
assert linter.file_state.base_name == "namespace_on_path"


def test_sys_path_patching_for_namespace_package(initialized_linter: PyLinter) -> None:
"""Test that a normal package in the arguments doesn't pollute sys.path.

Originally reported in https://github.com/PyCQA/pylint/issues/7339.
If pylint is run over a file within a namespace package which
shares the name with a normal package that 1) is stored in another directory
and 2) is being linted at the same time previously there would be a clash of names
due to the addition of the normal package to sys.path.
This was fixed by only patching sys.path on a per-file basis.
"""
linter = initialized_linter
with tempdir() as tmpdir:
create_files(["ns1/m1.py", "ns1/m2.py", "ns2/ns1/__init__.py"])
with open(os.path.join(tmpdir, "ns1/m1.py"), "w", encoding="utf-8") as f:
f.write(
textwrap.dedent(
"""
'''Test file'''
import ns1.m2
print(ns1.m2)
"""
)
)
os.chdir(tmpdir)
with fix_import_path([tmpdir]):
linter.check(["ns1/m1.py", "ns2/ns1/__init__.py"])
assert not linter.reporter.messages
7 changes: 6 additions & 1 deletion tests/test_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from _pytest.recwarn import WarningsRecorder

from pylint import testutils
from pylint.lint.utils import fix_import_path
from pylint.testutils import UPDATE_FILE, UPDATE_OPTION
from pylint.testutils.functional import (
FunctionalTestFile,
Expand Down Expand Up @@ -50,7 +51,11 @@ def test_functional(
else:
lint_test = testutils.LintModuleTest(test_file, pytestconfig)
lint_test.setUp()
lint_test.runTest()
# Pytest changes 'sys.path' depending on the files you're running over.
# To fix this, we simply patch sys.path like we do in a normal run, but
# as if we're running against the test file.
with fix_import_path((test_file.source,)):
lint_test.runTest()
if recwarn.list:
if (
test_file.base in TEST_WITH_EXPECTED_DEPRECATION
Expand Down
10 changes: 5 additions & 5 deletions tests/test_self.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ def test_wrong_import_position_when_others_disabled(self) -> None:
module2 = join(HERE, "regrtest_data", "wrong_import_position.py")
expected_output = textwrap.dedent(
f"""
************* Module wrong_import_position
************* Module regrtest_data.wrong_import_position
{module2}:11:0: C0413: Import "import os" should be placed at the top of the module (wrong-import-position)
"""
)
Expand Down Expand Up @@ -325,7 +325,7 @@ def test_json_report_when_file_has_syntax_error(self) -> None:
"line": 1,
"type": "error",
"symbol": "syntax-error",
"module": "syntax_error",
"module": "regrtest_data.syntax_error",
}
message = output[0]
for key, value in expected.items():
Expand Down Expand Up @@ -368,7 +368,7 @@ def test_json_report_does_not_escape_quotes(self) -> None:
assert isinstance(output[0], dict)
expected = {
"symbol": "unused-variable",
"module": "unused_variable",
"module": "regrtest_data.unused_variable",
"column": 4,
"message": "Unused variable 'variable'",
"message-id": "W0612",
Expand All @@ -389,7 +389,7 @@ def test_error_mode_shows_no_score(self) -> None:
module = join(HERE, "regrtest_data", "application_crash.py")
expected_output = textwrap.dedent(
f"""
************* Module application_crash
************* Module regrtest_data.application_crash
{module}:1:6: E0602: Undefined variable 'something_undefined' (undefined-variable)
"""
)
Expand Down Expand Up @@ -446,7 +446,7 @@ def test_pylintrc_comments_in_values(self) -> None:
config_path = join(HERE, "regrtest_data", "comments_pylintrc")
expected = textwrap.dedent(
f"""
************* Module test_pylintrc_comments
************* Module regrtest_data.test_pylintrc_comments
{path}:2:0: W0311: Bad indentation. Found 1 spaces, expected 4 (bad-indentation)
{path}:1:0: C0114: Missing module docstring (missing-module-docstring)
{path}:1:0: C0116: Missing function or method docstring (missing-function-docstring)
Expand Down