From ef3ad1283830bbb97dc1f2fafadd528eebb55848 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Wed, 30 Nov 2022 23:36:06 +0000 Subject: [PATCH 01/20] TST: bump mypy version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- mesonpy/_tags.py | 2 +- noxfile.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mesonpy/_tags.py b/mesonpy/_tags.py index cdfa68094..ff2b7bb38 100644 --- a/mesonpy/_tags.py +++ b/mesonpy/_tags.py @@ -29,7 +29,7 @@ def get_interpreter_tag() -> str: def _get_config_var(name: str, default: Union[str, int, None] = None) -> Union[str, int, None]: - value = sysconfig.get_config_var(name) + value: Union[str, int, None] = sysconfig.get_config_var(name) if value is None: return default return value diff --git a/noxfile.py b/noxfile.py index 8c2f73a98..47ddbb080 100644 --- a/noxfile.py +++ b/noxfile.py @@ -30,7 +30,7 @@ def docs(session): @nox.session(python='3.7') def mypy(session): - session.install('mypy==0.981') + session.install('mypy==0.991') session.run('mypy', '-p', 'mesonpy') From 1f2d517341e99d90fced4eb30b3dca0b63c888d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Thu, 24 Nov 2022 04:55:13 +0000 Subject: [PATCH 02/20] MAINT: move cached_property backport to compat module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- mesonpy/__init__.py | 9 ++------- mesonpy/_compat.py | 8 ++++++++ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index ac8901c50..a7ac98296 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -49,7 +49,8 @@ import mesonpy._wheelfile from mesonpy._compat import ( - Collection, Iterator, Literal, Mapping, ParamSpec, Path, typing_get_args + Collection, Iterator, Literal, Mapping, ParamSpec, Path, cached_property, + typing_get_args ) @@ -57,12 +58,6 @@ import pyproject_metadata # noqa: F401 -if sys.version_info >= (3, 8): - from functools import cached_property -else: - cached_property = lambda x: property(functools.lru_cache(maxsize=None)(x)) # noqa: E731 - - __version__ = '0.13.0.dev0' diff --git a/mesonpy/_compat.py b/mesonpy/_compat.py index 852dbeade..4db7bec32 100644 --- a/mesonpy/_compat.py +++ b/mesonpy/_compat.py @@ -2,6 +2,7 @@ # SPDX-FileCopyrightText: 2021 Quansight, LLC # SPDX-FileCopyrightText: 2021 Filipe Laíns +import functools import os import pathlib import sys @@ -30,6 +31,12 @@ from typing_extensions import get_args as typing_get_args +if sys.version_info >= (3, 8): + from functools import cached_property +else: + cached_property = lambda x: property(functools.lru_cache(maxsize=None)(x)) # noqa: E731 + + Path = Union[str, os.PathLike] @@ -43,6 +50,7 @@ def is_relative_to(path: pathlib.Path, other: Union[pathlib.Path, str]) -> bool: __all__ = [ + 'cached_property', 'is_relative_to', 'typing_get_args', 'Collection', From ad0018936991856c706b269dbfe6a8e4f738143b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Wed, 30 Nov 2022 02:27:42 +0000 Subject: [PATCH 03/20] MAINT: add _WheelBuilder.normalized_name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- mesonpy/__init__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index a7ac98296..0107d1b56 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -193,11 +193,15 @@ def _has_extension_modules(self) -> bool: # Assume that all code installed in {platlib} is Python ABI dependent. return bool(self._wheel_files['platlib']) + @property + def normalized_name(self) -> str: + return self._project.name.replace('-', '_') + @property def basename(self) -> str: """Normalized wheel name and version (eg. meson_python-1.0.0).""" return '{distribution}-{version}'.format( - distribution=self._project.name.replace('-', '_'), + distribution=self.normalized_name, version=self._project.version, ) @@ -542,6 +546,9 @@ def build(self, directory: Path) -> pathlib.Path: return wheel_file + def build_editable(self, directory: Path) -> pathlib.Path: + self._build() + MesonArgsKeys = Literal['dist', 'setup', 'compile', 'install'] MesonArgs = Mapping[MesonArgsKeys, List[str]] From 7c764bee567c3b04fc01bb92687e06df99919eb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Fri, 25 Nov 2022 07:59:01 +0000 Subject: [PATCH 04/20] ENH: add support for editable installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- .gitignore | 1 + meson.build | 1 + mesonpy/__init__.py | 242 +++++++++++++++++++++++++++++++++++++------ mesonpy/_editable.py | 90 ++++++++++++++++ pyproject.toml | 2 + 5 files changed, 306 insertions(+), 30 deletions(-) create mode 100644 mesonpy/_editable.py diff --git a/.gitignore b/.gitignore index af85c9a39..5598c22d9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .mesonpy-native-file.ini +.mesonpy/ docs/_build *.pyc .cache/ diff --git a/meson.build b/meson.build index 9018de9ba..bbb95dfb9 100644 --- a/meson.build +++ b/meson.build @@ -10,6 +10,7 @@ endif py.install_sources( 'mesonpy/__init__.py', 'mesonpy/_compat.py', + 'mesonpy/_editable.py', 'mesonpy/_elf.py', 'mesonpy/_tags.py', 'mesonpy/_util.py', diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index 0107d1b56..99cf6d04b 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -42,6 +42,11 @@ else: import tomllib +if sys.version_info >= (3, 10): + import importlib.resources as importlib_resources +else: + import importlib_resources + import mesonpy._compat import mesonpy._elf import mesonpy._tags @@ -49,8 +54,8 @@ import mesonpy._wheelfile from mesonpy._compat import ( - Collection, Iterator, Literal, Mapping, ParamSpec, Path, cached_property, - typing_get_args + Collection, Iterable, Iterator, Literal, Mapping, ParamSpec, Path, + cached_property, typing_get_args ) @@ -102,7 +107,7 @@ def _init_colors() -> Dict[str, str]: _STYLES = _init_colors() # holds the color values, should be _COLORS or _NO_COLORS -_EXTENSION_SUFFIXES = frozenset(importlib.machinery.EXTENSION_SUFFIXES) +_EXTENSION_SUFFIXES = importlib.machinery.EXTENSION_SUFFIXES.copy() _EXTENSION_SUFFIX_REGEX = re.compile(r'^\.(?:(?P[^.]+)\.)?(?:so|pyd|dll)$') assert all(re.match(_EXTENSION_SUFFIX_REGEX, x) for x in _EXTENSION_SUFFIXES) @@ -133,6 +138,16 @@ 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]) @@ -296,6 +311,41 @@ def _debian_python(self) -> bool: except ModuleNotFoundError: return False + @property + def _debian_distutils_paths(self) -> Mapping[str, str]: + # https://ffy00.github.io/blog/02-python-debian-and-the-install-locations/ + assert sys.version_info < (3, 12) and self._debian_python + + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', category=DeprecationWarning) + import distutils.dist + + distribution = distutils.dist.Distribution({ + 'name': self._project.name, + }) + install_cmd = distribution.get_command_obj('install') + install_cmd.install_layout = 'deb' # type: ignore[union-attr] + install_cmd.finalize_options() # type: ignore[union-attr] + + return { + 'data': install_cmd.install_data, # type: ignore[union-attr] + 'headers': install_cmd.install_headers, # type: ignore[union-attr] + 'platlib': install_cmd.install_platlib, # type: ignore[union-attr] + 'purelib': install_cmd.install_purelib, # type: ignore[union-attr] + 'scripts': install_cmd.install_scripts, # type: ignore[union-attr] + } + + @property + def _sysconfig_paths(self) -> Mapping[str, str]: + sys_vars = sysconfig.get_config_vars().copy() + sys_vars['base'] = sys_vars['platbase'] = sys.base_prefix + if self._debian_python: + if sys.version_info >= (3, 10): + return sysconfig.get_paths('deb_system', vars=sys_vars) + else: + return self._debian_distutils_paths + return sysconfig.get_paths(vars=sys_vars) + @cached_property def _stable_abi(self) -> Optional[str]: """Determine stabe ABI compatibility. @@ -383,9 +433,7 @@ def _map_from_heuristics(self, origin: pathlib.Path, destination: pathlib.Path) origin file and the Meson destination path. """ warnings.warn('Using heuristics to map files to wheel, this may result in incorrect locations') - sys_vars = sysconfig.get_config_vars().copy() - sys_vars['base'] = sys_vars['platbase'] = sys.base_prefix - sys_paths = sysconfig.get_paths(vars=sys_vars) + sys_paths = self._sysconfig_paths # Try to map to Debian dist-packages if self._debian_python: search_path = origin @@ -502,24 +550,27 @@ def _install_path( wheel_file.write(origin, location) + def _wheel_write_metadata(self, whl: mesonpy._wheelfile.WheelFile) -> None: + # add metadata + whl.writestr(f'{self.distinfo_dir}/METADATA', self._project.metadata) + whl.writestr(f'{self.distinfo_dir}/WHEEL', self.wheel) + if self.entrypoints_txt: + whl.writestr(f'{self.distinfo_dir}/entry_points.txt', self.entrypoints_txt) + + # add license (see https://github.com/mesonbuild/meson-python/issues/88) + if self._project.license_file: + whl.write( + self._source_dir / self._project.license_file, + f'{self.distinfo_dir}/{os.path.basename(self._project.license_file)}', + ) + def build(self, directory: Path) -> pathlib.Path: self._project.build() # ensure project is built wheel_file = pathlib.Path(directory, f'{self.name}.whl') with mesonpy._wheelfile.WheelFile(wheel_file, 'w') as whl: - # add metadata - whl.writestr(f'{self.distinfo_dir}/METADATA', self._project.metadata) - whl.writestr(f'{self.distinfo_dir}/WHEEL', self.wheel) - if self.entrypoints_txt: - whl.writestr(f'{self.distinfo_dir}/entry_points.txt', self.entrypoints_txt) - - # add license (see https://github.com/mesonbuild/meson-python/issues/88) - if self._project.license_file: - whl.write( - self._source_dir / self._project.license_file, - f'{self.distinfo_dir}/{os.path.basename(self._project.license_file)}', - ) + self._wheel_write_metadata(whl) print('{light_blue}{bold}Copying files to wheel...{reset}'.format(**_STYLES)) with mesonpy._util.cli_counter( @@ -547,7 +598,60 @@ def build(self, directory: Path) -> pathlib.Path: return wheel_file def build_editable(self, directory: Path) -> pathlib.Path: - self._build() + self._project.build() # ensure project is built + + wheel_file = pathlib.Path(directory, f'{self.name}.whl') + + install_path = self._source_dir / '.mesonpy' / 'editable' / 'install' + top_level_modules = self._project.top_level_modules + rebuild_commands = self._project.build_commands(install_path) + + import_paths = set() + for name, raw_path in self._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(), + ) + + # install hook module + hook_module_name = f'_mesonpy_hook_{self.normalized_name.replace(".", "_")}' + hook_install_code = textwrap.dedent(f''' + MesonpyFinder.install( + 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(top_level_modules)}, + rebuild_commands={_as_python_declaration(rebuild_commands)}, + ) + ''').strip().encode() + whl.writestr( + f'{hook_module_name}.py', + (importlib_resources.files('mesonpy') / '_editable.py').read_bytes() + hook_install_code, + ) + # 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()) + + return wheel_file MesonArgsKeys = Literal['dist', 'setup', 'compile', 'install'] @@ -645,8 +749,8 @@ def __init__( # noqa: C901 self._meson_args[key].extend(value) # make sure the build dir exists - self._build_dir.mkdir(exist_ok=True) - self._install_dir.mkdir(exist_ok=True) + self._build_dir.mkdir(exist_ok=True, parents=True) + self._install_dir.mkdir(exist_ok=True, parents=True) # write the native file native_file_data = textwrap.dedent(f''' @@ -685,14 +789,13 @@ def _get_config_key(self, key: str) -> Any: def _proc(self, *args: str) -> None: """Invoke a subprocess.""" print('{cyan}{bold}+ {}{reset}'.format(' '.join(args), **_STYLES)) - r = subprocess.run(list(args), env=self._env) + r = subprocess.run(list(args), env=self._env, cwd=self._build_dir) if r.returncode != 0: raise SystemExit(r.returncode) def _meson(self, *args: str) -> None: """Invoke Meson.""" - with mesonpy._util.chdir(self._build_dir): - return self._proc('meson', *args) + return self._proc('meson', *args) def _configure(self, reconfigure: bool = False) -> None: """Configure Meson project. @@ -768,11 +871,24 @@ def _wheel_builder(self) -> _WheelBuilder: self._copy_files, ) + def build_commands(self, install_dir: Optional[pathlib.Path] = None) -> Sequence[Sequence[str]]: + return ( + ('meson', 'compile', *self._meson_args['compile'],), + ( + 'meson', + 'install', + '--only-changed', + '--destdir', + os.fspath(install_dir or self._install_dir), + *self._meson_args['install'], + ), + ) + @functools.lru_cache(maxsize=None) def build(self) -> None: """Trigger the Meson build.""" - self._meson('compile', *self._meson_args['compile'],) - self._meson('install', '--destdir', os.fspath(self._install_dir), *self._meson_args['install'],) + for cmd in self.build_commands(): + self._meson(*cmd[1:]) @classmethod @contextlib.contextmanager @@ -852,21 +968,56 @@ def version(self) -> str: assert isinstance(version, str) return version + @property + def top_level_modules(self) -> Collection[str]: + modules = set() + for type_ in self._install_plan: + for _, details in self._install_plan[type_].items(): + path = pathlib.Path(details['destination']) + if len(path.parts) >= 2 and path.parts[0]: + top_part = path.parts[1] + # file module + if top_part.endswith('.py'): + modules.add(top_part[:-3]) + else: + # native module + for extension in _EXTENSION_SUFFIXES: + if top_part.endswith(extension): + modules.add(top_part[:-len(extension)]) + # XXX: We assume the order in _EXTENSION_SUFFIXES + # goes from more specific to last, so we go + # with the first match we find. + break + else: # nobreak + # skip Windows import libraries + if top_part.endswith('.a'): + continue + # package module + modules.add(top_part) + return modules + @cached_property def metadata(self) -> bytes: """Project metadata.""" # the rest of the keys are only available when using PEP 621 metadata if not self.pep621: - return textwrap.dedent(f''' + data = textwrap.dedent(f''' Metadata-Version: 2.1 Name: {self.name} Version: {self.version} - ''').strip().encode() + ''').strip() + return data.encode() + # re-import pyproject_metadata to raise ModuleNotFoundError if it is really missing import pyproject_metadata # noqa: F401, F811 assert self._metadata - # use self.version as the version may be dynamic -- fetched from Meson + core_metadata = self._metadata.as_rfc822() + # use self.version as the version may be dynamic -- fetched from Meson + # + # we need to overwrite this field in the RFC822 field as + # pyproject_metadata removes 'version' from the dynamic fields when + # giving it a value via the dataclass core_metadata.headers['Version'] = [self.version] return bytes(core_metadata) @@ -952,7 +1103,7 @@ def sdist(self, directory: Path) -> pathlib.Path: return sdist - def wheel(self, directory: Path) -> pathlib.Path: # noqa: F811 + def wheel(self, directory: Path) -> pathlib.Path: """Generates a wheel (binary distribution) in the specified directory.""" wheel = self._wheel_builder.build(self._build_dir) @@ -960,6 +1111,11 @@ def wheel(self, directory: Path) -> pathlib.Path: # noqa: F811 shutil.move(os.fspath(wheel), final_wheel) return final_wheel + def editable(self, directory: Path) -> pathlib.Path: + file = self._wheel_builder.build_editable(directory) + assert isinstance(file, pathlib.Path) + return file + @contextlib.contextmanager def _project(config_settings: Optional[Dict[Any, Any]]) -> Iterator[Project]: @@ -1116,3 +1272,29 @@ def build_wheel( out = pathlib.Path(wheel_directory) with _project(config_settings) as project: return project.wheel(out).name + + +@_pyproject_hook +def build_editable( + wheel_directory: str, + config_settings: Optional[Dict[Any, Any]] = None, + metadata_directory: Optional[str] = None, +) -> str: + _setup_cli() + + # force set a permanent builddir + if not config_settings: + config_settings = {} + if 'builddir' not in config_settings: + config_settings['builddir'] = os.path.join('.mesonpy', 'editable', 'build') + + out = pathlib.Path(wheel_directory) + with _project(config_settings) as project: + return project.editable(out).name + + +@_pyproject_hook +def get_requires_for_build_editable( + config_settings: Optional[Dict[str, str]] = None, +) -> List[str]: + return get_requires_for_build_wheel() diff --git a/mesonpy/_editable.py b/mesonpy/_editable.py new file mode 100644 index 000000000..5db88371e --- /dev/null +++ b/mesonpy/_editable.py @@ -0,0 +1,90 @@ +import functools +import importlib.abc +import subprocess +import sys + +from types import ModuleType +from typing import List, 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. + + +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_path: str, + build_path: str, + import_paths: List[str], + top_level_modules: List[str], + rebuild_commands: List[List[str]], + ) -> None: + 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 + + def __repr__(self) -> str: + return f'{self.__class__.__name__}({self._project_path})' + + @functools.lru_cache(maxsize=1) + def rebuild(self) -> None: + for command in self._rebuild_commands: + subprocess.check_output(command, cwd=self._build_path) + + 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 + if fullname.split('.', maxsplit=1)[0] in self._top_level_modules: + 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 + return None + + @classmethod + def install( + cls, + project_path: str, + build_path: str, + import_paths: List[str], + top_level_modules: List[str], + rebuild_commands: List[List[str]], + ) -> None: + finder = cls(project_path, build_path, import_paths, top_level_modules, rebuild_commands) + 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 + + +# generated hook install below diff --git a/pyproject.toml b/pyproject.toml index deb250f10..d14ec6f95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ requires = [ 'pyproject-metadata>=0.6.1', 'tomli>=1.0.0; python_version<"3.11"', 'typing-extensions>=3.7.4; python_version<"3.10"', + 'importlib_resources>=5.0.0; python_version<"3.10"', ] [project] @@ -29,6 +30,7 @@ dependencies = [ 'pyproject-metadata>=0.6.1', # not a hard dependency, only needed for projects that use PEP 621 metadata 'tomli>=1.0.0; python_version<"3.11"', 'typing-extensions>=3.7.4; python_version<"3.10"', + 'importlib_resources>=5.0.0; python_version<"3.10"', ] dynamic = [ From e99b3d09c9c590c1c46fc9d3b80eee9095387d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Wed, 30 Nov 2022 02:39:09 +0000 Subject: [PATCH 05/20] MAINT: simplify Project.wheel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This was leftover from when we ran auditwheel. Signed-off-by: Filipe Laíns --- mesonpy/__init__.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index 99cf6d04b..7a6cf3dc0 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -1105,11 +1105,9 @@ def sdist(self, directory: Path) -> pathlib.Path: def wheel(self, directory: Path) -> pathlib.Path: """Generates a wheel (binary distribution) in the specified directory.""" - wheel = self._wheel_builder.build(self._build_dir) - - final_wheel = pathlib.Path(directory, wheel.name) - shutil.move(os.fspath(wheel), final_wheel) - return final_wheel + file = self._wheel_builder.build(directory) + assert isinstance(file, pathlib.Path) + return file def editable(self, directory: Path) -> pathlib.Path: file = self._wheel_builder.build_editable(directory) From 72e88ea4e026418bad2f00e47d86bae8c94cd168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Wed, 30 Nov 2022 02:40:26 +0000 Subject: [PATCH 06/20] MAINT: use pathlib.Path.as_posix in _WheelBuilder._install_path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- mesonpy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index 7a6cf3dc0..c53dd612c 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -519,7 +519,7 @@ def _install_path( Some files might need to be fixed up to set the RPATH to the internal library directory on Linux wheels for eg. """ - location = os.fspath(destination).replace(os.path.sep, '/') + location = destination.as_posix() counter.update(location) # fix file From 569a707df3b14f8f804e65a69d46cb52ec84c578 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Fri, 9 Dec 2022 01:30:17 +0000 Subject: [PATCH 07/20] BUG: fix rebuild loop when the build tries to import the editable module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- mesonpy/_editable.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/mesonpy/_editable.py b/mesonpy/_editable.py index 5db88371e..1def84b93 100644 --- a/mesonpy/_editable.py +++ b/mesonpy/_editable.py @@ -1,5 +1,6 @@ import functools import importlib.abc +import os import subprocess import sys @@ -44,7 +45,17 @@ def __repr__(self) -> str: @functools.lru_cache(maxsize=1) def rebuild(self) -> None: for command in self._rebuild_commands: - subprocess.check_output(command, cwd=self._build_path) + # 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, + )) + + subprocess.check_output(command, cwd=self._build_path, env=env) def find_spec( self, @@ -77,6 +88,9 @@ def install( top_level_modules: List[str], rebuild_commands: List[List[str]], ) -> None: + if project_path in os.environ.get('_MESONPY_EDITABLE_SKIP', '').split(os.pathsep): + return + # install our finder finder = cls(project_path, build_path, import_paths, top_level_modules, rebuild_commands) if finder not in sys.meta_path: # prepend our finder to sys.meta_path, so that it is queried before From c22ec4dcbe8732989dd575098de90a55f97f6f1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Fri, 9 Dec 2022 01:57:24 +0000 Subject: [PATCH 08/20] ENH: add editable-verbose config setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- mesonpy/__init__.py | 13 +++++--- mesonpy/_editable.py | 75 ++++++++++++++++++++++++++++++++++++-------- 2 files changed, 71 insertions(+), 17 deletions(-) diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index c53dd612c..8aac23587 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -597,7 +597,7 @@ def build(self, directory: Path) -> pathlib.Path: return wheel_file - def build_editable(self, directory: Path) -> pathlib.Path: + def build_editable(self, directory: Path, verbose: bool = False) -> pathlib.Path: self._project.build() # ensure project is built wheel_file = pathlib.Path(directory, f'{self.name}.whl') @@ -631,6 +631,7 @@ def build_editable(self, directory: Path) -> pathlib.Path: import_paths={_as_python_declaration(import_paths)}, top_level_modules={_as_python_declaration(top_level_modules)}, rebuild_commands={_as_python_declaration(rebuild_commands)}, + verbose={verbose}, ) ''').strip().encode() whl.writestr( @@ -672,10 +673,12 @@ def __init__( # noqa: C901 working_dir: Path, build_dir: Optional[Path] = None, meson_args: Optional[MesonArgs] = None, + editable_verbose: bool = False, ) -> None: self._source_dir = pathlib.Path(source_dir).absolute() self._working_dir = pathlib.Path(working_dir).absolute() self._build_dir = pathlib.Path(build_dir).absolute() if build_dir else (self._working_dir / 'build') + self._editable_verbose = editable_verbose self._install_dir = self._working_dir / 'install' self._meson_native_file = self._source_dir / '.mesonpy-native-file.ini' self._meson_cross_file = self._source_dir / '.mesonpy-cross-file.ini' @@ -897,10 +900,11 @@ def with_temp_working_dir( source_dir: Path = os.path.curdir, build_dir: Optional[Path] = None, meson_args: Optional[MesonArgs] = None, + editable_verbose: bool = False, ) -> Iterator[Project]: """Creates a project instance pointing to a temporary working directory.""" with tempfile.TemporaryDirectory(prefix='.mesonpy-', dir=os.fspath(source_dir)) as tmpdir: - yield cls(source_dir, tmpdir, build_dir, meson_args) + yield cls(source_dir, tmpdir, build_dir, meson_args, editable_verbose) @functools.lru_cache() def _info(self, name: str) -> Dict[str, Any]: @@ -1110,7 +1114,7 @@ def wheel(self, directory: Path) -> pathlib.Path: return file def editable(self, directory: Path) -> pathlib.Path: - file = self._wheel_builder.build_editable(directory) + file = self._wheel_builder.build_editable(directory, self._editable_verbose) assert isinstance(file, pathlib.Path) return file @@ -1151,7 +1155,7 @@ def _validate_string_collection(key: str) -> None: meson_args_cli_keys = tuple(f'{key}-args' for key in meson_args_keys) for key in config_settings: - known_keys = ('builddir', *meson_args_cli_keys) + known_keys = ('builddir', 'editable-verbose', *meson_args_cli_keys) if key not in known_keys: import difflib matches = difflib.get_close_matches(key, known_keys, n=3) @@ -1170,6 +1174,7 @@ def _validate_string_collection(key: str) -> None: key: config_settings.get(f'{key}-args', ()) for key in meson_args_keys }), + editable_verbose=bool(config_settings.get('editable-verbose')) ) as project: yield project diff --git a/mesonpy/_editable.py b/mesonpy/_editable.py index 1def84b93..f452f1aa8 100644 --- a/mesonpy/_editable.py +++ b/mesonpy/_editable.py @@ -3,9 +3,10 @@ import os import subprocess import sys +import warnings from types import ModuleType -from typing import List, Optional, Union +from typing import List, Mapping, Optional, Union if sys.version_info >= (3, 9): @@ -18,6 +19,37 @@ # 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 @@ -32,30 +64,44 @@ def __init__( import_paths: List[str], top_level_modules: List[str], rebuild_commands: List[List[str]], + verbose: bool = False, ) -> None: 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 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: - # 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, - )) - - subprocess.check_output(command, cwd=self._build_path, env=env) + self._proc(command) + self._debug('{cyan}{bold}+ successfully rebuilt{reset}') def find_spec( self, @@ -87,11 +133,14 @@ def install( 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_path, build_path, import_paths, top_level_modules, rebuild_commands) + finder = cls(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 From ebc70dabf756231b3270222481f16603484c7938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Sat, 10 Dec 2022 03:12:14 +0000 Subject: [PATCH 09/20] TST: add VEnv.python and VEnv.pip helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- tests/conftest.py | 6 ++++++ tests/test_wheel.py | 9 ++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9b91f5b64..e177e520a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -89,6 +89,12 @@ def ensure_directories(self, env_dir): self.executable = context.env_exe return context + def python(self, *args: str): + return subprocess.check_output([self.executable, *args]).decode() + + def pip(self, *args: str): + return self.python('-m', 'pip', *args) + @pytest.fixture() def venv(): diff --git a/tests/test_wheel.py b/tests/test_wheel.py index 93086e778..2121c0b53 100644 --- a/tests/test_wheel.py +++ b/tests/test_wheel.py @@ -146,13 +146,8 @@ def test_configure_data(wheel_configure_data): @pytest.mark.skipif(platform.system() != 'Linux', reason='Unsupported on this platform for now') def test_local_lib(venv, wheel_link_against_local_lib): - subprocess.run( - [venv.executable, '-m', 'pip', 'install', wheel_link_against_local_lib], - check=True) - output = subprocess.run( - [venv.executable, '-c', 'import example; print(example.example_sum(1, 2))'], - stdout=subprocess.PIPE, - check=True).stdout + venv.pip('install', wheel_link_against_local_lib) + output = venv.python('-c', 'import example; print(example.example_sum(1, 2))') assert int(output) == 3 From f64dca125cb68b0c2e501d71a762af5f21dc6e2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Sat, 10 Dec 2022 03:44:48 +0000 Subject: [PATCH 10/20] TST: use pytest's tmp_path_factory fixture in venv creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pytest has its own tmpdir management and cleanup, making is easier to inspect the environments when the tests fail. Signed-off-by: Filipe Laíns --- tests/conftest.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index e177e520a..35a89feb2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -97,13 +97,9 @@ def pip(self, *args: str): @pytest.fixture() -def venv(): - path = pathlib.Path(tempfile.mkdtemp(prefix='mesonpy-test-venv-')) - venv = VEnv(path) - try: - yield venv - finally: - shutil.rmtree(path) +def venv(tmp_path_factory): + path = pathlib.Path(tmp_path_factory.mktemp('mesonpy-test-venv')) + return VEnv(path) def generate_package_fixture(package): From 0b4ac54ee7423943c765a2c223a9df9f6ffe4f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Sat, 10 Dec 2022 04:08:13 +0000 Subject: [PATCH 11/20] TST: add test for editable wheels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- tests/conftest.py | 9 +++++++ .../imports-itself-during-build/meson.build | 15 ++++++++++++ .../imports-itself-during-build/plat.c | 24 +++++++++++++++++++ .../imports-itself-during-build/pure.py | 2 ++ .../pyproject.toml | 3 +++ tests/test_wheel.py | 19 +++++++++++++++ 6 files changed, 72 insertions(+) create mode 100644 tests/packages/imports-itself-during-build/meson.build create mode 100644 tests/packages/imports-itself-during-build/plat.c create mode 100644 tests/packages/imports-itself-during-build/pure.py create mode 100644 tests/packages/imports-itself-during-build/pyproject.toml diff --git a/tests/conftest.py b/tests/conftest.py index 35a89feb2..aadecddd5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -126,12 +126,21 @@ def fixture(tmp_path_session): return fixture +def generate_editable_fixture(package): + @pytest.fixture(scope='session') + def fixture(tmp_path_session): + with chdir(package_dir / package), in_git_repo_context(): + return tmp_path_session / mesonpy.build_editable(tmp_path_session) + return fixture + + # inject {package,sdist,wheel}_* fixtures (https://github.com/pytest-dev/pytest/issues/2424) for package in os.listdir(package_dir): normalized = package.replace('-', '_') globals()[f'package_{normalized}'] = generate_package_fixture(package) globals()[f'sdist_{normalized}'] = generate_sdist_fixture(package) globals()[f'wheel_{normalized}'] = generate_wheel_fixture(package) + globals()[f'editable_{normalized}'] = generate_editable_fixture(package) @pytest.fixture(autouse=True, scope='session') diff --git a/tests/packages/imports-itself-during-build/meson.build b/tests/packages/imports-itself-during-build/meson.build new file mode 100644 index 000000000..75a5d14bd --- /dev/null +++ b/tests/packages/imports-itself-during-build/meson.build @@ -0,0 +1,15 @@ +project( + 'imports-itself-during-build', 'c', + version: '1.0.0', +) + +py_mod = import('python') +py = py_mod.find_installation() + +py.install_sources('pure.py') +py.extension_module( + 'plat', 'plat.c', + install: true, +) + +run_command(py, '-c', 'import pure') diff --git a/tests/packages/imports-itself-during-build/plat.c b/tests/packages/imports-itself-during-build/plat.c new file mode 100644 index 000000000..ae22f71b5 --- /dev/null +++ b/tests/packages/imports-itself-during-build/plat.c @@ -0,0 +1,24 @@ +#include + +static PyObject* foo(PyObject* self) +{ + return PyUnicode_FromString("bar"); +} + +static PyMethodDef methods[] = { + {"foo", (PyCFunction)foo, METH_NOARGS, NULL}, + {NULL, NULL, 0, NULL}, +}; + +static struct PyModuleDef module = { + PyModuleDef_HEAD_INIT, + "plat", + NULL, + -1, + methods, +}; + +PyMODINIT_FUNC PyInit_plat(void) +{ + return PyModule_Create(&module); +} diff --git a/tests/packages/imports-itself-during-build/pure.py b/tests/packages/imports-itself-during-build/pure.py new file mode 100644 index 000000000..d84c8b24a --- /dev/null +++ b/tests/packages/imports-itself-during-build/pure.py @@ -0,0 +1,2 @@ +def foo(): + return 'bar' diff --git a/tests/packages/imports-itself-during-build/pyproject.toml b/tests/packages/imports-itself-during-build/pyproject.toml new file mode 100644 index 000000000..d6f3b6861 --- /dev/null +++ b/tests/packages/imports-itself-during-build/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +build-backend = 'mesonpy' +requires = ['meson-python'] diff --git a/tests/test_wheel.py b/tests/test_wheel.py index 2121c0b53..875579129 100644 --- a/tests/test_wheel.py +++ b/tests/test_wheel.py @@ -220,3 +220,22 @@ def test_entrypoints(wheel_full_metadata): [gui_scripts] example-gui = example:gui ''').strip() + + +def test_editable( + package_imports_itself_during_build, + editable_imports_itself_during_build, + venv, +): + venv.pip('install', os.fspath(editable_imports_itself_during_build)) + + assert venv.python('-c', 'import plat; print(plat.foo())').strip() == 'bar' + + plat = package_imports_itself_during_build / 'plat.c' + plat_text = plat.read_text() + try: + plat.write_text(plat_text.replace('bar', 'something else')) + + assert venv.python('-c', 'import plat; print(plat.foo())').strip() == 'something else' + finally: + plat.write_text(plat_text) From 72c870125d9b3e3290278c72ed84627c60207e5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Sat, 10 Dec 2022 04:33:36 +0000 Subject: [PATCH 12/20] TST: add test for top_level_modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- tests/packages/module-types/file.py | 0 tests/packages/module-types/meson.build | 21 ++++++++++++++++ tests/packages/module-types/namespace/data.py | 0 tests/packages/module-types/native.c | 24 +++++++++++++++++++ .../packages/module-types/package/__init__.py | 0 tests/packages/module-types/pyproject.toml | 3 +++ tests/test_project.py | 10 ++++++++ 7 files changed, 58 insertions(+) create mode 100644 tests/packages/module-types/file.py create mode 100644 tests/packages/module-types/meson.build create mode 100644 tests/packages/module-types/namespace/data.py create mode 100644 tests/packages/module-types/native.c create mode 100644 tests/packages/module-types/package/__init__.py create mode 100644 tests/packages/module-types/pyproject.toml diff --git a/tests/packages/module-types/file.py b/tests/packages/module-types/file.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/packages/module-types/meson.build b/tests/packages/module-types/meson.build new file mode 100644 index 000000000..5c5b52535 --- /dev/null +++ b/tests/packages/module-types/meson.build @@ -0,0 +1,21 @@ +project( + 'module-types', 'c', + version: '1.0.0', +) + +py_mod = import('python') +py = py_mod.find_installation() + +py.install_sources('file.py') +py.install_sources( + 'package' / '__init__.py', + subdir: 'package', +) +py.install_sources( + 'namespace' / 'data.py', + subdir: 'namespace', +) +py.extension_module( + 'native', 'native.c', + install: true, +) diff --git a/tests/packages/module-types/namespace/data.py b/tests/packages/module-types/namespace/data.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/packages/module-types/native.c b/tests/packages/module-types/native.c new file mode 100644 index 000000000..ca30346d2 --- /dev/null +++ b/tests/packages/module-types/native.c @@ -0,0 +1,24 @@ +#include + +static PyObject* foo(PyObject* self) +{ + return PyUnicode_FromString("bar"); +} + +static PyMethodDef methods[] = { + {"foo", (PyCFunction)foo, METH_NOARGS, NULL}, + {NULL, NULL, 0, NULL}, +}; + +static struct PyModuleDef module = { + PyModuleDef_HEAD_INIT, + "native", + NULL, + -1, + methods, +}; + +PyMODINIT_FUNC PyInit_native(void) +{ + return PyModule_Create(&module); +} diff --git a/tests/packages/module-types/package/__init__.py b/tests/packages/module-types/package/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/packages/module-types/pyproject.toml b/tests/packages/module-types/pyproject.toml new file mode 100644 index 000000000..d6f3b6861 --- /dev/null +++ b/tests/packages/module-types/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +build-backend = 'mesonpy' +requires = ['meson-python'] diff --git a/tests/test_project.py b/tests/test_project.py index 4cfb177b2..9b5131fcb 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -95,3 +95,13 @@ def last_two_meson_args(): def test_unknown_user_args(package, tmp_path_session): with pytest.raises(mesonpy.ConfigError): mesonpy.Project(package_dir / f'unknown-user-args-{package}', tmp_path_session) + + +def test_top_level_modules(package_module_types): + with mesonpy.Project.with_temp_working_dir() as project: + assert set(project.top_level_modules) == { + 'file', + 'package', + 'namespace', + 'native', + } From 3416d0bc9eee535f1e7dd53c08b97b6ab99ac7cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Tue, 13 Dec 2022 02:19:13 +0000 Subject: [PATCH 13/20] MAINT: use sysconfig in _debian_python on Python >=3.10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- mesonpy/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index 8aac23587..f5064a23f 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -301,6 +301,8 @@ def entrypoints_txt(self) -> bytes: @property def _debian_python(self) -> bool: """Check if we are running on Debian-patched Python.""" + if sys.version_info >= (3, 10): + return 'deb_system' in sysconfig.get_scheme_names() try: import distutils try: From 1833569b60b6e8a21778c241c5243c36b7986c8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Tue, 13 Dec 2022 04:21:13 +0000 Subject: [PATCH 14/20] MAINT: move top_level_modules to _WheelBuilder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- mesonpy/__init__.py | 57 ++++++++++++++++++++----------------------- tests/test_project.py | 10 -------- tests/test_wheel.py | 10 ++++++++ 3 files changed, 37 insertions(+), 40 deletions(-) diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index f5064a23f..02f72bcb9 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -385,6 +385,32 @@ def _stable_abi(self) -> Optional[str]: return stable[0] return None + @property + def top_level_modules(self) -> Collection[str]: + modules = set() + for type_ in self._wheel_files: + for path, _ in self._wheel_files[type_]: + top_part = path.parts[0] + # file module + if top_part.endswith('.py'): + modules.add(top_part[:-3]) + else: + # native module + for extension in _EXTENSION_SUFFIXES: + if top_part.endswith(extension): + modules.add(top_part[:-len(extension)]) + # XXX: We assume the order in _EXTENSION_SUFFIXES + # goes from more specific to last, so we go + # with the first match we find. + break + else: # nobreak + # skip Windows import libraries + if top_part.endswith('.a'): + continue + # package module + modules.add(top_part) + return modules + def _is_native(self, file: Union[str, pathlib.Path]) -> bool: """Check if file is a native file.""" self._project.build() # the project needs to be built for this :/ @@ -605,7 +631,6 @@ 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' - top_level_modules = self._project.top_level_modules rebuild_commands = self._project.build_commands(install_path) import_paths = set() @@ -631,7 +656,7 @@ def build_editable(self, directory: Path, verbose: bool = False) -> pathlib.Path 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(top_level_modules)}, + top_level_modules={_as_python_declaration(self.top_level_modules)}, rebuild_commands={_as_python_declaration(rebuild_commands)}, verbose={verbose}, ) @@ -974,34 +999,6 @@ def version(self) -> str: assert isinstance(version, str) return version - @property - def top_level_modules(self) -> Collection[str]: - modules = set() - for type_ in self._install_plan: - for _, details in self._install_plan[type_].items(): - path = pathlib.Path(details['destination']) - if len(path.parts) >= 2 and path.parts[0]: - top_part = path.parts[1] - # file module - if top_part.endswith('.py'): - modules.add(top_part[:-3]) - else: - # native module - for extension in _EXTENSION_SUFFIXES: - if top_part.endswith(extension): - modules.add(top_part[:-len(extension)]) - # XXX: We assume the order in _EXTENSION_SUFFIXES - # goes from more specific to last, so we go - # with the first match we find. - break - else: # nobreak - # skip Windows import libraries - if top_part.endswith('.a'): - continue - # package module - modules.add(top_part) - return modules - @cached_property def metadata(self) -> bytes: """Project metadata.""" diff --git a/tests/test_project.py b/tests/test_project.py index 9b5131fcb..4cfb177b2 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -95,13 +95,3 @@ def last_two_meson_args(): def test_unknown_user_args(package, tmp_path_session): with pytest.raises(mesonpy.ConfigError): mesonpy.Project(package_dir / f'unknown-user-args-{package}', tmp_path_session) - - -def test_top_level_modules(package_module_types): - with mesonpy.Project.with_temp_working_dir() as project: - assert set(project.top_level_modules) == { - 'file', - 'package', - 'namespace', - 'native', - } diff --git a/tests/test_wheel.py b/tests/test_wheel.py index 875579129..b3d77c65f 100644 --- a/tests/test_wheel.py +++ b/tests/test_wheel.py @@ -222,6 +222,16 @@ def test_entrypoints(wheel_full_metadata): ''').strip() +def test_top_level_modules(package_module_types): + with mesonpy.Project.with_temp_working_dir() as project: + assert set(project._wheel_builder.top_level_modules) == { + 'file', + 'package', + 'namespace', + 'native', + } + + def test_editable( package_imports_itself_during_build, editable_imports_itself_during_build, From d0dc5d55531de372adadf32b613338575def07d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Tue, 13 Dec 2022 08:48:43 +0000 Subject: [PATCH 15/20] TST: ignore couldnt-parse warning in coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index d14ec6f95..3d86fa93e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,11 @@ multi_line_output = 5 known_first_party = 'mesonpy' +[tool.coverage.run] +disable_warnings = [ + 'couldnt-parse', +] + [tool.coverage.html] show_contexts = true From 7d7ff76f57abb684bb2e1d5404e067bb21596bde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Tue, 13 Dec 2022 10:45:18 +0000 Subject: [PATCH 16/20] BUG: fix editable import path calculation when Meson does not match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dirty workaround for cases where Meson does not install to the expected paths, like in macOS. Signed-off-by: Filipe Laíns --- mesonpy/__init__.py | 116 ++++++++++++++++++++++++-------------------- 1 file changed, 63 insertions(+), 53 deletions(-) diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index 02f72bcb9..e1ee089b6 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -148,6 +148,56 @@ def _as_python_declaration(value: Any) -> str: raise NotImplementedError(f'Unsupported type: {type(value)}') +@functools.lru_cache() +def _debian_python() -> bool: + """Check if we are running on Debian-patched Python.""" + if sys.version_info >= (3, 10): + return 'deb_system' in sysconfig.get_scheme_names() + try: + import distutils + try: + import distutils.command.install + except ModuleNotFoundError: + raise ModuleNotFoundError('Unable to import distutils, please install python3-distutils') + return 'deb_system' in distutils.command.install.INSTALL_SCHEMES + except ModuleNotFoundError: + return False + + +@functools.lru_cache() +def _debian_distutils_paths() -> Mapping[str, str]: + # https://ffy00.github.io/blog/02-python-debian-and-the-install-locations/ + assert sys.version_info < (3, 12) and _debian_python() + + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', category=DeprecationWarning) + import distutils.dist + + distribution = distutils.dist.Distribution() + install_cmd = distribution.get_command_obj('install') + install_cmd.install_layout = 'deb' # type: ignore[union-attr] + install_cmd.finalize_options() # type: ignore[union-attr] + + return { + 'data': install_cmd.install_data, # type: ignore[union-attr] + 'platlib': install_cmd.install_platlib, # type: ignore[union-attr] + 'purelib': install_cmd.install_purelib, # type: ignore[union-attr] + 'scripts': install_cmd.install_scripts, # type: ignore[union-attr] + } + + +@functools.lru_cache() +def _sysconfig_paths() -> Mapping[str, str]: + sys_vars = sysconfig.get_config_vars().copy() + sys_vars['base'] = sys_vars['platbase'] = sys.base_prefix + if _debian_python(): + if sys.version_info >= (3, 10): + return sysconfig.get_paths('deb_system', vars=sys_vars) + else: + return _debian_distutils_paths() + return sysconfig.get_paths(vars=sys_vars) + + class Error(RuntimeError): def __str__(self) -> str: return str(self.args[0]) @@ -298,56 +348,6 @@ def entrypoints_txt(self) -> bytes: return text.encode() - @property - def _debian_python(self) -> bool: - """Check if we are running on Debian-patched Python.""" - if sys.version_info >= (3, 10): - return 'deb_system' in sysconfig.get_scheme_names() - try: - import distutils - try: - import distutils.command.install - except ModuleNotFoundError: - raise ModuleNotFoundError('Unable to import distutils, please install python3-distutils') - return 'deb_system' in distutils.command.install.INSTALL_SCHEMES - except ModuleNotFoundError: - return False - - @property - def _debian_distutils_paths(self) -> Mapping[str, str]: - # https://ffy00.github.io/blog/02-python-debian-and-the-install-locations/ - assert sys.version_info < (3, 12) and self._debian_python - - with warnings.catch_warnings(): - warnings.filterwarnings('ignore', category=DeprecationWarning) - import distutils.dist - - distribution = distutils.dist.Distribution({ - 'name': self._project.name, - }) - install_cmd = distribution.get_command_obj('install') - install_cmd.install_layout = 'deb' # type: ignore[union-attr] - install_cmd.finalize_options() # type: ignore[union-attr] - - return { - 'data': install_cmd.install_data, # type: ignore[union-attr] - 'headers': install_cmd.install_headers, # type: ignore[union-attr] - 'platlib': install_cmd.install_platlib, # type: ignore[union-attr] - 'purelib': install_cmd.install_purelib, # type: ignore[union-attr] - 'scripts': install_cmd.install_scripts, # type: ignore[union-attr] - } - - @property - def _sysconfig_paths(self) -> Mapping[str, str]: - sys_vars = sysconfig.get_config_vars().copy() - sys_vars['base'] = sys_vars['platbase'] = sys.base_prefix - if self._debian_python: - if sys.version_info >= (3, 10): - return sysconfig.get_paths('deb_system', vars=sys_vars) - else: - return self._debian_distutils_paths - return sysconfig.get_paths(vars=sys_vars) - @cached_property def _stable_abi(self) -> Optional[str]: """Determine stabe ABI compatibility. @@ -461,9 +461,9 @@ def _map_from_heuristics(self, origin: pathlib.Path, destination: pathlib.Path) origin file and the Meson destination path. """ warnings.warn('Using heuristics to map files to wheel, this may result in incorrect locations') - sys_paths = self._sysconfig_paths + sys_paths = _sysconfig_paths() # Try to map to Debian dist-packages - if self._debian_python: + if _debian_python(): search_path = origin while search_path != search_path.parent: search_path = search_path.parent @@ -634,7 +634,7 @@ def build_editable(self, directory: Path, verbose: bool = False) -> pathlib.Path rebuild_commands = self._project.build_commands(install_path) import_paths = set() - for name, raw_path in self._sysconfig_paths.items(): + for name, raw_path in _sysconfig_paths().items(): if name not in ('purelib', 'platlib'): continue path = pathlib.Path(raw_path) @@ -833,6 +833,7 @@ def _configure(self, reconfigure: bool = False) -> None: We will try to reconfigure the build directory if possible to avoid expensive rebuilds. """ + sys_paths = _sysconfig_paths() setup_args = [ f'--prefix={sys.base_prefix}', os.fspath(self._source_dir), @@ -841,6 +842,15 @@ def _configure(self, reconfigure: bool = False) -> None: # TODO: Allow configuring these arguments '-Ddebug=false', '-Doptimization=2', + + # XXX: This should not be needed, but Meson is using the wrong paths + # in some scenarios, like on macOS. + # https://github.com/mesonbuild/meson-python/pull/87#discussion_r1047041306 + '--python.purelibdir', + sys_paths['purelib'], + '--python.platlibdir', + sys_paths['platlib'], + # user args *self._meson_args['setup'], ] From 2305efb248e0d02590566addbb13bba22faa376d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Tue, 13 Dec 2022 12:11:39 +0000 Subject: [PATCH 17/20] MAINT: split path introspection into _introspection module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- meson.build | 1 + mesonpy/__init__.py | 59 +++------------------------------- mesonpy/_introspection.py | 66 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 54 deletions(-) create mode 100644 mesonpy/_introspection.py diff --git a/meson.build b/meson.build index bbb95dfb9..831c72107 100644 --- a/meson.build +++ b/meson.build @@ -12,6 +12,7 @@ py.install_sources( 'mesonpy/_compat.py', 'mesonpy/_editable.py', 'mesonpy/_elf.py', + 'mesonpy/_introspection.py', 'mesonpy/_tags.py', 'mesonpy/_util.py', 'mesonpy/_wheelfile.py', diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index e1ee089b6..7098c1004 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -49,6 +49,7 @@ import mesonpy._compat import mesonpy._elf +import mesonpy._introspection import mesonpy._tags import mesonpy._util import mesonpy._wheelfile @@ -148,56 +149,6 @@ def _as_python_declaration(value: Any) -> str: raise NotImplementedError(f'Unsupported type: {type(value)}') -@functools.lru_cache() -def _debian_python() -> bool: - """Check if we are running on Debian-patched Python.""" - if sys.version_info >= (3, 10): - return 'deb_system' in sysconfig.get_scheme_names() - try: - import distutils - try: - import distutils.command.install - except ModuleNotFoundError: - raise ModuleNotFoundError('Unable to import distutils, please install python3-distutils') - return 'deb_system' in distutils.command.install.INSTALL_SCHEMES - except ModuleNotFoundError: - return False - - -@functools.lru_cache() -def _debian_distutils_paths() -> Mapping[str, str]: - # https://ffy00.github.io/blog/02-python-debian-and-the-install-locations/ - assert sys.version_info < (3, 12) and _debian_python() - - with warnings.catch_warnings(): - warnings.filterwarnings('ignore', category=DeprecationWarning) - import distutils.dist - - distribution = distutils.dist.Distribution() - install_cmd = distribution.get_command_obj('install') - install_cmd.install_layout = 'deb' # type: ignore[union-attr] - install_cmd.finalize_options() # type: ignore[union-attr] - - return { - 'data': install_cmd.install_data, # type: ignore[union-attr] - 'platlib': install_cmd.install_platlib, # type: ignore[union-attr] - 'purelib': install_cmd.install_purelib, # type: ignore[union-attr] - 'scripts': install_cmd.install_scripts, # type: ignore[union-attr] - } - - -@functools.lru_cache() -def _sysconfig_paths() -> Mapping[str, str]: - sys_vars = sysconfig.get_config_vars().copy() - sys_vars['base'] = sys_vars['platbase'] = sys.base_prefix - if _debian_python(): - if sys.version_info >= (3, 10): - return sysconfig.get_paths('deb_system', vars=sys_vars) - else: - return _debian_distutils_paths() - return sysconfig.get_paths(vars=sys_vars) - - class Error(RuntimeError): def __str__(self) -> str: return str(self.args[0]) @@ -461,9 +412,9 @@ def _map_from_heuristics(self, origin: pathlib.Path, destination: pathlib.Path) origin file and the Meson destination path. """ warnings.warn('Using heuristics to map files to wheel, this may result in incorrect locations') - sys_paths = _sysconfig_paths() + sys_paths = mesonpy._introspection.SYSCONFIG_PATHS # Try to map to Debian dist-packages - if _debian_python(): + if mesonpy._introspection.DEBIAN_PYTHON: search_path = origin while search_path != search_path.parent: search_path = search_path.parent @@ -634,7 +585,7 @@ def build_editable(self, directory: Path, verbose: bool = False) -> pathlib.Path rebuild_commands = self._project.build_commands(install_path) import_paths = set() - for name, raw_path in _sysconfig_paths().items(): + for name, raw_path in mesonpy._introspection.SYSCONFIG_PATHS.items(): if name not in ('purelib', 'platlib'): continue path = pathlib.Path(raw_path) @@ -833,7 +784,7 @@ def _configure(self, reconfigure: bool = False) -> None: We will try to reconfigure the build directory if possible to avoid expensive rebuilds. """ - sys_paths = _sysconfig_paths() + sys_paths = mesonpy._introspection.SYSCONFIG_PATHS setup_args = [ f'--prefix={sys.base_prefix}', os.fspath(self._source_dir), diff --git a/mesonpy/_introspection.py b/mesonpy/_introspection.py new file mode 100644 index 000000000..7df50daf6 --- /dev/null +++ b/mesonpy/_introspection.py @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: MIT + +import sys +import sysconfig +import warnings + +from mesonpy._compat import Mapping + + +def debian_python() -> bool: + """Check if we are running on Debian-patched Python.""" + if sys.version_info >= (3, 10): + return 'deb_system' in sysconfig.get_scheme_names() + try: + import distutils + try: + import distutils.command.install + except ModuleNotFoundError: + raise ModuleNotFoundError('Unable to import distutils, please install python3-distutils') + return 'deb_system' in distutils.command.install.INSTALL_SCHEMES + except ModuleNotFoundError: + return False + + +DEBIAN_PYTHON = debian_python() + + +def debian_distutils_paths() -> Mapping[str, str]: + # https://ffy00.github.io/blog/02-python-debian-and-the-install-locations/ + assert sys.version_info < (3, 12) and DEBIAN_PYTHON + + with warnings.catch_warnings(): + warnings.filterwarnings('ignore', category=DeprecationWarning) + import distutils.dist + + distribution = distutils.dist.Distribution() + install_cmd = distribution.get_command_obj('install') + install_cmd.install_layout = 'deb' # type: ignore[union-attr] + install_cmd.finalize_options() # type: ignore[union-attr] + + return { + 'data': install_cmd.install_data, # type: ignore[union-attr] + 'platlib': install_cmd.install_platlib, # type: ignore[union-attr] + 'purelib': install_cmd.install_purelib, # type: ignore[union-attr] + 'scripts': install_cmd.install_scripts, # type: ignore[union-attr] + } + + +def sysconfig_paths() -> Mapping[str, str]: + sys_vars = sysconfig.get_config_vars().copy() + sys_vars['base'] = sys_vars['platbase'] = sys.base_prefix + if DEBIAN_PYTHON: + if sys.version_info >= (3, 10): + return sysconfig.get_paths('deb_system', vars=sys_vars) + else: + return debian_distutils_paths() + return sysconfig.get_paths(vars=sys_vars) + + +SYSCONFIG_PATHS = sysconfig_paths() + + +__all__ = [ + 'DEBIAN_PYTHON', + 'SYSCONFIG_PATHS', +] From 920c4bd0903bd42a0c06a8660c7d80bd3a935f9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Wed, 21 Dec 2022 20:13:58 +0000 Subject: [PATCH 18/20] BUG: fix deb_system sysconfing scheme addition version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- mesonpy/_introspection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mesonpy/_introspection.py b/mesonpy/_introspection.py index 7df50daf6..1b214df82 100644 --- a/mesonpy/_introspection.py +++ b/mesonpy/_introspection.py @@ -50,7 +50,7 @@ def sysconfig_paths() -> Mapping[str, str]: sys_vars = sysconfig.get_config_vars().copy() sys_vars['base'] = sys_vars['platbase'] = sys.base_prefix if DEBIAN_PYTHON: - if sys.version_info >= (3, 10): + if sys.version_info >= (3, 10, 3): return sysconfig.get_paths('deb_system', vars=sys_vars) else: return debian_distutils_paths() From c10fc01fdc6a910780f81c93bf2edb65b5aef1e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Thu, 22 Dec 2022 16:57:30 +0000 Subject: [PATCH 19/20] DOC: add simple editable installs documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- docs/index.rst | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/docs/index.rst b/docs/index.rst index ecf43aac6..d9ed3df2d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -49,6 +49,56 @@ frontend, like ``builddir``, to specify the build directory to use/re-use. You can find more information about them in the `build options page`_. +Editable installs +----------------- + +Editable installs allow you to install the project in such a way where you can +edit the project source and have those changes be reflected in the installed +module, without having to explicitly rebuild and reinstall. + +The way it works on ``meson-python`` specifically, is that when you import a +module of the project, it will be rebuilt on the fly. This means that project +imports will take more time than usual. + +You can use pip to install the project in editable mode. + + +.. code-block:: + + python -m pip install -e . + + +It might be helpful to see the output of the Meson_ commands. This is offered +as a **provisional** feature, meaning it is subject to change. + +If you want to temporarily enable the output, you can set the +``MESONPY_EDITABLE_VERBOSE`` environment variable to a non-empty value. If this +environment variable is present during import, the Meson_ commands and their +output will be printed. + + +.. code-block:: + + MESONPY_EDITABLE_VERBOSE=1 python my_script.py + + + +This behavior can also be enabled by default by passing the ``editable-verbose`` +config setting when installing the project. + + +.. code-block:: + + python -m pip install -e . --config-settings editable-verbose=true + + +This way, you won't need to always set ``MESONPY_EDITABLE_VERBOSE`` environment +variable, the Meson_ commands and their output will always be printed. + +The ``MESONPY_EDITABLE_VERBOSE`` won't have any effect during the project +install step. + + How does it work? ================= From d0011ecf3813c1182fb201c59cb853b07abca175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filipe=20La=C3=ADns?= Date: Fri, 23 Dec 2022 12:32:24 +0000 Subject: [PATCH 20/20] ENH: add error if we can't locate the project in the editable hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filipe Laíns --- mesonpy/__init__.py | 2 ++ mesonpy/_editable.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/mesonpy/__init__.py b/mesonpy/__init__.py index 7098c1004..44a05b021 100644 --- a/mesonpy/__init__.py +++ b/mesonpy/__init__.py @@ -604,6 +604,8 @@ def build_editable(self, directory: Path, verbose: bool = False) -> pathlib.Path 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)}, diff --git a/mesonpy/_editable.py b/mesonpy/_editable.py index f452f1aa8..f3690c3ec 100644 --- a/mesonpy/_editable.py +++ b/mesonpy/_editable.py @@ -59,6 +59,8 @@ class MesonpyFinder(importlib.abc.MetaPathFinder): def __init__( self, + project_name: str, + hook_name: str, project_path: str, build_path: str, import_paths: List[str], @@ -66,6 +68,8 @@ def __init__( 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 @@ -73,6 +77,18 @@ def __init__( 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})' @@ -128,6 +144,8 @@ def find_spec( @classmethod def install( cls, + project_name: str, + hook_name: str, project_path: str, build_path: str, import_paths: List[str], @@ -140,7 +158,16 @@ def install( if os.environ.get('MESONPY_EDITABLE_VERBOSE', ''): verbose = True # install our finder - finder = cls(project_path, build_path, import_paths, top_level_modules, rebuild_commands, verbose) + 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