Skip to content

Commit fa8a5c8

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

File tree

2 files changed

+62
-210
lines changed

2 files changed

+62
-210
lines changed

mesonpy/__init__.py

Lines changed: 12 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -152,16 +152,6 @@ def _setup_cli() -> None:
152152
colorama.init() # fix colors on windows
153153

154154

155-
def _as_python_declaration(value: Any) -> str:
156-
if isinstance(value, str):
157-
return f"r'{value}'"
158-
elif isinstance(value, os.PathLike):
159-
return _as_python_declaration(os.fspath(value))
160-
elif isinstance(value, Iterable):
161-
return '[' + ', '.join(map(_as_python_declaration, value)) + ']'
162-
raise NotImplementedError(f'Unsupported type: {type(value)}')
163-
164-
165155
class Error(RuntimeError):
166156
def __str__(self) -> str:
167157
return str(self.args[0])
@@ -594,56 +584,27 @@ def build_editable(self, directory: Path, verbose: bool = False) -> pathlib.Path
594584

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

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-
609587
with mesonpy._wheelfile.WheelFile(wheel_file, 'w') as whl:
610588
self._wheel_write_metadata(whl)
611589
whl.writestr(
612590
f'{self.distinfo_dir}/direct_url.json',
613-
self._source_dir.as_uri().encode(),
591+
self._source_dir.as_uri().encode('utf-8'),
614592
)
615593

616-
# 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()
594+
# install loader module
595+
loader_module_name = f'_{self.normalized_name.replace(".", "_")}_editable_loader'
630596
whl.writestr(
631-
f'{hook_module_name}.py',
632-
read_binary('mesonpy', '_editable.py') + hook_install_code,
633-
)
597+
f'{loader_module_name}.py',
598+
read_binary('mesonpy', '_editable.py') + textwrap.dedent(f'''
599+
install(
600+
{self.top_level_modules!r},
601+
{os.fspath(self._build_dir)!r}
602+
)''').encode('utf-8'))
603+
634604
# install .pth file
635605
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())
606+
f'{self.normalized_name}-editable.pth',
607+
f'import {loader_module_name}'.encode('utf-8'))
647608

648609
return wheel_file
649610

mesonpy/_editable.py

Lines changed: 50 additions & 159 deletions
Original file line numberDiff line numberDiff line change
@@ -1,180 +1,71 @@
1+
from __future__ import annotations
2+
13
import functools
24
import importlib.abc
5+
import importlib.machinery
6+
import importlib.util
7+
import json
38
import os
9+
import pathlib
10+
import re
411
import subprocess
512
import sys
6-
import warnings
713

814
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-
15+
from typing import Any, Dict, Optional, Sequence, Set, Union
3316

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
4817

18+
MARKER = 'MESON_PYTHON_EDITABLE_SKIP'
19+
SUFFIXES = frozenset(importlib.machinery.SOURCE_SUFFIXES + importlib.machinery.EXTENSION_SUFFIXES)
4920

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

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

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-
"""
5935

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}')
36+
class MesonpyMetaFinder(importlib.abc.MetaPathFinder):
37+
def __init__(self, names: Set[str], path: str):
38+
self._top_level_modules = names
39+
self._build_path = path
12140

12241
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
42+
self,
43+
fullname: str,
44+
path: Optional[Sequence[Union[bytes, str]]] = None,
45+
target: Optional[ModuleType] = None
46+
) -> Optional[importlib.machinery.ModuleSpec]:
12947
if fullname.split('.', maxsplit=1)[0] in self._top_level_modules:
48+
if self._build_path in os.environ.get(MARKER, '').split(os.pathsep):
49+
return None
13050
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
51+
install_plan_path = os.path.join(self._build_path, 'meson-info', 'intro-install_plan.json')
52+
with open(install_plan_path, 'r', encoding='utf8') as f:
53+
install_plan = json.load(f)
54+
modules = collect_modules(install_plan)
55+
path = modules.get(fullname)
56+
if path is not None:
57+
return importlib.util.spec_from_file_location(fullname, path)
14258
return None
14359

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
60+
def rebuild(self, verbose: bool = False) -> None:
61+
# skip editable wheel lookup during rebuild: during the build
62+
# the module we are rebuilding might be imported causing a
63+
# rebuild loop.
64+
env = os.environ.copy()
65+
env[MARKER] = os.pathsep.join((env.get(MARKER, ''), self._build_path))
66+
stdout = None if verbose else subprocess.DEVNULL
67+
subprocess.run(['meson', 'compile'], cwd=self._build_path, env=env, stdout=stdout, check=True)
17868

17969

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

0 commit comments

Comments
 (0)