diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index 9fa30519c..47e8e8cfc 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -46,9 +46,7 @@ import mesonpy._util import mesonpy._wheelfile -from mesonpy._compat import ( - Collection, Iterable, Mapping, cached_property, read_binary -) +from mesonpy._compat import Collection, Mapping, cached_property, read_binary if typing.TYPE_CHECKING: # pragma: no cover @@ -152,16 +150,6 @@ def _setup_cli() -> None: colorama.init() # fix colors on windows -def _as_python_declaration(value: Any) -> str: - if isinstance(value, str): - return f"r'{value}'" - elif isinstance(value, os.PathLike): - return _as_python_declaration(os.fspath(value)) - elif isinstance(value, Iterable): - return '[' + ', '.join(map(_as_python_declaration, value)) + ']' - raise NotImplementedError(f'Unsupported type: {type(value)}') - - class Error(RuntimeError): def __str__(self) -> str: return str(self.args[0]) @@ -594,56 +582,27 @@ def build_editable(self, directory: Path, verbose: bool = False) -> pathlib.Path wheel_file = pathlib.Path(directory, f'{self.name}.whl') - install_path = self._source_dir / '.mesonpy' / 'editable' / 'install' - rebuild_commands = self._project.build_commands(install_path) - - import_paths = set() - for name, raw_path in mesonpy._introspection.SYSCONFIG_PATHS.items(): - if name not in ('purelib', 'platlib'): - continue - path = pathlib.Path(raw_path) - import_paths.add(install_path / path.relative_to(path.anchor)) - - install_path.mkdir(parents=True, exist_ok=True) - with mesonpy._wheelfile.WheelFile(wheel_file, 'w') as whl: self._wheel_write_metadata(whl) whl.writestr( f'{self.distinfo_dir}/direct_url.json', - self._source_dir.as_uri().encode(), + self._source_dir.as_uri().encode('utf-8'), ) - # install hook module - hook_module_name = f'_mesonpy_hook_{self.normalized_name.replace(".", "_")}' - hook_install_code = textwrap.dedent(f''' - MesonpyFinder.install( - project_name={_as_python_declaration(self._project.name)}, - hook_name={_as_python_declaration(hook_module_name)}, - project_path={_as_python_declaration(self._source_dir)}, - build_path={_as_python_declaration(self._build_dir)}, - import_paths={_as_python_declaration(import_paths)}, - top_level_modules={_as_python_declaration(self.top_level_modules)}, - rebuild_commands={_as_python_declaration(rebuild_commands)}, - verbose={verbose}, - ) - ''').strip().encode() + # install loader module + loader_module_name = f'_{self.normalized_name.replace(".", "_")}_editable_loader' whl.writestr( - f'{hook_module_name}.py', - read_binary('mesonpy', '_editable.py') + hook_install_code, - ) + f'{loader_module_name}.py', + read_binary('mesonpy', '_editable.py') + textwrap.dedent(f''' + install( + {self.top_level_modules!r}, + {os.fspath(self._build_dir)!r} + )''').encode('utf-8')) + # install .pth file whl.writestr( - f'{self.normalized_name}-editable-hook.pth', - f'import {hook_module_name}'.encode(), - ) - - # install non-code schemes - for scheme in self._SCHEME_MAP: - if scheme in ('purelib', 'platlib', 'mesonpy-libs'): - continue - for destination, origin in self._wheel_files[scheme]: - destination = pathlib.Path(self.data_dir, scheme, destination) - whl.write(origin, destination.as_posix()) + f'{self.normalized_name}-editable.pth', + f'import {loader_module_name}'.encode('utf-8')) return wheel_file diff --git a/mesonpy/_editable.py b/mesonpy/_editable.py index f3690c3ec..d0b6c46ee 100644 --- a/mesonpy/_editable.py +++ b/mesonpy/_editable.py @@ -1,180 +1,80 @@ +from __future__ import annotations + import functools import importlib.abc +import importlib.machinery +import importlib.util +import json import os +import pathlib +import re import subprocess import sys -import warnings from types import ModuleType -from typing import List, Mapping, Optional, Union - - -if sys.version_info >= (3, 9): - from collections.abc import Sequence -else: - from typing import Sequence - - -# This file should be standalone! -# It is copied during the editable hook installation. - - -_COLORS = { - 'cyan': '\33[36m', - 'yellow': '\33[93m', - 'light_blue': '\33[94m', - 'bold': '\33[1m', - 'dim': '\33[2m', - 'underline': '\33[4m', - 'reset': '\33[0m', -} -_NO_COLORS = {color: '' for color in _COLORS} - - -def _init_colors() -> Mapping[str, str]: - """Detect if we should be using colors in the output. We will enable colors - if running in a TTY, and no environment variable overrides it. Setting the - NO_COLOR (https://no-color.org/) environment variable force-disables colors, - and FORCE_COLOR forces color to be used, which is useful for thing like - Github actions. - """ - if 'NO_COLOR' in os.environ: - if 'FORCE_COLOR' in os.environ: - warnings.warn('Both NO_COLOR and FORCE_COLOR environment variables are set, disabling color') - return _NO_COLORS - elif 'FORCE_COLOR' in os.environ or sys.stdout.isatty(): - return _COLORS - return _NO_COLORS - - -_STYLES = _init_colors() # holds the color values, should be _COLORS or _NO_COLORS - - -class MesonpyFinder(importlib.abc.MetaPathFinder): - """Custom loader that whose purpose is to detect when the import system is - trying to load our modules, and trigger a rebuild. After triggering a - rebuild, we return None in find_spec, letting the normal finders pick up the - modules. - """ - - def __init__( - self, - project_name: str, - hook_name: str, - project_path: str, - build_path: str, - import_paths: List[str], - top_level_modules: List[str], - rebuild_commands: List[List[str]], - verbose: bool = False, - ) -> None: - self._project_name = project_name - self._hook_name = hook_name - self._project_path = project_path - self._build_path = build_path - self._import_paths = import_paths - self._top_level_modules = top_level_modules - self._rebuild_commands = rebuild_commands - self._verbose = verbose - - for path in (self._project_path, self._build_path): - if not os.path.isdir(path): - raise ImportError( - f'{path} is not a directory, but it is required to rebuild ' - f'"{self._project_name}", which is installed in editable ' - 'mode. Please reinstall the project to get it back to ' - 'working condition. If there are any issues uninstalling ' - 'this installation, you can manually remove ' - f'{self._hook_name} and {os.path.basename(__file__)}, ' - f'located in {os.path.dirname(__file__)}.' - ) - - def __repr__(self) -> str: - return f'{self.__class__.__name__}({self._project_path})' - - def _debug(self, msg: str) -> None: - if self._verbose: - print(msg.format(**_STYLES)) - - def _proc(self, command: List[str]) -> None: - # skip editable hook installation in subprocesses, as during the build - # commands the module we are rebuilding might be imported, causing a - # rebuild loop - # see https://github.com/mesonbuild/meson-python/pull/87#issuecomment-1342548894 - env = os.environ.copy() - env['_MESONPY_EDITABLE_SKIP'] = os.pathsep.join(( - env.get('_MESONPY_EDITABLE_SKIP', ''), - self._project_path, - )) - - if self._verbose: - subprocess.check_call(command, cwd=self._build_path, env=env) - else: - subprocess.check_output(command, cwd=self._build_path, env=env) - - @functools.lru_cache(maxsize=1) - def rebuild(self) -> None: - self._debug(f'{{cyan}}{{bold}}+ rebuilding {self._project_path}{{reset}}') - for command in self._rebuild_commands: - self._proc(command) - self._debug('{cyan}{bold}+ successfully rebuilt{reset}') +from typing import Any, Dict, Optional, Sequence, Set, Union + + +MARKER = 'MESON_PYTHON_EDITABLE_SKIP' +VERBOSE = 'MESON_PYTHON_EDITABLE_VERBOSE' +SUFFIXES = frozenset(importlib.machinery.all_suffixes()) + + +def collect(install_plan: Dict[str, Dict[str, Any]]) -> Dict[str, str]: + modules = {} + for group in install_plan.values(): + for src, target in group.items(): + path = pathlib.Path(target['destination']) + if path.parts[0] in {'{py_platlib}', '{py_purelib}'}: + if path.parts[-1] == '__init__.py': + module = '.'.join(path.parts[1:-1]) + modules[module] = src + else: + match = re.match(r'^([^.]+)(.*)$', path.name) + assert match is not None + name, suffix = match.groups() + if suffix in SUFFIXES: + module = '.'.join(path.parts[1:-1] + (name, )) + modules[module] = src + return modules + + +class MesonpyMetaFinder(importlib.abc.MetaPathFinder): + def __init__(self, names: Set[str], path: str): + self._top_level_modules = names + self._build_path = path + self._verbose = False def find_spec( - self, - fullname: str, - path: Optional[Sequence[Union[str, bytes]]], - target: Optional[ModuleType] = None, - ) -> None: - # if it's one of our modules, trigger a rebuild + self, + fullname: str, + path: Optional[Sequence[Union[bytes, str]]] = None, + target: Optional[ModuleType] = None + ) -> Optional[importlib.machinery.ModuleSpec]: if fullname.split('.', maxsplit=1)[0] in self._top_level_modules: + if self._build_path in os.environ.get(MARKER, '').split(os.pathsep): + return None self.rebuild() - # prepend the project path to sys.path, so that the normal finder - # can find our modules - # we prepend so that our path comes before the current path (if - # the interpreter is run with -m), see gh-239 - if sys.path[:len(self._import_paths)] != self._import_paths: - for path in self._import_paths: - if path in sys.path: - sys.path.remove(path) - sys.path = self._import_paths + sys.path - # return none (meaning we "didn't find" the module) and let the normal - # finders find/import it + install_plan_path = os.path.join(self._build_path, 'meson-info', 'intro-install_plan.json') + with open(install_plan_path, 'r', encoding='utf8') as f: + install_plan = json.load(f) + modules = collect(install_plan) + path = modules.get(fullname) + if path is not None: + return importlib.util.spec_from_file_location(fullname, path) return None - @classmethod - def install( - cls, - project_name: str, - hook_name: str, - project_path: str, - build_path: str, - import_paths: List[str], - top_level_modules: List[str], - rebuild_commands: List[List[str]], - verbose: bool = False, - ) -> None: - if project_path in os.environ.get('_MESONPY_EDITABLE_SKIP', '').split(os.pathsep): - return - if os.environ.get('MESONPY_EDITABLE_VERBOSE', ''): - verbose = True - # install our finder - finder = cls( - project_name, - hook_name, - project_path, - build_path, - import_paths, - top_level_modules, - rebuild_commands, - verbose, - ) - if finder not in sys.meta_path: - # prepend our finder to sys.meta_path, so that it is queried before - # the normal finders, and can trigger a project rebuild - sys.meta_path.insert(0, finder) - # we add the project path to sys.path later, so that we can prepend - # after the current directory is prepended (when -m is used) - # see gh-239 + @functools.lru_cache(maxsize=1) + def rebuild(self) -> None: + # skip editable wheel lookup during rebuild: during the build + # the module we are rebuilding might be imported causing a + # rebuild loop. + env = os.environ.copy() + env[MARKER] = os.pathsep.join((env.get(MARKER, ''), self._build_path)) + verbose = self._verbose or bool(env.get(VERBOSE, '')) + stdout = None if verbose else subprocess.DEVNULL + subprocess.run(['meson', 'compile'], cwd=self._build_path, env=env, stdout=stdout, check=True) -# generated hook install below +def install(names: Set[str], path: str) -> None: + sys.meta_path.insert(0, MesonpyMetaFinder(names, path))