Skip to content

Commit 97b07f7

Browse files
daogilvieDanielNoordPierre-Sassoulas
committed
Add more cases that emit bad-plugin-value (#7284)
* Add x-failing test for issue 7264 case 3 This is the case where a plugin can be imported only after the init-hook is run, and the init hook is specified in a pylintrc file We would expect the module to not load in any case, and cause the emission of a bad-plugin-value message. * _dynamic_plugins is a dict not a set This is in preparation for caching the loaded modules, and for storing other information that will help in identifying times loading is dependent on init-hook magic. * Use cached module objects for load_configuration This fixes case 3 in #7264, and is technically a breaking change, in that it now emits a message for what would have previously been a silent failure. * Interim review comment fixes 1. Remove the xfail that snuck back in after the rebases. 2. Now that fake_home yields a str, update the type hint. * Add test for bad plugin with duplicated load This is case 6 in issue #7264, and represents the other silent failure that is being made non-silent by this change. * Apply review feedback - Add an ordering test for CLI arguments - Add clarifying comments on affected functions - Tidy up test assertions to be more pythonic Co-authored-by: Daniël van Noord <[email protected]> Co-authored-by: Pierre Sassoulas <[email protected]>
1 parent c5aefa2 commit 97b07f7

File tree

4 files changed

+221
-15
lines changed

4 files changed

+221
-15
lines changed

.pyenchant_pylint_custom_dict.txt

+1
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ classmethod
4949
classmethod's
5050
classname
5151
classobj
52+
CLI
5253
cls
5354
cmp
5455
codebase

doc/whatsnew/fragments/7264.bugfix

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Fixed a case where custom plugins specified by command line could silently fail.
2+
3+
Specifically, if a plugin relies on the ``init-hook`` option changing ``sys.path`` before
4+
it can be imported, this will now emit a ``bad-plugin-value`` message. Before this
5+
change, it would silently fail to register the plugin for use, but would load
6+
any configuration, which could have unintended effects.
7+
8+
Fixes part of #7264.

pylint/lint/pylinter.py

+27-12
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from io import TextIOWrapper
1919
from pathlib import Path
2020
from re import Pattern
21+
from types import ModuleType
2122
from typing import Any
2223

2324
import astroid
@@ -295,7 +296,7 @@ def __init__(
295296
str, list[checkers.BaseChecker]
296297
] = collections.defaultdict(list)
297298
"""Dictionary of registered and initialized checkers."""
298-
self._dynamic_plugins: set[str] = set()
299+
self._dynamic_plugins: dict[str, ModuleType | ModuleNotFoundError] = {}
299300
"""Set of loaded plugin names."""
300301

301302
# Attributes related to registering messages and their handling
@@ -361,31 +362,45 @@ def load_default_plugins(self) -> None:
361362
reporters.initialize(self)
362363

363364
def load_plugin_modules(self, modnames: list[str]) -> None:
364-
"""Check a list pylint plugins modules, load and register them."""
365+
"""Check a list of pylint plugins modules, load and register them.
366+
367+
If a module cannot be loaded, never try to load it again and instead
368+
store the error message for later use in ``load_plugin_configuration``
369+
below.
370+
"""
365371
for modname in modnames:
366372
if modname in self._dynamic_plugins:
367373
continue
368-
self._dynamic_plugins.add(modname)
369374
try:
370375
module = astroid.modutils.load_module_from_name(modname)
371376
module.register(self)
372-
except ModuleNotFoundError:
373-
pass
377+
self._dynamic_plugins[modname] = module
378+
except ModuleNotFoundError as mnf_e:
379+
self._dynamic_plugins[modname] = mnf_e
374380

375381
def load_plugin_configuration(self) -> None:
376382
"""Call the configuration hook for plugins.
377383
378384
This walks through the list of plugins, grabs the "load_configuration"
379385
hook, if exposed, and calls it to allow plugins to configure specific
380386
settings.
387+
388+
The result of attempting to load the plugin of the given name
389+
is stored in the dynamic plugins dictionary in ``load_plugin_modules`` above.
390+
391+
..note::
392+
This function previously always tried to load modules again, which
393+
led to some confusion and silent failure conditions as described
394+
in GitHub issue #7264. Making it use the stored result is more efficient, and
395+
means that we avoid the ``init-hook`` problems from before.
381396
"""
382-
for modname in self._dynamic_plugins:
383-
try:
384-
module = astroid.modutils.load_module_from_name(modname)
385-
if hasattr(module, "load_configuration"):
386-
module.load_configuration(self)
387-
except ModuleNotFoundError as e:
388-
self.add_message("bad-plugin-value", args=(modname, e), line=0)
397+
for modname, module_or_error in self._dynamic_plugins.items():
398+
if isinstance(module_or_error, ModuleNotFoundError):
399+
self.add_message(
400+
"bad-plugin-value", args=(modname, module_or_error), line=0
401+
)
402+
elif hasattr(module_or_error, "load_configuration"):
403+
module_or_error.load_configuration(self)
389404

390405
def _load_reporters(self, reporter_names: str) -> None:
391406
"""Load the reporters if they are available on _reporters."""

tests/lint/unittest_lint.py

+185-3
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from os import chdir, getcwd
2020
from os.path import abspath, dirname, join, sep
2121
from pathlib import Path
22-
from shutil import rmtree
22+
from shutil import copytree, rmtree
2323

2424
import platformdirs
2525
import pytest
@@ -59,12 +59,12 @@
5959

6060

6161
@contextmanager
62-
def fake_home() -> Iterator:
62+
def fake_home() -> Iterator[str]:
6363
folder = tempfile.mkdtemp("fake-home")
6464
old_home = os.environ.get(HOME)
6565
try:
6666
os.environ[HOME] = folder
67-
yield
67+
yield folder
6868
finally:
6969
os.environ.pop("PYLINTRC", "")
7070
if old_home is None:
@@ -524,6 +524,188 @@ def test_load_plugin_command_line() -> None:
524524
sys.path.remove(dummy_plugin_path)
525525

526526

527+
@pytest.mark.usefixtures("pop_pylintrc")
528+
def test_load_plugin_path_manipulation_case_6() -> None:
529+
"""Case 6 refers to GitHub issue #7264.
530+
531+
This is where we supply a plugin we want to load on both the CLI and
532+
config file, but that plugin is only loadable after the ``init-hook`` in
533+
the config file has run. This is not supported, and was previously a silent
534+
failure. This test ensures a ``bad-plugin-value`` message is emitted.
535+
"""
536+
dummy_plugin_path = abspath(join(REGRTEST_DATA_DIR, "dummy_plugin"))
537+
with fake_home() as home_path:
538+
# construct a basic rc file that just modifies the path
539+
pylintrc_file = join(home_path, "pylintrc")
540+
with open(pylintrc_file, "w", encoding="utf8") as out:
541+
out.writelines(
542+
[
543+
"[MASTER]\n",
544+
f"init-hook=\"import sys; sys.path.append(r'{home_path}')\"\n",
545+
"load-plugins=copy_dummy\n",
546+
]
547+
)
548+
549+
copytree(dummy_plugin_path, join(home_path, "copy_dummy"))
550+
551+
# To confirm we won't load this module _without_ the init hook running.
552+
assert home_path not in sys.path
553+
554+
run = Run(
555+
[
556+
"--rcfile",
557+
pylintrc_file,
558+
"--load-plugins",
559+
"copy_dummy",
560+
join(REGRTEST_DATA_DIR, "empty.py"),
561+
],
562+
reporter=testutils.GenericTestReporter(),
563+
exit=False,
564+
)
565+
assert run._rcfile == pylintrc_file
566+
assert home_path in sys.path
567+
# The module should not be loaded
568+
assert not any(ch.name == "copy_dummy" for ch in run.linter.get_checkers())
569+
570+
# There should be a bad-plugin-message for this module
571+
assert len(run.linter.reporter.messages) == 1
572+
assert run.linter.reporter.messages[0] == Message(
573+
msg_id="E0013",
574+
symbol="bad-plugin-value",
575+
msg="Plugin 'copy_dummy' is impossible to load, is it installed ? ('No module named 'copy_dummy'')",
576+
confidence=interfaces.Confidence(
577+
name="UNDEFINED",
578+
description="Warning without any associated confidence level.",
579+
),
580+
location=MessageLocationTuple(
581+
abspath="Command line or configuration file",
582+
path="Command line or configuration file",
583+
module="Command line or configuration file",
584+
obj="",
585+
line=1,
586+
column=0,
587+
end_line=None,
588+
end_column=None,
589+
),
590+
)
591+
592+
# Necessary as the executed init-hook modifies sys.path
593+
sys.path.remove(home_path)
594+
595+
596+
@pytest.mark.usefixtures("pop_pylintrc")
597+
def test_load_plugin_path_manipulation_case_3() -> None:
598+
"""Case 3 refers to GitHub issue #7264.
599+
600+
This is where we supply a plugin we want to load on the CLI only,
601+
but that plugin is only loadable after the ``init-hook`` in
602+
the config file has run. This is not supported, and was previously a silent
603+
failure. This test ensures a ``bad-plugin-value`` message is emitted.
604+
"""
605+
dummy_plugin_path = abspath(join(REGRTEST_DATA_DIR, "dummy_plugin"))
606+
with fake_home() as home_path:
607+
# construct a basic rc file that just modifies the path
608+
pylintrc_file = join(home_path, "pylintrc")
609+
with open(pylintrc_file, "w", encoding="utf8") as out:
610+
out.writelines(
611+
[
612+
"[MASTER]\n",
613+
f"init-hook=\"import sys; sys.path.append(r'{home_path}')\"\n",
614+
]
615+
)
616+
617+
copytree(dummy_plugin_path, join(home_path, "copy_dummy"))
618+
619+
# To confirm we won't load this module _without_ the init hook running.
620+
assert home_path not in sys.path
621+
622+
run = Run(
623+
[
624+
"--rcfile",
625+
pylintrc_file,
626+
"--load-plugins",
627+
"copy_dummy",
628+
join(REGRTEST_DATA_DIR, "empty.py"),
629+
],
630+
reporter=testutils.GenericTestReporter(),
631+
exit=False,
632+
)
633+
assert run._rcfile == pylintrc_file
634+
assert home_path in sys.path
635+
# The module should not be loaded
636+
assert not any(ch.name == "copy_dummy" for ch in run.linter.get_checkers())
637+
638+
# There should be a bad-plugin-message for this module
639+
assert len(run.linter.reporter.messages) == 1
640+
assert run.linter.reporter.messages[0] == Message(
641+
msg_id="E0013",
642+
symbol="bad-plugin-value",
643+
msg="Plugin 'copy_dummy' is impossible to load, is it installed ? ('No module named 'copy_dummy'')",
644+
confidence=interfaces.Confidence(
645+
name="UNDEFINED",
646+
description="Warning without any associated confidence level.",
647+
),
648+
location=MessageLocationTuple(
649+
abspath="Command line or configuration file",
650+
path="Command line or configuration file",
651+
module="Command line or configuration file",
652+
obj="",
653+
line=1,
654+
column=0,
655+
end_line=None,
656+
end_column=None,
657+
),
658+
)
659+
660+
# Necessary as the executed init-hook modifies sys.path
661+
sys.path.remove(home_path)
662+
663+
664+
def test_load_plugin_command_line_before_init_hook() -> None:
665+
"""Check that the order of 'load-plugins' and 'init-hook' doesn't affect execution."""
666+
regrtest_data_dir_abs = abspath(REGRTEST_DATA_DIR)
667+
668+
run = Run(
669+
[
670+
"--load-plugins",
671+
"dummy_plugin",
672+
"--init-hook",
673+
f'import sys; sys.path.append("{regrtest_data_dir_abs}")',
674+
join(REGRTEST_DATA_DIR, "empty.py"),
675+
],
676+
exit=False,
677+
)
678+
assert (
679+
len([ch.name for ch in run.linter.get_checkers() if ch.name == "dummy_plugin"])
680+
== 2
681+
)
682+
683+
# Necessary as the executed init-hook modifies sys.path
684+
sys.path.remove(regrtest_data_dir_abs)
685+
686+
687+
def test_load_plugin_command_line_with_init_hook_command_line() -> None:
688+
regrtest_data_dir_abs = abspath(REGRTEST_DATA_DIR)
689+
690+
run = Run(
691+
[
692+
"--init-hook",
693+
f'import sys; sys.path.append("{regrtest_data_dir_abs}")',
694+
"--load-plugins",
695+
"dummy_plugin",
696+
join(REGRTEST_DATA_DIR, "empty.py"),
697+
],
698+
exit=False,
699+
)
700+
assert (
701+
len([ch.name for ch in run.linter.get_checkers() if ch.name == "dummy_plugin"])
702+
== 2
703+
)
704+
705+
# Necessary as the executed init-hook modifies sys.path
706+
sys.path.remove(regrtest_data_dir_abs)
707+
708+
527709
def test_load_plugin_config_file() -> None:
528710
dummy_plugin_path = join(REGRTEST_DATA_DIR, "dummy_plugin")
529711
sys.path.append(dummy_plugin_path)

0 commit comments

Comments
 (0)