Skip to content

Commit c32fa12

Browse files
committed
ENH: add support for editable installs
Signed-off-by: Filipe Laíns <[email protected]>
1 parent 753ce5b commit c32fa12

File tree

2 files changed

+203
-22
lines changed

2 files changed

+203
-22
lines changed

mesonpy/__init__.py

Lines changed: 133 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import collections
1414
import contextlib
15+
import dataclasses
1516
import functools
1617
import importlib.machinery
1718
import io
@@ -497,24 +498,27 @@ def _install_path(
497498

498499
wheel_file.write(origin, location)
499500

501+
def _wheel_write_metadata(self, whl: mesonpy._wheelfile.WheelFile, editable: bool = False) -> None:
502+
# add metadata
503+
whl.writestr(f'{self.distinfo_dir}/METADATA', self._project.metadata(editable))
504+
whl.writestr(f'{self.distinfo_dir}/WHEEL', self.wheel)
505+
if self.entrypoints_txt:
506+
whl.writestr(f'{self.distinfo_dir}/entry_points.txt', self.entrypoints_txt)
507+
508+
# add license (see https://github.com/mesonbuild/meson-python/issues/88)
509+
if self._project.license_file:
510+
whl.write(
511+
self._source_dir / self._project.license_file,
512+
f'{self.distinfo_dir}/{os.path.basename(self._project.license_file)}',
513+
)
514+
500515
def build(self, directory: Path) -> pathlib.Path:
501516
self._project.build() # ensure project is built
502517

503518
wheel_file = pathlib.Path(directory, f'{self.name}.whl')
504519

505520
with mesonpy._wheelfile.WheelFile(wheel_file, 'w') as whl:
506-
# add metadata
507-
whl.writestr(f'{self.distinfo_dir}/METADATA', self._project.metadata)
508-
whl.writestr(f'{self.distinfo_dir}/WHEEL', self.wheel)
509-
if self.entrypoints_txt:
510-
whl.writestr(f'{self.distinfo_dir}/entry_points.txt', self.entrypoints_txt)
511-
512-
# add license (see https://github.com/mesonbuild/meson-python/issues/88)
513-
if self._project.license_file:
514-
whl.write(
515-
self._source_dir / self._project.license_file,
516-
f'{self.distinfo_dir}/{os.path.basename(self._project.license_file)}',
517-
)
521+
self._wheel_write_metadata(whl)
518522

519523
print('{light_blue}{bold}Copying files to wheel...{reset}'.format(**_STYLES))
520524
with mesonpy._util.cli_counter(
@@ -542,7 +546,49 @@ def build(self, directory: Path) -> pathlib.Path:
542546
return wheel_file
543547

544548
def build_editable(self, directory: Path) -> pathlib.Path:
545-
self._build()
549+
self._project.build() # ensure project is built
550+
551+
wheel_file = pathlib.Path(directory, f'{self.name}.whl')
552+
553+
project_path = self._source_dir / '.mesonpy-editable'
554+
top_level_modules = self._project.top_level_modules
555+
rebuild_commands = [list(cmd) for cmd in self._project.build_commands(project_path)]
556+
557+
with mesonpy._wheelfile.WheelFile(wheel_file, 'w') as whl:
558+
self._wheel_write_metadata(whl, editable=True)
559+
whl.writestr(
560+
f'{self.distinfo_dir}/direct_url.json.py',
561+
f'file://{os.fspath(self._project.source_dir)}'.encode(),
562+
)
563+
564+
# install hook module
565+
hook_module_name = f'_mesonpy_hook_{self.normalized_name.replace(".", "_")}'
566+
whl.writestr(
567+
f'{hook_module_name}.py',
568+
textwrap.dedent(f'''
569+
import mesonpy._editable
570+
mesonpy._editable.MesonpyFinder.install(
571+
project_path={project_path},
572+
top_level_modules={top_level_modules},
573+
rebuild_commands={rebuild_commands},
574+
)
575+
''').strip().encode(),
576+
)
577+
# install .pth file
578+
whl.writestr(
579+
f'_mesonpy_{self.normalized_name}.pth',
580+
f'import {hook_module_name}'.encode(),
581+
)
582+
583+
# install non-code schemes
584+
for scheme in self._SCHEME_MAP:
585+
if scheme in ('purelib', 'platlib', 'mesonpy-libs'):
586+
continue
587+
for destination, origin in self._wheel_files[scheme]:
588+
destination = pathlib.Path(self.data_dir, scheme, destination)
589+
whl.write(origin, destination.as_posix())
590+
591+
return wheel_file
546592

547593

548594
MesonArgsKeys = Literal['dist', 'setup', 'compile', 'install']
@@ -733,11 +779,29 @@ def _wheel_builder(self) -> _WheelBuilder:
733779
self._copy_files,
734780
)
735781

782+
@property
783+
def source_dir(self) -> pathlib.Path:
784+
return self._source_dir
785+
786+
@property
787+
def working_dir(self) -> pathlib.Path:
788+
return self._working_dir
789+
790+
@property
791+
def build_dir(self) -> pathlib.Path:
792+
return self._build_dir
793+
794+
def build_commands(self, install_dir: Optional[pathlib.Path] = None) -> Collection[Sequence[str]]:
795+
return (
796+
('meson', 'compile', *self._meson_args['compile'],),
797+
('meson', 'install', '--destdir', os.fspath(install_dir or self._install_dir), *self._meson_args['install'],),
798+
)
799+
736800
@functools.lru_cache(maxsize=None)
737801
def build(self) -> None:
738802
"""Trigger the Meson build."""
739-
self._meson('compile', *self._meson_args['compile'],)
740-
self._meson('install', '--destdir', os.fspath(self._install_dir), *self._meson_args['install'],)
803+
for cmd in self.build_commands():
804+
self._meson(*cmd[1:])
741805

742806
@classmethod
743807
@contextlib.contextmanager
@@ -817,21 +881,46 @@ def version(self) -> str:
817881
assert isinstance(version, str)
818882
return version
819883

820-
@cached_property
821-
def metadata(self) -> bytes:
884+
@property
885+
def top_level_modules(self) -> Collection[str]:
886+
return {
887+
os.path.dirname(self._copy_files[file])
888+
for files in self._install_plan.values()
889+
for file, details in files.items()
890+
if details['destination'].startswith('{purelib}') or details['destination'].startswith('{platlib}')
891+
}
892+
893+
@functools.lru_cache(maxsize=2)
894+
def metadata(self, editable: bool = False) -> bytes:
822895
"""Project metadata."""
823896
# the rest of the keys are only available when using PEP 621 metadata
824897
if not self.pep621:
825-
return textwrap.dedent(f'''
898+
data = textwrap.dedent(f'''
826899
Metadata-Version: 2.1
827900
Name: {self.name}
828901
Version: {self.version}
829-
''').strip().encode()
902+
''').strip()
903+
if editable:
904+
data += f'\nRequires-Dist: mesonpy=={__version__}'
905+
return data.encode()
906+
830907
# re-import pyproject_metadata to raise ModuleNotFoundError if it is really missing
831908
import pyproject_metadata # noqa: F401, F811
832909
assert self._metadata
910+
911+
metadata = dataclasses.replace(self._metadata)
912+
# add extra dependency required for the editable hook
913+
if editable:
914+
from packaging.requirements import Requirement
915+
metadata.dependencies.append(Requirement(f'mesonpy=={__version__}'))
916+
917+
core_metadata = metadata.as_rfc822()
918+
833919
# use self.version as the version may be dynamic -- fetched from Meson
834-
core_metadata = self._metadata.as_rfc822()
920+
#
921+
# we need to overwrite this field in the RFC822 field as
922+
# pyproject_metadata removes 'version' from the dynamic fields when
923+
# giving it a value via the dataclass
835924
core_metadata.headers['Version'] = [self.version]
836925
return bytes(core_metadata)
837926

@@ -912,8 +1001,9 @@ def sdist(self, directory: Path) -> pathlib.Path:
9121001
pkginfo_info = tarfile.TarInfo(f'{dist_name}/PKG-INFO')
9131002
if mtime:
9141003
pkginfo_info.mtime = mtime
915-
pkginfo_info.size = len(self.metadata)
916-
tar.addfile(pkginfo_info, fileobj=io.BytesIO(self.metadata))
1004+
metadata = self.metadata()
1005+
pkginfo_info.size = len(metadata)
1006+
tar.addfile(pkginfo_info, fileobj=io.BytesIO(metadata))
9171007

9181008
return sdist
9191009

@@ -925,6 +1015,9 @@ def wheel(self, directory: Path) -> pathlib.Path: # noqa: F811
9251015
shutil.move(os.fspath(wheel), final_wheel)
9261016
return final_wheel
9271017

1018+
def editable(self, directory: Path) -> pathlib.Path:
1019+
return self._wheel_builder.build_editable(directory)
1020+
9281021

9291022
@contextlib.contextmanager
9301023
def _project(config_settings: Optional[Dict[Any, Any]]) -> Iterator[Project]:
@@ -1061,3 +1154,21 @@ def build_wheel(
10611154
out = pathlib.Path(wheel_directory)
10621155
with _project(config_settings) as project:
10631156
return project.wheel(out).name
1157+
1158+
1159+
def build_editable(
1160+
wheel_directory: str,
1161+
config_settings: Optional[Dict[Any, Any]] = None,
1162+
metadata_directory: Optional[str] = None,
1163+
) -> str:
1164+
_setup_cli()
1165+
1166+
out = pathlib.Path(wheel_directory)
1167+
with _project(config_settings) as project:
1168+
return project.editable(out).name
1169+
1170+
1171+
def get_requires_for_build_editable(
1172+
config_settings: Optional[Dict[str, str]] = None,
1173+
) -> List[str]:
1174+
return get_requires_for_build_wheel()

mesonpy/_editable.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import functools
2+
import importlib.abc
3+
import os
4+
import subprocess
5+
import sys
6+
7+
from types import ModuleType
8+
from typing import Optional, Union
9+
10+
from mesonpy._compat import Collection, Sequence
11+
12+
13+
class MesonpyFinder(importlib.abc.MetaPathFinder):
14+
"""Custom loader that whose purpose is to detect when the import system is
15+
trying to load our modules, and trigger a rebuild. After triggering a
16+
rebuild, we return None in find_spec, letting the normal finders pick up the
17+
modules.
18+
"""
19+
20+
def __init__(
21+
self,
22+
project_path: str,
23+
top_level_modules: Collection[str],
24+
rebuild_commands: Sequence[Sequence[str]],
25+
) -> None:
26+
if not os.path.isabs(project_path):
27+
raise ImportError('Project path must be absolute')
28+
self._project_path = project_path
29+
self._top_level_modules = top_level_modules
30+
self._rebuild_commands = rebuild_commands
31+
32+
def __repr__(self) -> str:
33+
return f'{self.__class__}({self._project_path})'
34+
35+
def __hash__(self) -> int:
36+
return hash((self._project_path, self._top_level_modules, self._rebuild_commands))
37+
38+
@functools.lru_cache(maxsize=1)
39+
def rebuild(self) -> None:
40+
for command in self._rebuild_commands:
41+
subprocess.check_call(command)
42+
43+
def find_spec(
44+
self,
45+
fullname: str,
46+
path: Optional[Sequence[Union[str, bytes]]],
47+
target: Optional[ModuleType] = None,
48+
) -> None:
49+
# if it's one of our modules, trigger a rebuild
50+
if fullname.split('.', maxsplit=1)[0] in self._top_level_modules:
51+
self.rebuild()
52+
# return none (meaning we "didn't find" the module) and let the normal
53+
# finders find/import it
54+
return None
55+
56+
@classmethod
57+
def install(
58+
cls,
59+
project_path: str,
60+
top_level_modules: Collection[str],
61+
rebuild_commands: Sequence[Sequence[str]],
62+
) -> None:
63+
finder = cls(project_path, top_level_modules, rebuild_commands)
64+
if finder not in sys.meta_path:
65+
# prepend our finder to sys.meta_path, so that it is queried before
66+
# the normal finders, and can trigger a project rebuild
67+
sys.meta_path.insert(0, finder)
68+
# add the project path to sys.path, so that the normal finder can
69+
# find our modules
70+
sys.path.append(project_path)

0 commit comments

Comments
 (0)