diff --git a/mypy/build.py b/mypy/build.py index a03a6eb8972c..51e61cfff5ef 100644 --- a/mypy/build.py +++ b/mypy/build.py @@ -159,6 +159,7 @@ ModuleSearchResult, SearchPaths, compute_search_paths, + get_search_dirs, ) from mypy.modules_state import modules_state from mypy.nodes import Expression @@ -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]]: diff --git a/mypy/test/testplugins.py b/mypy/test/testplugins.py new file mode 100644 index 000000000000..38837131cac0 --- /dev/null +++ b/mypy/test/testplugins.py @@ -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)