Skip to content
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
143 changes: 85 additions & 58 deletions mypy/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@
ModuleSearchResult,
SearchPaths,
compute_search_paths,
get_search_dirs,
)
from mypy.modules_state import modules_state
from mypy.nodes import Expression
Expand Down Expand Up @@ -658,72 +659,98 @@ def plugin_error(message: str) -> NoReturn:

custom_plugins: list[Plugin] = []
errors.set_file(options.config_file, None, options)
for plugin_path in options.plugins:
func_name = "plugin"
plugin_dir: str | None = None
if ":" in os.path.basename(plugin_path):
plugin_path, func_name = plugin_path.rsplit(":", 1)
if plugin_path.endswith(".py"):
# Plugin paths can be relative to the config file location.
plugin_path = os_path_join(os.path.dirname(options.config_file), plugin_path)
if not os.path.isfile(plugin_path):
plugin_error(f'Can\'t find plugin "{plugin_path}"')
# Use an absolute path to avoid populating the cache entry
# for 'tmp' during tests, since it will be different in
# different tests.
plugin_dir = os.path.abspath(os.path.dirname(plugin_path))
fnam = os.path.basename(plugin_path)
module_name = fnam[:-3]
sys.path.insert(0, plugin_dir)
elif re.search(r"[\\/]", plugin_path):
fnam = os.path.basename(plugin_path)
plugin_error(f'Plugin "{fnam}" does not have a .py extension')
else:
module_name = plugin_path
with plugin_import_path(options):
for plugin_path in options.plugins:
func_name = "plugin"
plugin_dir: str | None = None
if ":" in os.path.basename(plugin_path):
plugin_path, func_name = plugin_path.rsplit(":", 1)
if plugin_path.endswith(".py"):
# Plugin paths can be relative to the config file location.
plugin_path = os_path_join(os.path.dirname(options.config_file), plugin_path)
if not os.path.isfile(plugin_path):
plugin_error(f'Can\'t find plugin "{plugin_path}"')
# Use an absolute path to avoid populating the cache entry
# for 'tmp' during tests, since it will be different in
# different tests.
plugin_dir = os.path.abspath(os.path.dirname(plugin_path))
fnam = os.path.basename(plugin_path)
module_name = fnam[:-3]
sys.path.insert(0, plugin_dir)
elif re.search(r"[\\/]", plugin_path):
fnam = os.path.basename(plugin_path)
plugin_error(f'Plugin "{fnam}" does not have a .py extension')
else:
module_name = plugin_path

try:
module = importlib.import_module(module_name)
except Exception as exc:
plugin_error(f'Error importing plugin "{plugin_path}": {exc}')
finally:
if plugin_dir is not None:
assert sys.path[0] == plugin_dir
del sys.path[0]

if not hasattr(module, func_name):
plugin_error(
'Plugin "{}" does not define entry point function "{}"'.format(
plugin_path, func_name
try:
module = importlib.import_module(module_name)
except Exception as exc:
plugin_error(f'Error importing plugin "{plugin_path}": {exc}')
finally:
if plugin_dir is not None:
assert sys.path[0] == plugin_dir
del sys.path[0]

if not hasattr(module, func_name):
plugin_error(
'Plugin "{}" does not define entry point function "{}"'.format(
plugin_path, func_name
)
)
)

try:
plugin_type = getattr(module, func_name)(__version__)
except Exception:
print(f"Error calling the plugin(version) entry point of {plugin_path}\n", file=stdout)
raise # Propagate to display traceback

if not isinstance(plugin_type, type):
plugin_error(
'Type object expected as the return value of "plugin"; got {!r} (in {})'.format(
plugin_type, plugin_path
try:
plugin_type = getattr(module, func_name)(__version__)
except Exception:
print(
f"Error calling the plugin(version) entry point of {plugin_path}\n",
file=stdout,
)
)
if not issubclass(plugin_type, Plugin):
plugin_error(
'Return value of "plugin" must be a subclass of "mypy.plugin.Plugin" '
"(in {})".format(plugin_path)
)
try:
custom_plugins.append(plugin_type(options))
snapshot[module_name] = take_module_snapshot(module)
except Exception:
print(f"Error constructing plugin instance of {plugin_type.__name__}\n", file=stdout)
raise # Propagate to display traceback
raise # Propagate to display traceback

if not isinstance(plugin_type, type):
plugin_error(
'Type object expected as the return value of "plugin"; '
"got {!r} (in {})".format(plugin_type, plugin_path)
)
if not issubclass(plugin_type, Plugin):
plugin_error(
'Return value of "plugin" must be a subclass of "mypy.plugin.Plugin" '
"(in {})".format(plugin_path)
)
try:
custom_plugins.append(plugin_type(options))
snapshot[module_name] = take_module_snapshot(module)
except Exception:
print(
f"Error constructing plugin instance of {plugin_type.__name__}\n", file=stdout
)
raise # Propagate to display traceback

return custom_plugins, snapshot


@contextlib.contextmanager
def plugin_import_path(options: Options) -> Iterator[None]:
"""Make module-name plugins importable from --python-executable."""
python_executable = options.python_executable
if python_executable is None or is_current_python_executable(python_executable):
yield
return

sys_path, site_packages = get_search_dirs(python_executable)
original_path = sys.path[:]
sys.path[:0] = sys_path + site_packages
try:
yield
finally:
sys.path[:] = original_path


def is_current_python_executable(python_executable: str) -> bool:
return os.path.abspath(python_executable) == os.path.abspath(sys.executable)


def load_plugins(
options: Options, errors: Errors, stdout: TextIO, extra_plugins: Sequence[Plugin]
) -> tuple[Plugin, dict[str, str]]:
Expand Down
75 changes: 75 additions & 0 deletions mypy/test/testplugins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from __future__ import annotations

import io
import sys
import tempfile
from pathlib import Path
from unittest.mock import patch

from mypy import build
from mypy.errors import Errors
from mypy.options import Options
from mypy.test.helpers import Suite


class PluginSuite(Suite):
def test_module_plugin_can_be_loaded_from_python_executable_search_dirs(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
plugin_dir = temp_path / "target-site-packages"
plugin_dir.mkdir()
plugin_file = plugin_dir / "target_only_plugin.py"
plugin_file.write_text(
"""\
from mypy.plugin import Plugin


class TargetPlugin(Plugin):
pass


def plugin(version: str) -> type[Plugin]:
return TargetPlugin
""",
encoding="utf8",
)
config_file = temp_path / "mypy.ini"
config_file.write_text("[mypy]\nplugins = target_only_plugin\n", encoding="utf8")

options = Options()
options.config_file = str(config_file)
options.plugins = ["target_only_plugin"]
options.python_executable = str(temp_path / "target-python")
errors = Errors(options)
original_sys_path = sys.path[:]

with patch.object(
build, "get_search_dirs", lambda executable: ([], [str(plugin_dir)])
):
sys.modules.pop("target_only_plugin", None)
try:
plugins, snapshot = build.load_plugins_from_config(
options, errors, io.StringIO()
)
finally:
sys.modules.pop("target_only_plugin", None)

assert len(plugins) == 1
assert "target_only_plugin" in snapshot
assert sys.path == original_sys_path

def test_python_executable_symlink_uses_own_search_dirs(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
python_link = temp_path / "python"
python_link.symlink_to(sys.executable)
plugin_dir = temp_path / "target-site-packages"

options = Options()
options.python_executable = str(python_link)

with patch.object(
build, "get_search_dirs", lambda executable: ([], [str(plugin_dir)])
):
with build.plugin_import_path(options):
assert sys.path[0] == str(plugin_dir)
Loading