|
1 |
| -import functools |
| 1 | +from __future__ import annotations |
| 2 | + |
2 | 3 | import importlib.abc
|
| 4 | +import importlib.machinery |
| 5 | +import importlib.util |
| 6 | +import json |
3 | 7 | import os
|
| 8 | +import pathlib |
| 9 | +import re |
4 | 10 | import subprocess
|
5 | 11 | import sys
|
6 |
| -import warnings |
7 | 12 |
|
8 | 13 | 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 |
33 | 15 |
|
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 | 16 |
|
| 17 | +MARKER = 'MESON_PYTHON_EDITABLE_SKIP' |
| 18 | +SUFFIXES = frozenset(importlib.machinery.SOURCE_SUFFIXES + importlib.machinery.EXTENSION_SUFFIXES) |
49 | 19 |
|
50 |
| -_STYLES = _init_colors() # holds the color values, should be _COLORS or _NO_COLORS |
51 | 20 |
|
| 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 |
52 | 33 |
|
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 | 34 |
|
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 |
121 | 39 |
|
122 | 40 | 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]: |
129 | 46 | 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 |
130 | 49 | 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) |
142 | 57 | return None
|
143 | 58 |
|
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) |
178 | 67 |
|
179 | 68 |
|
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