]>
Commit | Line | Data |
---|---|---|
53e6db90 DC |
1 | """ |
2 | Create a wheel that, when installed, will make the source package 'editable' | |
3 | (add it to the interpreter's path, including metadata) per PEP 660. Replaces | |
4 | 'setup.py develop'. | |
5 | ||
6 | .. note:: | |
7 | One of the mechanisms briefly mentioned in PEP 660 to implement editable installs is | |
8 | to create a separated directory inside ``build`` and use a .pth file to point to that | |
9 | directory. In the context of this file such directory is referred as | |
10 | *auxiliary build directory* or ``auxiliary_dir``. | |
11 | """ | |
12 | ||
13 | import logging | |
14 | import os | |
15 | import re | |
16 | import shutil | |
17 | import sys | |
18 | import traceback | |
19 | import warnings | |
20 | from contextlib import suppress | |
21 | from enum import Enum | |
22 | from inspect import cleandoc | |
23 | from itertools import chain | |
24 | from pathlib import Path | |
25 | from tempfile import TemporaryDirectory | |
26 | from typing import ( | |
27 | TYPE_CHECKING, | |
28 | Dict, | |
29 | Iterable, | |
30 | Iterator, | |
31 | List, | |
32 | Mapping, | |
33 | Optional, | |
34 | Tuple, | |
35 | TypeVar, | |
36 | Union, | |
37 | ) | |
38 | ||
39 | from setuptools import Command, SetuptoolsDeprecationWarning, errors, namespaces | |
40 | from setuptools.command.build_py import build_py as build_py_cls | |
41 | from setuptools.discovery import find_package_path | |
42 | from setuptools.dist import Distribution | |
43 | ||
44 | if TYPE_CHECKING: | |
45 | from wheel.wheelfile import WheelFile # noqa | |
46 | ||
47 | if sys.version_info >= (3, 8): | |
48 | from typing import Protocol | |
49 | elif TYPE_CHECKING: | |
50 | from typing_extensions import Protocol | |
51 | else: | |
52 | from abc import ABC as Protocol | |
53 | ||
54 | _Path = Union[str, Path] | |
55 | _P = TypeVar("_P", bound=_Path) | |
56 | _logger = logging.getLogger(__name__) | |
57 | ||
58 | ||
59 | class _EditableMode(Enum): | |
60 | """ | |
61 | Possible editable installation modes: | |
62 | `lenient` (new files automatically added to the package - DEFAULT); | |
63 | `strict` (requires a new installation when files are added/removed); or | |
64 | `compat` (attempts to emulate `python setup.py develop` - DEPRECATED). | |
65 | """ | |
66 | ||
67 | STRICT = "strict" | |
68 | LENIENT = "lenient" | |
69 | COMPAT = "compat" # TODO: Remove `compat` after Dec/2022. | |
70 | ||
71 | @classmethod | |
72 | def convert(cls, mode: Optional[str]) -> "_EditableMode": | |
73 | if not mode: | |
74 | return _EditableMode.LENIENT # default | |
75 | ||
76 | _mode = mode.upper() | |
77 | if _mode not in _EditableMode.__members__: | |
78 | raise errors.OptionError(f"Invalid editable mode: {mode!r}. Try: 'strict'.") | |
79 | ||
80 | if _mode == "COMPAT": | |
81 | msg = """ | |
82 | The 'compat' editable mode is transitional and will be removed | |
83 | in future versions of `setuptools`. | |
84 | Please adapt your code accordingly to use either the 'strict' or the | |
85 | 'lenient' modes. | |
86 | ||
87 | For more information, please check: | |
88 | https://setuptools.pypa.io/en/latest/userguide/development_mode.html | |
89 | """ | |
90 | warnings.warn(msg, SetuptoolsDeprecationWarning) | |
91 | ||
92 | return _EditableMode[_mode] | |
93 | ||
94 | ||
95 | _STRICT_WARNING = """ | |
96 | New or renamed files may not be automatically picked up without a new installation. | |
97 | """ | |
98 | ||
99 | _LENIENT_WARNING = """ | |
100 | Options like `package-data`, `include/exclude-package-data` or | |
101 | `packages.find.exclude/include` may have no effect. | |
102 | """ | |
103 | ||
104 | ||
105 | class editable_wheel(Command): | |
106 | """Build 'editable' wheel for development. | |
107 | (This command is reserved for internal use of setuptools). | |
108 | """ | |
109 | ||
110 | description = "create a PEP 660 'editable' wheel" | |
111 | ||
112 | user_options = [ | |
113 | ("dist-dir=", "d", "directory to put final built distributions in"), | |
114 | ("dist-info-dir=", "I", "path to a pre-build .dist-info directory"), | |
115 | ("mode=", None, cleandoc(_EditableMode.__doc__ or "")), | |
116 | ] | |
117 | ||
118 | def initialize_options(self): | |
119 | self.dist_dir = None | |
120 | self.dist_info_dir = None | |
121 | self.project_dir = None | |
122 | self.mode = None | |
123 | ||
124 | def finalize_options(self): | |
125 | dist = self.distribution | |
126 | self.project_dir = dist.src_root or os.curdir | |
127 | self.package_dir = dist.package_dir or {} | |
128 | self.dist_dir = Path(self.dist_dir or os.path.join(self.project_dir, "dist")) | |
129 | ||
130 | def run(self): | |
131 | try: | |
132 | self.dist_dir.mkdir(exist_ok=True) | |
133 | self._ensure_dist_info() | |
134 | ||
135 | # Add missing dist_info files | |
136 | self.reinitialize_command("bdist_wheel") | |
137 | bdist_wheel = self.get_finalized_command("bdist_wheel") | |
138 | bdist_wheel.write_wheelfile(self.dist_info_dir) | |
139 | ||
140 | self._create_wheel_file(bdist_wheel) | |
141 | except Exception as ex: | |
142 | traceback.print_exc() | |
143 | msg = """ | |
144 | Support for editable installs via PEP 660 was recently introduced | |
145 | in `setuptools`. If you are seeing this error, please report to: | |
146 | ||
147 | https://github.com/pypa/setuptools/issues | |
148 | ||
149 | Meanwhile you can try the legacy behavior by setting an | |
150 | environment variable and trying to install again: | |
151 | ||
152 | SETUPTOOLS_ENABLE_FEATURES="legacy-editable" | |
153 | """ | |
154 | raise errors.InternalError(cleandoc(msg)) from ex | |
155 | ||
156 | def _ensure_dist_info(self): | |
157 | if self.dist_info_dir is None: | |
158 | dist_info = self.reinitialize_command("dist_info") | |
159 | dist_info.output_dir = self.dist_dir | |
160 | dist_info.ensure_finalized() | |
161 | dist_info.run() | |
162 | self.dist_info_dir = dist_info.dist_info_dir | |
163 | else: | |
164 | assert str(self.dist_info_dir).endswith(".dist-info") | |
165 | assert Path(self.dist_info_dir, "METADATA").exists() | |
166 | ||
167 | def _install_namespaces(self, installation_dir, pth_prefix): | |
168 | # XXX: Only required to support the deprecated namespace practice | |
169 | dist = self.distribution | |
170 | if not dist.namespace_packages: | |
171 | return | |
172 | ||
173 | src_root = Path(self.project_dir, self.package_dir.get("", ".")).resolve() | |
174 | installer = _NamespaceInstaller(dist, installation_dir, pth_prefix, src_root) | |
175 | installer.install_namespaces() | |
176 | ||
177 | def _find_egg_info_dir(self) -> Optional[str]: | |
178 | parent_dir = Path(self.dist_info_dir).parent if self.dist_info_dir else Path() | |
179 | candidates = map(str, parent_dir.glob("*.egg-info")) | |
180 | return next(candidates, None) | |
181 | ||
182 | def _configure_build( | |
183 | self, name: str, unpacked_wheel: _Path, build_lib: _Path, tmp_dir: _Path | |
184 | ): | |
185 | """Configure commands to behave in the following ways: | |
186 | ||
187 | - Build commands can write to ``build_lib`` if they really want to... | |
188 | (but this folder is expected to be ignored and modules are expected to live | |
189 | in the project directory...) | |
190 | - Binary extensions should be built in-place (editable_mode = True) | |
191 | - Data/header/script files are not part of the "editable" specification | |
192 | so they are written directly to the unpacked_wheel directory. | |
193 | """ | |
194 | # Non-editable files (data, headers, scripts) are written directly to the | |
195 | # unpacked_wheel | |
196 | ||
197 | dist = self.distribution | |
198 | wheel = str(unpacked_wheel) | |
199 | build_lib = str(build_lib) | |
200 | data = str(Path(unpacked_wheel, f"{name}.data", "data")) | |
201 | headers = str(Path(unpacked_wheel, f"{name}.data", "headers")) | |
202 | scripts = str(Path(unpacked_wheel, f"{name}.data", "scripts")) | |
203 | ||
204 | # egg-info may be generated again to create a manifest (used for package data) | |
205 | egg_info = dist.reinitialize_command("egg_info", reinit_subcommands=True) | |
206 | egg_info.egg_base = str(tmp_dir) | |
207 | egg_info.ignore_egg_info_in_manifest = True | |
208 | ||
209 | build = dist.reinitialize_command("build", reinit_subcommands=True) | |
210 | install = dist.reinitialize_command("install", reinit_subcommands=True) | |
211 | ||
212 | build.build_platlib = build.build_purelib = build.build_lib = build_lib | |
213 | install.install_purelib = install.install_platlib = install.install_lib = wheel | |
214 | install.install_scripts = build.build_scripts = scripts | |
215 | install.install_headers = headers | |
216 | install.install_data = data | |
217 | ||
218 | install_scripts = dist.get_command_obj("install_scripts") | |
219 | install_scripts.no_ep = True | |
220 | ||
221 | build.build_temp = str(tmp_dir) | |
222 | ||
223 | build_py = dist.get_command_obj("build_py") | |
224 | build_py.compile = False | |
225 | build_py.existing_egg_info_dir = self._find_egg_info_dir() | |
226 | ||
227 | self._set_editable_mode() | |
228 | ||
229 | build.ensure_finalized() | |
230 | install.ensure_finalized() | |
231 | ||
232 | def _set_editable_mode(self): | |
233 | """Set the ``editable_mode`` flag in the build sub-commands""" | |
234 | dist = self.distribution | |
235 | build = dist.get_command_obj("build") | |
236 | for cmd_name in build.get_sub_commands(): | |
237 | cmd = dist.get_command_obj(cmd_name) | |
238 | if hasattr(cmd, "editable_mode"): | |
239 | cmd.editable_mode = True | |
240 | elif hasattr(cmd, "inplace"): | |
241 | cmd.inplace = True # backward compatibility with distutils | |
242 | ||
243 | def _collect_build_outputs(self) -> Tuple[List[str], Dict[str, str]]: | |
244 | files: List[str] = [] | |
245 | mapping: Dict[str, str] = {} | |
246 | build = self.get_finalized_command("build") | |
247 | ||
248 | for cmd_name in build.get_sub_commands(): | |
249 | cmd = self.get_finalized_command(cmd_name) | |
250 | if hasattr(cmd, "get_outputs"): | |
251 | files.extend(cmd.get_outputs() or []) | |
252 | if hasattr(cmd, "get_output_mapping"): | |
253 | mapping.update(cmd.get_output_mapping() or {}) | |
254 | ||
255 | return files, mapping | |
256 | ||
257 | def _run_build_commands( | |
258 | self, dist_name: str, unpacked_wheel: _Path, build_lib: _Path, tmp_dir: _Path | |
259 | ) -> Tuple[List[str], Dict[str, str]]: | |
260 | self._configure_build(dist_name, unpacked_wheel, build_lib, tmp_dir) | |
261 | self._run_build_subcommands() | |
262 | files, mapping = self._collect_build_outputs() | |
263 | self._run_install("headers") | |
264 | self._run_install("scripts") | |
265 | self._run_install("data") | |
266 | return files, mapping | |
267 | ||
268 | def _run_build_subcommands(self): | |
269 | """ | |
270 | Issue #3501 indicates that some plugins/customizations might rely on: | |
271 | ||
272 | 1. ``build_py`` not running | |
273 | 2. ``build_py`` always copying files to ``build_lib`` | |
274 | ||
275 | However both these assumptions may be false in editable_wheel. | |
276 | This method implements a temporary workaround to support the ecosystem | |
277 | while the implementations catch up. | |
278 | """ | |
279 | # TODO: Once plugins/customisations had the chance to catch up, replace | |
280 | # `self._run_build_subcommands()` with `self.run_command("build")`. | |
281 | # Also remove _safely_run, TestCustomBuildPy. Suggested date: Aug/2023. | |
282 | build: Command = self.get_finalized_command("build") | |
283 | for name in build.get_sub_commands(): | |
284 | cmd = self.get_finalized_command(name) | |
285 | if name == "build_py" and type(cmd) != build_py_cls: | |
286 | self._safely_run(name) | |
287 | else: | |
288 | self.run_command(name) | |
289 | ||
290 | def _safely_run(self, cmd_name: str): | |
291 | try: | |
292 | return self.run_command(cmd_name) | |
293 | except Exception: | |
294 | msg = f"""{traceback.format_exc()}\n | |
295 | If you are seeing this warning it is very likely that a setuptools | |
296 | plugin or customization overrides the `{cmd_name}` command, without | |
297 | taking into consideration how editable installs run build steps | |
298 | starting from v64.0.0. | |
299 | ||
300 | Plugin authors and developers relying on custom build steps are encouraged | |
301 | to update their `{cmd_name}` implementation considering the information in | |
302 | https://setuptools.pypa.io/en/latest/userguide/extension.html | |
303 | about editable installs. | |
304 | ||
305 | For the time being `setuptools` will silence this error and ignore | |
306 | the faulty command, but this behaviour will change in future versions.\n | |
307 | """ | |
308 | warnings.warn(msg, SetuptoolsDeprecationWarning, stacklevel=2) | |
309 | ||
310 | def _create_wheel_file(self, bdist_wheel): | |
311 | from wheel.wheelfile import WheelFile | |
312 | ||
313 | dist_info = self.get_finalized_command("dist_info") | |
314 | dist_name = dist_info.name | |
315 | tag = "-".join(bdist_wheel.get_tag()) | |
316 | build_tag = "0.editable" # According to PEP 427 needs to start with digit | |
317 | archive_name = f"{dist_name}-{build_tag}-{tag}.whl" | |
318 | wheel_path = Path(self.dist_dir, archive_name) | |
319 | if wheel_path.exists(): | |
320 | wheel_path.unlink() | |
321 | ||
322 | unpacked_wheel = TemporaryDirectory(suffix=archive_name) | |
323 | build_lib = TemporaryDirectory(suffix=".build-lib") | |
324 | build_tmp = TemporaryDirectory(suffix=".build-temp") | |
325 | ||
326 | with unpacked_wheel as unpacked, build_lib as lib, build_tmp as tmp: | |
327 | unpacked_dist_info = Path(unpacked, Path(self.dist_info_dir).name) | |
328 | shutil.copytree(self.dist_info_dir, unpacked_dist_info) | |
329 | self._install_namespaces(unpacked, dist_info.name) | |
330 | files, mapping = self._run_build_commands(dist_name, unpacked, lib, tmp) | |
331 | strategy = self._select_strategy(dist_name, tag, lib) | |
332 | with strategy, WheelFile(wheel_path, "w") as wheel_obj: | |
333 | strategy(wheel_obj, files, mapping) | |
334 | wheel_obj.write_files(unpacked) | |
335 | ||
336 | return wheel_path | |
337 | ||
338 | def _run_install(self, category: str): | |
339 | has_category = getattr(self.distribution, f"has_{category}", None) | |
340 | if has_category and has_category(): | |
341 | _logger.info(f"Installing {category} as non editable") | |
342 | self.run_command(f"install_{category}") | |
343 | ||
344 | def _select_strategy( | |
345 | self, | |
346 | name: str, | |
347 | tag: str, | |
348 | build_lib: _Path, | |
349 | ) -> "EditableStrategy": | |
350 | """Decides which strategy to use to implement an editable installation.""" | |
351 | build_name = f"__editable__.{name}-{tag}" | |
352 | project_dir = Path(self.project_dir) | |
353 | mode = _EditableMode.convert(self.mode) | |
354 | ||
355 | if mode is _EditableMode.STRICT: | |
356 | auxiliary_dir = _empty_dir(Path(self.project_dir, "build", build_name)) | |
357 | return _LinkTree(self.distribution, name, auxiliary_dir, build_lib) | |
358 | ||
359 | packages = _find_packages(self.distribution) | |
360 | has_simple_layout = _simple_layout(packages, self.package_dir, project_dir) | |
361 | is_compat_mode = mode is _EditableMode.COMPAT | |
362 | if set(self.package_dir) == {""} and has_simple_layout or is_compat_mode: | |
363 | # src-layout(ish) is relatively safe for a simple pth file | |
364 | src_dir = self.package_dir.get("", ".") | |
365 | return _StaticPth(self.distribution, name, [Path(project_dir, src_dir)]) | |
366 | ||
367 | # Use a MetaPathFinder to avoid adding accidental top-level packages/modules | |
368 | return _TopLevelFinder(self.distribution, name) | |
369 | ||
370 | ||
371 | class EditableStrategy(Protocol): | |
372 | def __call__(self, wheel: "WheelFile", files: List[str], mapping: Dict[str, str]): | |
373 | ... | |
374 | ||
375 | def __enter__(self): | |
376 | ... | |
377 | ||
378 | def __exit__(self, _exc_type, _exc_value, _traceback): | |
379 | ... | |
380 | ||
381 | ||
382 | class _StaticPth: | |
383 | def __init__(self, dist: Distribution, name: str, path_entries: List[Path]): | |
384 | self.dist = dist | |
385 | self.name = name | |
386 | self.path_entries = path_entries | |
387 | ||
388 | def __call__(self, wheel: "WheelFile", files: List[str], mapping: Dict[str, str]): | |
389 | entries = "\n".join((str(p.resolve()) for p in self.path_entries)) | |
390 | contents = bytes(f"{entries}\n", "utf-8") | |
391 | wheel.writestr(f"__editable__.{self.name}.pth", contents) | |
392 | ||
393 | def __enter__(self): | |
394 | msg = f""" | |
395 | Editable install will be performed using .pth file to extend `sys.path` with: | |
396 | {list(map(os.fspath, self.path_entries))!r} | |
397 | """ | |
398 | _logger.warning(msg + _LENIENT_WARNING) | |
399 | return self | |
400 | ||
401 | def __exit__(self, _exc_type, _exc_value, _traceback): | |
402 | ... | |
403 | ||
404 | ||
405 | class _LinkTree(_StaticPth): | |
406 | """ | |
407 | Creates a ``.pth`` file that points to a link tree in the ``auxiliary_dir``. | |
408 | ||
409 | This strategy will only link files (not dirs), so it can be implemented in | |
410 | any OS, even if that means using hardlinks instead of symlinks. | |
411 | ||
412 | By collocating ``auxiliary_dir`` and the original source code, limitations | |
413 | with hardlinks should be avoided. | |
414 | """ | |
415 | def __init__( | |
416 | self, dist: Distribution, | |
417 | name: str, | |
418 | auxiliary_dir: _Path, | |
419 | build_lib: _Path, | |
420 | ): | |
421 | self.auxiliary_dir = Path(auxiliary_dir) | |
422 | self.build_lib = Path(build_lib).resolve() | |
423 | self._file = dist.get_command_obj("build_py").copy_file | |
424 | super().__init__(dist, name, [self.auxiliary_dir]) | |
425 | ||
426 | def __call__(self, wheel: "WheelFile", files: List[str], mapping: Dict[str, str]): | |
427 | self._create_links(files, mapping) | |
428 | super().__call__(wheel, files, mapping) | |
429 | ||
430 | def _normalize_output(self, file: str) -> Optional[str]: | |
431 | # Files relative to build_lib will be normalized to None | |
432 | with suppress(ValueError): | |
433 | path = Path(file).resolve().relative_to(self.build_lib) | |
434 | return str(path).replace(os.sep, '/') | |
435 | return None | |
436 | ||
437 | def _create_file(self, relative_output: str, src_file: str, link=None): | |
438 | dest = self.auxiliary_dir / relative_output | |
439 | if not dest.parent.is_dir(): | |
440 | dest.parent.mkdir(parents=True) | |
441 | self._file(src_file, dest, link=link) | |
442 | ||
443 | def _create_links(self, outputs, output_mapping): | |
444 | self.auxiliary_dir.mkdir(parents=True, exist_ok=True) | |
445 | link_type = "sym" if _can_symlink_files(self.auxiliary_dir) else "hard" | |
446 | mappings = { | |
447 | self._normalize_output(k): v | |
448 | for k, v in output_mapping.items() | |
449 | } | |
450 | mappings.pop(None, None) # remove files that are not relative to build_lib | |
451 | ||
452 | for output in outputs: | |
453 | relative = self._normalize_output(output) | |
454 | if relative and relative not in mappings: | |
455 | self._create_file(relative, output) | |
456 | ||
457 | for relative, src in mappings.items(): | |
458 | self._create_file(relative, src, link=link_type) | |
459 | ||
460 | def __enter__(self): | |
461 | msg = "Strict editable install will be performed using a link tree.\n" | |
462 | _logger.warning(msg + _STRICT_WARNING) | |
463 | return self | |
464 | ||
465 | def __exit__(self, _exc_type, _exc_value, _traceback): | |
466 | msg = f"""\n | |
467 | Strict editable installation performed using the auxiliary directory: | |
468 | {self.auxiliary_dir} | |
469 | ||
470 | Please be careful to not remove this directory, otherwise you might not be able | |
471 | to import/use your package. | |
472 | """ | |
473 | warnings.warn(msg, InformationOnly) | |
474 | ||
475 | ||
476 | class _TopLevelFinder: | |
477 | def __init__(self, dist: Distribution, name: str): | |
478 | self.dist = dist | |
479 | self.name = name | |
480 | ||
481 | def __call__(self, wheel: "WheelFile", files: List[str], mapping: Dict[str, str]): | |
482 | src_root = self.dist.src_root or os.curdir | |
483 | top_level = chain(_find_packages(self.dist), _find_top_level_modules(self.dist)) | |
484 | package_dir = self.dist.package_dir or {} | |
485 | roots = _find_package_roots(top_level, package_dir, src_root) | |
486 | ||
487 | namespaces_: Dict[str, List[str]] = dict(chain( | |
488 | _find_namespaces(self.dist.packages or [], roots), | |
489 | ((ns, []) for ns in _find_virtual_namespaces(roots)), | |
490 | )) | |
491 | ||
492 | name = f"__editable__.{self.name}.finder" | |
493 | finder = _make_identifier(name) | |
494 | content = bytes(_finder_template(name, roots, namespaces_), "utf-8") | |
495 | wheel.writestr(f"{finder}.py", content) | |
496 | ||
497 | content = bytes(f"import {finder}; {finder}.install()", "utf-8") | |
498 | wheel.writestr(f"__editable__.{self.name}.pth", content) | |
499 | ||
500 | def __enter__(self): | |
501 | msg = "Editable install will be performed using a meta path finder.\n" | |
502 | _logger.warning(msg + _LENIENT_WARNING) | |
503 | return self | |
504 | ||
505 | def __exit__(self, _exc_type, _exc_value, _traceback): | |
506 | msg = """\n | |
507 | Please be careful with folders in your working directory with the same | |
508 | name as your package as they may take precedence during imports. | |
509 | """ | |
510 | warnings.warn(msg, InformationOnly) | |
511 | ||
512 | ||
513 | def _can_symlink_files(base_dir: Path) -> bool: | |
514 | with TemporaryDirectory(dir=str(base_dir.resolve())) as tmp: | |
515 | path1, path2 = Path(tmp, "file1.txt"), Path(tmp, "file2.txt") | |
516 | path1.write_text("file1", encoding="utf-8") | |
517 | with suppress(AttributeError, NotImplementedError, OSError): | |
518 | os.symlink(path1, path2) | |
519 | if path2.is_symlink() and path2.read_text(encoding="utf-8") == "file1": | |
520 | return True | |
521 | ||
522 | try: | |
523 | os.link(path1, path2) # Ensure hard links can be created | |
524 | except Exception as ex: | |
525 | msg = ( | |
526 | "File system does not seem to support either symlinks or hard links. " | |
527 | "Strict editable installs require one of them to be supported." | |
528 | ) | |
529 | raise LinksNotSupported(msg) from ex | |
530 | return False | |
531 | ||
532 | ||
533 | def _simple_layout( | |
534 | packages: Iterable[str], package_dir: Dict[str, str], project_dir: Path | |
535 | ) -> bool: | |
536 | """Return ``True`` if: | |
537 | - all packages are contained by the same parent directory, **and** | |
538 | - all packages become importable if the parent directory is added to ``sys.path``. | |
539 | ||
540 | >>> _simple_layout(['a'], {"": "src"}, "/tmp/myproj") | |
541 | True | |
542 | >>> _simple_layout(['a', 'a.b'], {"": "src"}, "/tmp/myproj") | |
543 | True | |
544 | >>> _simple_layout(['a', 'a.b'], {}, "/tmp/myproj") | |
545 | True | |
546 | >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"": "src"}, "/tmp/myproj") | |
547 | True | |
548 | >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"a": "a", "b": "b"}, ".") | |
549 | True | |
550 | >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"a": "_a", "b": "_b"}, ".") | |
551 | False | |
552 | >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"a": "_a"}, "/tmp/myproj") | |
553 | False | |
554 | >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"a.a1.a2": "_a2"}, ".") | |
555 | False | |
556 | >>> _simple_layout(['a', 'a.b'], {"": "src", "a.b": "_ab"}, "/tmp/myproj") | |
557 | False | |
558 | >>> # Special cases, no packages yet: | |
559 | >>> _simple_layout([], {"": "src"}, "/tmp/myproj") | |
560 | True | |
561 | >>> _simple_layout([], {"a": "_a", "": "src"}, "/tmp/myproj") | |
562 | False | |
563 | """ | |
564 | layout = { | |
565 | pkg: find_package_path(pkg, package_dir, project_dir) | |
566 | for pkg in packages | |
567 | } | |
568 | if not layout: | |
569 | return set(package_dir) in ({}, {""}) | |
570 | parent = os.path.commonpath([_parent_path(k, v) for k, v in layout.items()]) | |
571 | return all( | |
572 | _normalize_path(Path(parent, *key.split('.'))) == _normalize_path(value) | |
573 | for key, value in layout.items() | |
574 | ) | |
575 | ||
576 | ||
577 | def _parent_path(pkg, pkg_path): | |
578 | """Infer the parent path containing a package, that if added to ``sys.path`` would | |
579 | allow importing that package. | |
580 | When ``pkg`` is directly mapped into a directory with a different name, return its | |
581 | own path. | |
582 | >>> _parent_path("a", "src/a") | |
583 | 'src' | |
584 | >>> _parent_path("b", "src/c") | |
585 | 'src/c' | |
586 | """ | |
587 | parent = pkg_path[:-len(pkg)] if pkg_path.endswith(pkg) else pkg_path | |
588 | return parent.rstrip("/" + os.sep) | |
589 | ||
590 | ||
591 | def _find_packages(dist: Distribution) -> Iterator[str]: | |
592 | yield from iter(dist.packages or []) | |
593 | ||
594 | py_modules = dist.py_modules or [] | |
595 | nested_modules = [mod for mod in py_modules if "." in mod] | |
596 | if dist.ext_package: | |
597 | yield dist.ext_package | |
598 | else: | |
599 | ext_modules = dist.ext_modules or [] | |
600 | nested_modules += [x.name for x in ext_modules if "." in x.name] | |
601 | ||
602 | for module in nested_modules: | |
603 | package, _, _ = module.rpartition(".") | |
604 | yield package | |
605 | ||
606 | ||
607 | def _find_top_level_modules(dist: Distribution) -> Iterator[str]: | |
608 | py_modules = dist.py_modules or [] | |
609 | yield from (mod for mod in py_modules if "." not in mod) | |
610 | ||
611 | if not dist.ext_package: | |
612 | ext_modules = dist.ext_modules or [] | |
613 | yield from (x.name for x in ext_modules if "." not in x.name) | |
614 | ||
615 | ||
616 | def _find_package_roots( | |
617 | packages: Iterable[str], | |
618 | package_dir: Mapping[str, str], | |
619 | src_root: _Path, | |
620 | ) -> Dict[str, str]: | |
621 | pkg_roots: Dict[str, str] = { | |
622 | pkg: _absolute_root(find_package_path(pkg, package_dir, src_root)) | |
623 | for pkg in sorted(packages) | |
624 | } | |
625 | ||
626 | return _remove_nested(pkg_roots) | |
627 | ||
628 | ||
629 | def _absolute_root(path: _Path) -> str: | |
630 | """Works for packages and top-level modules""" | |
631 | path_ = Path(path) | |
632 | parent = path_.parent | |
633 | ||
634 | if path_.exists(): | |
635 | return str(path_.resolve()) | |
636 | else: | |
637 | return str(parent.resolve() / path_.name) | |
638 | ||
639 | ||
640 | def _find_virtual_namespaces(pkg_roots: Dict[str, str]) -> Iterator[str]: | |
641 | """By carefully designing ``package_dir``, it is possible to implement the logical | |
642 | structure of PEP 420 in a package without the corresponding directories. | |
643 | ||
644 | Moreover a parent package can be purposefully/accidentally skipped in the discovery | |
645 | phase (e.g. ``find_packages(include=["mypkg.*"])``, when ``mypkg.foo`` is included | |
646 | by ``mypkg`` itself is not). | |
647 | We consider this case to also be a virtual namespace (ignoring the original | |
648 | directory) to emulate a non-editable installation. | |
649 | ||
650 | This function will try to find these kinds of namespaces. | |
651 | """ | |
652 | for pkg in pkg_roots: | |
653 | if "." not in pkg: | |
654 | continue | |
655 | parts = pkg.split(".") | |
656 | for i in range(len(parts) - 1, 0, -1): | |
657 | partial_name = ".".join(parts[:i]) | |
658 | path = Path(find_package_path(partial_name, pkg_roots, "")) | |
659 | if not path.exists() or partial_name not in pkg_roots: | |
660 | # partial_name not in pkg_roots ==> purposefully/accidentally skipped | |
661 | yield partial_name | |
662 | ||
663 | ||
664 | def _find_namespaces( | |
665 | packages: List[str], pkg_roots: Dict[str, str] | |
666 | ) -> Iterator[Tuple[str, List[str]]]: | |
667 | for pkg in packages: | |
668 | path = find_package_path(pkg, pkg_roots, "") | |
669 | if Path(path).exists() and not Path(path, "__init__.py").exists(): | |
670 | yield (pkg, [path]) | |
671 | ||
672 | ||
673 | def _remove_nested(pkg_roots: Dict[str, str]) -> Dict[str, str]: | |
674 | output = dict(pkg_roots.copy()) | |
675 | ||
676 | for pkg, path in reversed(list(pkg_roots.items())): | |
677 | if any( | |
678 | pkg != other and _is_nested(pkg, path, other, other_path) | |
679 | for other, other_path in pkg_roots.items() | |
680 | ): | |
681 | output.pop(pkg) | |
682 | ||
683 | return output | |
684 | ||
685 | ||
686 | def _is_nested(pkg: str, pkg_path: str, parent: str, parent_path: str) -> bool: | |
687 | """ | |
688 | Return ``True`` if ``pkg`` is nested inside ``parent`` both logically and in the | |
689 | file system. | |
690 | >>> _is_nested("a.b", "path/a/b", "a", "path/a") | |
691 | True | |
692 | >>> _is_nested("a.b", "path/a/b", "a", "otherpath/a") | |
693 | False | |
694 | >>> _is_nested("a.b", "path/a/b", "c", "path/c") | |
695 | False | |
696 | >>> _is_nested("a.a", "path/a/a", "a", "path/a") | |
697 | True | |
698 | >>> _is_nested("b.a", "path/b/a", "a", "path/a") | |
699 | False | |
700 | """ | |
701 | norm_pkg_path = _normalize_path(pkg_path) | |
702 | rest = pkg.replace(parent, "", 1).strip(".").split(".") | |
703 | return ( | |
704 | pkg.startswith(parent) | |
705 | and norm_pkg_path == _normalize_path(Path(parent_path, *rest)) | |
706 | ) | |
707 | ||
708 | ||
709 | def _normalize_path(filename: _Path) -> str: | |
710 | """Normalize a file/dir name for comparison purposes""" | |
711 | # See pkg_resources.normalize_path | |
712 | file = os.path.abspath(filename) if sys.platform == 'cygwin' else filename | |
713 | return os.path.normcase(os.path.realpath(os.path.normpath(file))) | |
714 | ||
715 | ||
716 | def _empty_dir(dir_: _P) -> _P: | |
717 | """Create a directory ensured to be empty. Existing files may be removed.""" | |
718 | shutil.rmtree(dir_, ignore_errors=True) | |
719 | os.makedirs(dir_) | |
720 | return dir_ | |
721 | ||
722 | ||
723 | def _make_identifier(name: str) -> str: | |
724 | """Make a string safe to be used as Python identifier. | |
725 | >>> _make_identifier("12abc") | |
726 | '_12abc' | |
727 | >>> _make_identifier("__editable__.myns.pkg-78.9.3_local") | |
728 | '__editable___myns_pkg_78_9_3_local' | |
729 | """ | |
730 | safe = re.sub(r'\W|^(?=\d)', '_', name) | |
731 | assert safe.isidentifier() | |
732 | return safe | |
733 | ||
734 | ||
735 | class _NamespaceInstaller(namespaces.Installer): | |
736 | def __init__(self, distribution, installation_dir, editable_name, src_root): | |
737 | self.distribution = distribution | |
738 | self.src_root = src_root | |
739 | self.installation_dir = installation_dir | |
740 | self.editable_name = editable_name | |
741 | self.outputs = [] | |
742 | self.dry_run = False | |
743 | ||
744 | def _get_target(self): | |
745 | """Installation target.""" | |
746 | return os.path.join(self.installation_dir, self.editable_name) | |
747 | ||
748 | def _get_root(self): | |
749 | """Where the modules/packages should be loaded from.""" | |
750 | return repr(str(self.src_root)) | |
751 | ||
752 | ||
753 | _FINDER_TEMPLATE = """\ | |
754 | import sys | |
755 | from importlib.machinery import ModuleSpec | |
756 | from importlib.machinery import all_suffixes as module_suffixes | |
757 | from importlib.util import spec_from_file_location | |
758 | from itertools import chain | |
759 | from pathlib import Path | |
760 | ||
761 | MAPPING = {mapping!r} | |
762 | NAMESPACES = {namespaces!r} | |
763 | PATH_PLACEHOLDER = {name!r} + ".__path_hook__" | |
764 | ||
765 | ||
766 | class _EditableFinder: # MetaPathFinder | |
767 | @classmethod | |
768 | def find_spec(cls, fullname, path=None, target=None): | |
769 | for pkg, pkg_path in reversed(list(MAPPING.items())): | |
770 | if fullname == pkg or fullname.startswith(f"{{pkg}}."): | |
771 | rest = fullname.replace(pkg, "", 1).strip(".").split(".") | |
772 | return cls._find_spec(fullname, Path(pkg_path, *rest)) | |
773 | ||
774 | return None | |
775 | ||
776 | @classmethod | |
777 | def _find_spec(cls, fullname, candidate_path): | |
778 | init = candidate_path / "__init__.py" | |
779 | candidates = (candidate_path.with_suffix(x) for x in module_suffixes()) | |
780 | for candidate in chain([init], candidates): | |
781 | if candidate.exists(): | |
782 | return spec_from_file_location(fullname, candidate) | |
783 | ||
784 | ||
785 | class _EditableNamespaceFinder: # PathEntryFinder | |
786 | @classmethod | |
787 | def _path_hook(cls, path): | |
788 | if path == PATH_PLACEHOLDER: | |
789 | return cls | |
790 | raise ImportError | |
791 | ||
792 | @classmethod | |
793 | def _paths(cls, fullname): | |
794 | # Ensure __path__ is not empty for the spec to be considered a namespace. | |
795 | return NAMESPACES[fullname] or MAPPING.get(fullname) or [PATH_PLACEHOLDER] | |
796 | ||
797 | @classmethod | |
798 | def find_spec(cls, fullname, target=None): | |
799 | if fullname in NAMESPACES: | |
800 | spec = ModuleSpec(fullname, None, is_package=True) | |
801 | spec.submodule_search_locations = cls._paths(fullname) | |
802 | return spec | |
803 | return None | |
804 | ||
805 | @classmethod | |
806 | def find_module(cls, fullname): | |
807 | return None | |
808 | ||
809 | ||
810 | def install(): | |
811 | if not any(finder == _EditableFinder for finder in sys.meta_path): | |
812 | sys.meta_path.append(_EditableFinder) | |
813 | ||
814 | if not NAMESPACES: | |
815 | return | |
816 | ||
817 | if not any(hook == _EditableNamespaceFinder._path_hook for hook in sys.path_hooks): | |
818 | # PathEntryFinder is needed to create NamespaceSpec without private APIS | |
819 | sys.path_hooks.append(_EditableNamespaceFinder._path_hook) | |
820 | if PATH_PLACEHOLDER not in sys.path: | |
821 | sys.path.append(PATH_PLACEHOLDER) # Used just to trigger the path hook | |
822 | """ | |
823 | ||
824 | ||
825 | def _finder_template( | |
826 | name: str, mapping: Mapping[str, str], namespaces: Dict[str, List[str]] | |
827 | ) -> str: | |
828 | """Create a string containing the code for the``MetaPathFinder`` and | |
829 | ``PathEntryFinder``. | |
830 | """ | |
831 | mapping = dict(sorted(mapping.items(), key=lambda p: p[0])) | |
832 | return _FINDER_TEMPLATE.format(name=name, mapping=mapping, namespaces=namespaces) | |
833 | ||
834 | ||
835 | class InformationOnly(UserWarning): | |
836 | """Currently there is no clear way of displaying messages to the users | |
837 | that use the setuptools backend directly via ``pip``. | |
838 | The only thing that might work is a warning, although it is not the | |
839 | most appropriate tool for the job... | |
840 | """ | |
841 | ||
842 | ||
843 | class LinksNotSupported(errors.FileError): | |
844 | """File system does not seem to support either symlinks or hard links.""" |