Skip to content

Commit b6e91c1

Browse files
committed
Load config plugins from python executable paths
1 parent 2433566 commit b6e91c1

2 files changed

Lines changed: 161 additions & 58 deletions

File tree

mypy/build.py

Lines changed: 86 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@
159159
ModuleSearchResult,
160160
SearchPaths,
161161
compute_search_paths,
162+
get_search_dirs,
162163
)
163164
from mypy.modules_state import modules_state
164165
from mypy.nodes import Expression
@@ -658,72 +659,99 @@ def plugin_error(message: str) -> NoReturn:
658659

659660
custom_plugins: list[Plugin] = []
660661
errors.set_file(options.config_file, None, options)
661-
for plugin_path in options.plugins:
662-
func_name = "plugin"
663-
plugin_dir: str | None = None
664-
if ":" in os.path.basename(plugin_path):
665-
plugin_path, func_name = plugin_path.rsplit(":", 1)
666-
if plugin_path.endswith(".py"):
667-
# Plugin paths can be relative to the config file location.
668-
plugin_path = os_path_join(os.path.dirname(options.config_file), plugin_path)
669-
if not os.path.isfile(plugin_path):
670-
plugin_error(f'Can\'t find plugin "{plugin_path}"')
671-
# Use an absolute path to avoid populating the cache entry
672-
# for 'tmp' during tests, since it will be different in
673-
# different tests.
674-
plugin_dir = os.path.abspath(os.path.dirname(plugin_path))
675-
fnam = os.path.basename(plugin_path)
676-
module_name = fnam[:-3]
677-
sys.path.insert(0, plugin_dir)
678-
elif re.search(r"[\\/]", plugin_path):
679-
fnam = os.path.basename(plugin_path)
680-
plugin_error(f'Plugin "{fnam}" does not have a .py extension')
681-
else:
682-
module_name = plugin_path
662+
with plugin_import_path(options):
663+
for plugin_path in options.plugins:
664+
func_name = "plugin"
665+
plugin_dir: str | None = None
666+
if ":" in os.path.basename(plugin_path):
667+
plugin_path, func_name = plugin_path.rsplit(":", 1)
668+
if plugin_path.endswith(".py"):
669+
# Plugin paths can be relative to the config file location.
670+
plugin_path = os_path_join(os.path.dirname(options.config_file), plugin_path)
671+
if not os.path.isfile(plugin_path):
672+
plugin_error(f'Can\'t find plugin "{plugin_path}"')
673+
# Use an absolute path to avoid populating the cache entry
674+
# for 'tmp' during tests, since it will be different in
675+
# different tests.
676+
plugin_dir = os.path.abspath(os.path.dirname(plugin_path))
677+
fnam = os.path.basename(plugin_path)
678+
module_name = fnam[:-3]
679+
sys.path.insert(0, plugin_dir)
680+
elif re.search(r"[\\/]", plugin_path):
681+
fnam = os.path.basename(plugin_path)
682+
plugin_error(f'Plugin "{fnam}" does not have a .py extension')
683+
else:
684+
module_name = plugin_path
683685

684-
try:
685-
module = importlib.import_module(module_name)
686-
except Exception as exc:
687-
plugin_error(f'Error importing plugin "{plugin_path}": {exc}')
688-
finally:
689-
if plugin_dir is not None:
690-
assert sys.path[0] == plugin_dir
691-
del sys.path[0]
692-
693-
if not hasattr(module, func_name):
694-
plugin_error(
695-
'Plugin "{}" does not define entry point function "{}"'.format(
696-
plugin_path, func_name
686+
try:
687+
module = importlib.import_module(module_name)
688+
except Exception as exc:
689+
plugin_error(f'Error importing plugin "{plugin_path}": {exc}')
690+
finally:
691+
if plugin_dir is not None:
692+
assert sys.path[0] == plugin_dir
693+
del sys.path[0]
694+
695+
if not hasattr(module, func_name):
696+
plugin_error(
697+
'Plugin "{}" does not define entry point function "{}"'.format(
698+
plugin_path, func_name
699+
)
697700
)
698-
)
699701

700-
try:
701-
plugin_type = getattr(module, func_name)(__version__)
702-
except Exception:
703-
print(f"Error calling the plugin(version) entry point of {plugin_path}\n", file=stdout)
704-
raise # Propagate to display traceback
705-
706-
if not isinstance(plugin_type, type):
707-
plugin_error(
708-
'Type object expected as the return value of "plugin"; got {!r} (in {})'.format(
709-
plugin_type, plugin_path
702+
try:
703+
plugin_type = getattr(module, func_name)(__version__)
704+
except Exception:
705+
print(
706+
f"Error calling the plugin(version) entry point of {plugin_path}\n",
707+
file=stdout,
710708
)
711-
)
712-
if not issubclass(plugin_type, Plugin):
713-
plugin_error(
714-
'Return value of "plugin" must be a subclass of "mypy.plugin.Plugin" '
715-
"(in {})".format(plugin_path)
716-
)
717-
try:
718-
custom_plugins.append(plugin_type(options))
719-
snapshot[module_name] = take_module_snapshot(module)
720-
except Exception:
721-
print(f"Error constructing plugin instance of {plugin_type.__name__}\n", file=stdout)
722-
raise # Propagate to display traceback
709+
raise # Propagate to display traceback
710+
711+
if not isinstance(plugin_type, type):
712+
plugin_error(
713+
'Type object expected as the return value of "plugin"; '
714+
"got {!r} (in {})".format(plugin_type, plugin_path)
715+
)
716+
if not issubclass(plugin_type, Plugin):
717+
plugin_error(
718+
'Return value of "plugin" must be a subclass of "mypy.plugin.Plugin" '
719+
"(in {})".format(plugin_path)
720+
)
721+
try:
722+
custom_plugins.append(plugin_type(options))
723+
snapshot[module_name] = take_module_snapshot(module)
724+
except Exception:
725+
print(
726+
f"Error constructing plugin instance of {plugin_type.__name__}\n",
727+
file=stdout,
728+
)
729+
raise # Propagate to display traceback
723730

724731
return custom_plugins, snapshot
725732

726733

734+
@contextlib.contextmanager
735+
def plugin_import_path(options: Options) -> Iterator[None]:
736+
"""Make module-name plugins importable from --python-executable."""
737+
python_executable = options.python_executable
738+
if python_executable is None or is_current_python_executable(python_executable):
739+
yield
740+
return
741+
742+
sys_path, site_packages = get_search_dirs(python_executable)
743+
original_path = sys.path[:]
744+
sys.path[:0] = sys_path + site_packages
745+
try:
746+
yield
747+
finally:
748+
sys.path[:] = original_path
749+
750+
751+
def is_current_python_executable(python_executable: str) -> bool:
752+
return os.path.abspath(python_executable) == os.path.abspath(sys.executable)
753+
754+
727755
def load_plugins(
728756
options: Options, errors: Errors, stdout: TextIO, extra_plugins: Sequence[Plugin]
729757
) -> tuple[Plugin, dict[str, str]]:

mypy/test/testplugins.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from __future__ import annotations
2+
3+
import io
4+
import sys
5+
import tempfile
6+
from pathlib import Path
7+
from unittest.mock import patch
8+
9+
from mypy import build
10+
from mypy.errors import Errors
11+
from mypy.options import Options
12+
from mypy.test.helpers import Suite
13+
14+
15+
class PluginSuite(Suite):
16+
def test_module_plugin_can_be_loaded_from_python_executable_search_dirs(self) -> None:
17+
with tempfile.TemporaryDirectory() as temp_dir:
18+
temp_path = Path(temp_dir)
19+
plugin_dir = temp_path / "target-site-packages"
20+
plugin_dir.mkdir()
21+
plugin_file = plugin_dir / "target_only_plugin.py"
22+
plugin_file.write_text(
23+
"""\
24+
from mypy.plugin import Plugin
25+
26+
27+
class TargetPlugin(Plugin):
28+
pass
29+
30+
31+
def plugin(version: str) -> type[Plugin]:
32+
return TargetPlugin
33+
""",
34+
encoding="utf8",
35+
)
36+
config_file = temp_path / "mypy.ini"
37+
config_file.write_text("[mypy]\nplugins = target_only_plugin\n", encoding="utf8")
38+
39+
options = Options()
40+
options.config_file = str(config_file)
41+
options.plugins = ["target_only_plugin"]
42+
options.python_executable = str(temp_path / "target-python")
43+
errors = Errors(options)
44+
original_sys_path = sys.path[:]
45+
46+
with patch.object(
47+
build, "get_search_dirs", lambda executable: ([], [str(plugin_dir)])
48+
):
49+
sys.modules.pop("target_only_plugin", None)
50+
try:
51+
plugins, snapshot = build.load_plugins_from_config(
52+
options, errors, io.StringIO()
53+
)
54+
finally:
55+
sys.modules.pop("target_only_plugin", None)
56+
57+
assert len(plugins) == 1
58+
assert "target_only_plugin" in snapshot
59+
assert sys.path == original_sys_path
60+
61+
def test_python_executable_symlink_uses_own_search_dirs(self) -> None:
62+
with tempfile.TemporaryDirectory() as temp_dir:
63+
temp_path = Path(temp_dir)
64+
python_link = temp_path / "python"
65+
python_link.symlink_to(sys.executable)
66+
plugin_dir = temp_path / "target-site-packages"
67+
68+
options = Options()
69+
options.python_executable = str(python_link)
70+
71+
with patch.object(
72+
build, "get_search_dirs", lambda executable: ([], [str(plugin_dir)])
73+
):
74+
with build.plugin_import_path(options):
75+
assert sys.path[0] == str(plugin_dir)

0 commit comments

Comments
 (0)