Skip to content

Commit b0059c7

Browse files
committed
WIP: editable wheels alternative implementation
1 parent 5d68ce6 commit b0059c7

File tree

2 files changed

+60
-199
lines changed

2 files changed

+60
-199
lines changed

mesonpy/__init__.py

Lines changed: 10 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -594,56 +594,27 @@ def build_editable(self, directory: Path, verbose: bool = False) -> pathlib.Path
594594

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

597-
install_path = self._source_dir / '.mesonpy' / 'editable' / 'install'
598-
rebuild_commands = self._project.build_commands(install_path)
599-
600-
import_paths = set()
601-
for name, raw_path in mesonpy._introspection.SYSCONFIG_PATHS.items():
602-
if name not in ('purelib', 'platlib'):
603-
continue
604-
path = pathlib.Path(raw_path)
605-
import_paths.add(install_path / path.relative_to(path.anchor))
606-
607-
install_path.mkdir(parents=True, exist_ok=True)
608-
609597
with mesonpy._wheelfile.WheelFile(wheel_file, 'w') as whl:
610598
self._wheel_write_metadata(whl)
611599
whl.writestr(
612600
f'{self.distinfo_dir}/direct_url.json',
613-
self._source_dir.as_uri().encode(),
601+
self._source_dir.as_uri().encode('utf-8'),
614602
)
615603

616604
# install hook module
617-
hook_module_name = f'_mesonpy_hook_{self.normalized_name.replace(".", "_")}'
618-
hook_install_code = textwrap.dedent(f'''
619-
MesonpyFinder.install(
620-
project_name={_as_python_declaration(self._project.name)},
621-
hook_name={_as_python_declaration(hook_module_name)},
622-
project_path={_as_python_declaration(self._source_dir)},
623-
build_path={_as_python_declaration(self._build_dir)},
624-
import_paths={_as_python_declaration(import_paths)},
625-
top_level_modules={_as_python_declaration(self.top_level_modules)},
626-
rebuild_commands={_as_python_declaration(rebuild_commands)},
627-
verbose={verbose},
628-
)
629-
''').strip().encode()
605+
hook_module_name = f'_{self.normalized_name.replace(".", "_")}_editable_loader'
630606
whl.writestr(
631607
f'{hook_module_name}.py',
632-
read_binary('mesonpy', '_editable.py') + hook_install_code,
633-
)
608+
read_binary('mesonpy', '_editable.py') + textwrap.dedent(f'''
609+
install(
610+
{self.top_level_modules!r},
611+
{os.fspath(self._build_dir)!r}
612+
)''').encode('utf-8'))
613+
634614
# install .pth file
635615
whl.writestr(
636-
f'{self.normalized_name}-editable-hook.pth',
637-
f'import {hook_module_name}'.encode(),
638-
)
639-
640-
# install non-code schemes
641-
for scheme in self._SCHEME_MAP:
642-
if scheme in ('purelib', 'platlib', 'mesonpy-libs'):
643-
continue
644-
for destination, origin in self._wheel_files[scheme]:
645-
destination = pathlib.Path(self.data_dir, scheme, destination)
646-
whl.write(origin, destination.as_posix())
616+
f'{self.normalized_name}-editable.pth',
617+
f'import {hook_module_name}'.encode('utf-8'))
647618

648619
return wheel_file
649620

mesonpy/_editable.py

Lines changed: 50 additions & 160 deletions
Original file line numberDiff line numberDiff line change
@@ -1,180 +1,70 @@
1-
import functools
1+
from __future__ import annotations
2+
23
import importlib.abc
4+
import importlib.machinery
5+
import importlib.util
6+
import json
37
import os
8+
import pathlib
9+
import re
410
import subprocess
511
import sys
6-
import warnings
712

813
from types import ModuleType
9-
from typing import List, Mapping, Optional, Union
10-
11-
12-
if sys.version_info >= (3, 9):
13-
from collections.abc import Sequence
14-
else:
15-
from typing import Sequence
16-
17-
18-
# This file should be standalone!
19-
# It is copied during the editable hook installation.
20-
21-
22-
_COLORS = {
23-
'cyan': '\33[36m',
24-
'yellow': '\33[93m',
25-
'light_blue': '\33[94m',
26-
'bold': '\33[1m',
27-
'dim': '\33[2m',
28-
'underline': '\33[4m',
29-
'reset': '\33[0m',
30-
}
31-
_NO_COLORS = {color: '' for color in _COLORS}
32-
14+
from typing import Any, Dict, Optional, Sequence, Set, Union
3315

34-
def _init_colors() -> Mapping[str, str]:
35-
"""Detect if we should be using colors in the output. We will enable colors
36-
if running in a TTY, and no environment variable overrides it. Setting the
37-
NO_COLOR (https://no-color.org/) environment variable force-disables colors,
38-
and FORCE_COLOR forces color to be used, which is useful for thing like
39-
Github actions.
40-
"""
41-
if 'NO_COLOR' in os.environ:
42-
if 'FORCE_COLOR' in os.environ:
43-
warnings.warn('Both NO_COLOR and FORCE_COLOR environment variables are set, disabling color')
44-
return _NO_COLORS
45-
elif 'FORCE_COLOR' in os.environ or sys.stdout.isatty():
46-
return _COLORS
47-
return _NO_COLORS
4816

17+
MARKER = 'MESON_PYTHON_EDITABLE_SKIP'
18+
SUFFIXES = frozenset(importlib.machinery.SOURCE_SUFFIXES + importlib.machinery.EXTENSION_SUFFIXES)
4919

50-
_STYLES = _init_colors() # holds the color values, should be _COLORS or _NO_COLORS
5120

21+
def collect_modules(install_plan: Dict[str, Dict[str, Any]]) -> Dict[str, str]:
22+
modules = {}
23+
for src, target in install_plan['targets'].items():
24+
path = pathlib.Path(target['destination'])
25+
if path.parts[0] in {'{py_platlib}', '{py_purelib}'}:
26+
match = re.match(r'^([^.]+)(.*)$', path.name)
27+
assert match is not None
28+
name, suffix = match.groups()
29+
if suffix in SUFFIXES:
30+
module = '.'.join(path.parts[1:-1] + (name, ))
31+
modules[module] = src
32+
return modules
5233

53-
class MesonpyFinder(importlib.abc.MetaPathFinder):
54-
"""Custom loader that whose purpose is to detect when the import system is
55-
trying to load our modules, and trigger a rebuild. After triggering a
56-
rebuild, we return None in find_spec, letting the normal finders pick up the
57-
modules.
58-
"""
5934

60-
def __init__(
61-
self,
62-
project_name: str,
63-
hook_name: str,
64-
project_path: str,
65-
build_path: str,
66-
import_paths: List[str],
67-
top_level_modules: List[str],
68-
rebuild_commands: List[List[str]],
69-
verbose: bool = False,
70-
) -> None:
71-
self._project_name = project_name
72-
self._hook_name = hook_name
73-
self._project_path = project_path
74-
self._build_path = build_path
75-
self._import_paths = import_paths
76-
self._top_level_modules = top_level_modules
77-
self._rebuild_commands = rebuild_commands
78-
self._verbose = verbose
79-
80-
for path in (self._project_path, self._build_path):
81-
if not os.path.isdir(path):
82-
raise ImportError(
83-
f'{path} is not a directory, but it is required to rebuild '
84-
f'"{self._project_name}", which is installed in editable '
85-
'mode. Please reinstall the project to get it back to '
86-
'working condition. If there are any issues uninstalling '
87-
'this installation, you can manually remove '
88-
f'{self._hook_name} and {os.path.basename(__file__)}, '
89-
f'located in {os.path.dirname(__file__)}.'
90-
)
91-
92-
def __repr__(self) -> str:
93-
return f'{self.__class__.__name__}({self._project_path})'
94-
95-
def _debug(self, msg: str) -> None:
96-
if self._verbose:
97-
print(msg.format(**_STYLES))
98-
99-
def _proc(self, command: List[str]) -> None:
100-
# skip editable hook installation in subprocesses, as during the build
101-
# commands the module we are rebuilding might be imported, causing a
102-
# rebuild loop
103-
# see https://github.com/mesonbuild/meson-python/pull/87#issuecomment-1342548894
104-
env = os.environ.copy()
105-
env['_MESONPY_EDITABLE_SKIP'] = os.pathsep.join((
106-
env.get('_MESONPY_EDITABLE_SKIP', ''),
107-
self._project_path,
108-
))
109-
110-
if self._verbose:
111-
subprocess.check_call(command, cwd=self._build_path, env=env)
112-
else:
113-
subprocess.check_output(command, cwd=self._build_path, env=env)
114-
115-
@functools.lru_cache(maxsize=1)
116-
def rebuild(self) -> None:
117-
self._debug(f'{{cyan}}{{bold}}+ rebuilding {self._project_path}{{reset}}')
118-
for command in self._rebuild_commands:
119-
self._proc(command)
120-
self._debug('{cyan}{bold}+ successfully rebuilt{reset}')
35+
class MesonpyMetaFinder(importlib.abc.MetaPathFinder):
36+
def __init__(self, names: Set[str], path: str):
37+
self._top_level_modules = names
38+
self._build_path = path
12139

12240
def find_spec(
123-
self,
124-
fullname: str,
125-
path: Optional[Sequence[Union[str, bytes]]],
126-
target: Optional[ModuleType] = None,
127-
) -> None:
128-
# if it's one of our modules, trigger a rebuild
41+
self,
42+
fullname: str,
43+
path: Optional[Sequence[Union[bytes, str]]] = None,
44+
target: Optional[ModuleType] = None
45+
) -> Optional[importlib.machinery.ModuleSpec]:
12946
if fullname.split('.', maxsplit=1)[0] in self._top_level_modules:
47+
if self._build_path in os.environ.get(MARKER, '').split(os.pathsep):
48+
return None
13049
self.rebuild()
131-
# prepend the project path to sys.path, so that the normal finder
132-
# can find our modules
133-
# we prepend so that our path comes before the current path (if
134-
# the interpreter is run with -m), see gh-239
135-
if sys.path[:len(self._import_paths)] != self._import_paths:
136-
for path in self._import_paths:
137-
if path in sys.path:
138-
sys.path.remove(path)
139-
sys.path = self._import_paths + sys.path
140-
# return none (meaning we "didn't find" the module) and let the normal
141-
# finders find/import it
50+
install_plan_path = os.path.join(self._build_path, 'meson-info', 'intro-install_plan.json')
51+
with open(install_plan_path, 'r', encoding='utf8') as f:
52+
install_plan = json.load(f)
53+
modules = collect_modules(install_plan)
54+
path = modules.get(fullname)
55+
if path is not None:
56+
return importlib.util.spec_from_file_location(fullname, path)
14257
return None
14358

144-
@classmethod
145-
def install(
146-
cls,
147-
project_name: str,
148-
hook_name: str,
149-
project_path: str,
150-
build_path: str,
151-
import_paths: List[str],
152-
top_level_modules: List[str],
153-
rebuild_commands: List[List[str]],
154-
verbose: bool = False,
155-
) -> None:
156-
if project_path in os.environ.get('_MESONPY_EDITABLE_SKIP', '').split(os.pathsep):
157-
return
158-
if os.environ.get('MESONPY_EDITABLE_VERBOSE', ''):
159-
verbose = True
160-
# install our finder
161-
finder = cls(
162-
project_name,
163-
hook_name,
164-
project_path,
165-
build_path,
166-
import_paths,
167-
top_level_modules,
168-
rebuild_commands,
169-
verbose,
170-
)
171-
if finder not in sys.meta_path:
172-
# prepend our finder to sys.meta_path, so that it is queried before
173-
# the normal finders, and can trigger a project rebuild
174-
sys.meta_path.insert(0, finder)
175-
# we add the project path to sys.path later, so that we can prepend
176-
# after the current directory is prepended (when -m is used)
177-
# see gh-239
59+
def rebuild(self, verbose: bool = False) -> None:
60+
# skip editable wheel lookup during rebuild: during the build
61+
# the module we are rebuilding might be imported causing a
62+
# rebuild loop.
63+
env = os.environ.copy()
64+
env[MARKER] = os.pathsep.join((env.get(MARKER, ''), self._build_path))
65+
stdout = None if verbose else subprocess.DEVNULL
66+
subprocess.run(['meson', 'compile'], cwd=self._build_path, env=env, stdout=stdout, check=True)
17867

17968

180-
# generated hook install below
69+
def install(names: Set[str], path: str) -> None:
70+
sys.meta_path.insert(0, MesonpyMetaFinder(names, path))

0 commit comments

Comments
 (0)