Skip to content

Commit 93a9a12

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

File tree

2 files changed

+81
-219
lines changed

2 files changed

+81
-219
lines changed

mesonpy/__init__.py

Lines changed: 13 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,7 @@
4646
import mesonpy._util
4747
import mesonpy._wheelfile
4848

49-
from mesonpy._compat import (
50-
Collection, Iterable, Mapping, cached_property, read_binary
51-
)
49+
from mesonpy._compat import Collection, Mapping, cached_property, read_binary
5250

5351

5452
if typing.TYPE_CHECKING: # pragma: no cover
@@ -152,16 +150,6 @@ def _setup_cli() -> None:
152150
colorama.init() # fix colors on windows
153151

154152

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-
165153
class Error(RuntimeError):
166154
def __str__(self) -> str:
167155
return str(self.args[0])
@@ -594,56 +582,27 @@ def build_editable(self, directory: Path, verbose: bool = False) -> pathlib.Path
594582

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

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-
609585
with mesonpy._wheelfile.WheelFile(wheel_file, 'w') as whl:
610586
self._wheel_write_metadata(whl)
611587
whl.writestr(
612588
f'{self.distinfo_dir}/direct_url.json',
613-
self._source_dir.as_uri().encode(),
589+
self._source_dir.as_uri().encode('utf-8'),
614590
)
615591

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()
592+
# install loader module
593+
loader_module_name = f'_{self.normalized_name.replace(".", "_")}_editable_loader'
630594
whl.writestr(
631-
f'{hook_module_name}.py',
632-
read_binary('mesonpy', '_editable.py') + hook_install_code,
633-
)
595+
f'{loader_module_name}.py',
596+
read_binary('mesonpy', '_editable.py') + textwrap.dedent(f'''
597+
install(
598+
{self.top_level_modules!r},
599+
{os.fspath(self._build_dir)!r}
600+
)''').encode('utf-8'))
601+
634602
# install .pth file
635603
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())
604+
f'{self.normalized_name}-editable.pth',
605+
f'import {loader_module_name}'.encode('utf-8'))
647606

648607
return wheel_file
649608

mesonpy/_editable.py

Lines changed: 68 additions & 165 deletions
Original file line numberDiff line numberDiff line change
@@ -1,180 +1,83 @@
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-
33-
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
48-
49-
50-
_STYLES = _init_colors() # holds the color values, should be _COLORS or _NO_COLORS
51-
52-
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-
"""
59-
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}')
15+
from typing import Any, Dict, Optional, Sequence, Set, Union
16+
17+
18+
MARKER = 'MESON_PYTHON_EDITABLE_SKIP'
19+
VERBOSE = 'MESON_PYTHON_EDITABLE_VERBOSE'
20+
SUFFIXES = frozenset(importlib.machinery.all_suffixes())
21+
22+
23+
def collect(install_plan: Dict[str, Dict[str, Any]]) -> Dict[str, str]:
24+
modules = {}
25+
for group in install_plan.values():
26+
for src, target in group.items():
27+
path = pathlib.Path(target['destination'])
28+
if path.parts[0] in {'{py_platlib}', '{py_purelib}'}:
29+
if path.parts[-1] == '__init__.py':
30+
module = '.'.join(path.parts[1:-1])
31+
modules[module] = src
32+
else:
33+
match = re.match(r'^([^.]+)(.*)$', path.name)
34+
assert match is not None
35+
name, suffix = match.groups()
36+
if suffix in SUFFIXES:
37+
module = '.'.join(path.parts[1:-1] + (name, ))
38+
modules[module] = src
39+
return modules
40+
41+
42+
class MesonpyMetaFinder(importlib.abc.MetaPathFinder):
43+
def __init__(self, names: Set[str], path: str):
44+
self._top_level_modules = names
45+
self._build_path = path
46+
self._verbose = False
47+
48+
@property
49+
def verbose(self) -> bool:
50+
return self._verbose or os.environ.get(VERBOSE, '')
12151

12252
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
53+
self,
54+
fullname: str,
55+
path: Optional[Sequence[Union[bytes, str]]] = None,
56+
target: Optional[ModuleType] = None
57+
) -> Optional[importlib.machinery.ModuleSpec]:
12958
if fullname.split('.', maxsplit=1)[0] in self._top_level_modules:
59+
if self._build_path in os.environ.get(MARKER, '').split(os.pathsep):
60+
return None
13061
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
62+
install_plan_path = os.path.join(self._build_path, 'meson-info', 'intro-install_plan.json')
63+
with open(install_plan_path, 'r', encoding='utf8') as f:
64+
install_plan = json.load(f)
65+
modules = collect(install_plan)
66+
path = modules.get(fullname)
67+
if path is not None:
68+
return importlib.util.spec_from_file_location(fullname, path)
14269
return None
14370

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
71+
@functools.lru_cache(maxsize=1)
72+
def rebuild(self) -> None:
73+
# skip editable wheel lookup during rebuild: during the build
74+
# the module we are rebuilding might be imported causing a
75+
# rebuild loop.
76+
env = os.environ.copy()
77+
env[MARKER] = os.pathsep.join((env.get(MARKER, ''), self._build_path))
78+
stdout = None if self.verbose else subprocess.DEVNULL
79+
subprocess.run(['meson', 'compile'], cwd=self._build_path, env=env, stdout=stdout, check=True)
17880

17981

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

0 commit comments

Comments
 (0)