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
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``.
20 from contextlib
import suppress
22 from inspect
import cleandoc
23 from itertools
import chain
24 from pathlib
import Path
25 from tempfile
import TemporaryDirectory
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
45 from wheel
.wheelfile
import WheelFile
# noqa
47 if sys
.version_info
>= (3, 8):
48 from typing
import Protocol
50 from typing_extensions
import Protocol
52 from abc
import ABC
as Protocol
54 _Path
= Union
[str, Path
]
55 _P
= TypeVar("_P", bound
=_Path
)
56 _logger
= logging
.getLogger(__name__
)
59 class _EditableMode(Enum
):
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).
69 COMPAT
= "compat" # TODO: Remove `compat` after Dec/2022.
72 def convert(cls
, mode
: Optional
[str]) -> "_EditableMode":
74 return _EditableMode
.LENIENT
# default
77 if _mode
not in _EditableMode
.__members
__:
78 raise errors
.OptionError(f
"Invalid editable mode: {mode!r}. Try: 'strict'.")
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
87 For more information, please check:
88 https://setuptools.pypa.io/en/latest/userguide/development_mode.html
90 warnings
.warn(msg
, SetuptoolsDeprecationWarning
)
92 return _EditableMode
[_mode
]
96 New or renamed files may not be automatically picked up without a new installation.
99 _LENIENT_WARNING
= """
100 Options like `package-data`, `include/exclude-package-data` or
101 `packages.find.exclude/include` may have no effect.
105 class editable_wheel(Command
):
106 """Build 'editable' wheel for development.
107 (This command is reserved for internal use of setuptools).
110 description
= "create a PEP 660 'editable' wheel"
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 "")),
118 def initialize_options(self
):
120 self
.dist_info_dir
= None
121 self
.project_dir
= None
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"))
132 self
.dist_dir
.mkdir(exist_ok
=True)
133 self
._ensure
_dist
_info
()
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
)
140 self
._create
_wheel
_file
(bdist_wheel
)
141 except Exception as ex
:
142 traceback
.print_exc()
144 Support for editable installs via PEP 660 was recently introduced
145 in `setuptools`. If you are seeing this error, please report to:
147 https://github.com/pypa/setuptools/issues
149 Meanwhile you can try the legacy behavior by setting an
150 environment variable and trying to install again:
152 SETUPTOOLS_ENABLE_FEATURES="legacy-editable"
154 raise errors
.InternalError(cleandoc(msg
)) from ex
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()
162 self
.dist_info_dir
= dist_info
.dist_info_dir
164 assert str(self
.dist_info_dir
).endswith(".dist-info")
165 assert Path(self
.dist_info_dir
, "METADATA").exists()
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
:
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()
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)
182 def _configure_build(
183 self
, name
: str, unpacked_wheel
: _Path
, build_lib
: _Path
, tmp_dir
: _Path
185 """Configure commands to behave in the following ways:
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.
194 # Non-editable files (data, headers, scripts) are written directly to the
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"))
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
209 build
= dist
.reinitialize_command("build", reinit_subcommands
=True)
210 install
= dist
.reinitialize_command("install", reinit_subcommands
=True)
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
218 install_scripts
= dist
.get_command_obj("install_scripts")
219 install_scripts
.no_ep
= True
221 build
.build_temp
= str(tmp_dir
)
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
()
227 self
._set
_editable
_mode
()
229 build
.ensure_finalized()
230 install
.ensure_finalized()
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
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")
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 {})
255 return files
, mapping
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
268 def _run_build_subcommands(self
):
270 Issue #3501 indicates that some plugins/customizations might rely on:
272 1. ``build_py`` not running
273 2. ``build_py`` always copying files to ``build_lib``
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.
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
)
288 self
.run_command(name
)
290 def _safely_run(self
, cmd_name
: str):
292 return self
.run_command(cmd_name
)
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.
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.
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
308 warnings
.warn(msg
, SetuptoolsDeprecationWarning
, stacklevel
=2)
310 def _create_wheel_file(self
, bdist_wheel
):
311 from wheel
.wheelfile
import WheelFile
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():
322 unpacked_wheel
= TemporaryDirectory(suffix
=archive_name
)
323 build_lib
= TemporaryDirectory(suffix
=".build-lib")
324 build_tmp
= TemporaryDirectory(suffix
=".build-temp")
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
)
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}")
344 def _select_strategy(
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
)
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
)
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
)])
367 # Use a MetaPathFinder to avoid adding accidental top-level packages/modules
368 return _TopLevelFinder(self
.distribution
, name
)
371 class EditableStrategy(Protocol
):
372 def __call__(self
, wheel
: "WheelFile", files
: List
[str], mapping
: Dict
[str, str]):
378 def __exit__(self
, _exc_type
, _exc_value
, _traceback
):
383 def __init__(self
, dist
: Distribution
, name
: str, path_entries
: List
[Path
]):
386 self
.path_entries
= path_entries
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
)
395 Editable install will be performed using .pth file to extend `sys.path` with:
396 {list(map(os.fspath, self.path_entries))!r}
398 _logger
.warning(msg
+ _LENIENT_WARNING
)
401 def __exit__(self
, _exc_type
, _exc_value
, _traceback
):
405 class _LinkTree(_StaticPth
):
407 Creates a ``.pth`` file that points to a link tree in the ``auxiliary_dir``.
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.
412 By collocating ``auxiliary_dir`` and the original source code, limitations
413 with hardlinks should be avoided.
416 self
, dist
: Distribution
,
418 auxiliary_dir
: _Path
,
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
])
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
)
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
, '/')
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
)
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"
447 self
._normalize
_output
(k
): v
448 for k
, v
in output_mapping
.items()
450 mappings
.pop(None, None) # remove files that are not relative to build_lib
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
)
457 for relative
, src
in mappings
.items():
458 self
._create
_file
(relative
, src
, link
=link_type
)
461 msg
= "Strict editable install will be performed using a link tree.\n"
462 _logger
.warning(msg
+ _STRICT_WARNING
)
465 def __exit__(self
, _exc_type
, _exc_value
, _traceback
):
467 Strict editable installation performed using the auxiliary directory:
470 Please be careful to not remove this directory, otherwise you might not be able
471 to import/use your package.
473 warnings
.warn(msg
, InformationOnly
)
476 class _TopLevelFinder
:
477 def __init__(self
, dist
: Distribution
, name
: str):
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
)
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
)),
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
)
497 content
= bytes(f
"import {finder}; {finder}.install()", "utf-8")
498 wheel
.writestr(f
"__editable__.{self.name}.pth", content
)
501 msg
= "Editable install will be performed using a meta path finder.\n"
502 _logger
.warning(msg
+ _LENIENT_WARNING
)
505 def __exit__(self
, _exc_type
, _exc_value
, _traceback
):
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.
510 warnings
.warn(msg
, InformationOnly
)
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":
523 os
.link(path1
, path2
) # Ensure hard links can be created
524 except Exception as ex
:
526 "File system does not seem to support either symlinks or hard links. "
527 "Strict editable installs require one of them to be supported."
529 raise LinksNotSupported(msg
) from ex
534 packages
: Iterable
[str], package_dir
: Dict
[str, str], project_dir
: Path
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``.
540 >>> _simple_layout(['a'], {"": "src"}, "/tmp/myproj")
542 >>> _simple_layout(['a', 'a.b'], {"": "src"}, "/tmp/myproj")
544 >>> _simple_layout(['a', 'a.b'], {}, "/tmp/myproj")
546 >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"": "src"}, "/tmp/myproj")
548 >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"a": "a", "b": "b"}, ".")
550 >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"a": "_a", "b": "_b"}, ".")
552 >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"a": "_a"}, "/tmp/myproj")
554 >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"a.a1.a2": "_a2"}, ".")
556 >>> _simple_layout(['a', 'a.b'], {"": "src", "a.b": "_ab"}, "/tmp/myproj")
558 >>> # Special cases, no packages yet:
559 >>> _simple_layout([], {"": "src"}, "/tmp/myproj")
561 >>> _simple_layout([], {"a": "_a", "": "src"}, "/tmp/myproj")
565 pkg
: find_package_path(pkg
, package_dir
, project_dir
)
569 return set(package_dir
) in ({}, {""})
570 parent
= os
.path
.commonpath([_parent_path(k
, v
) for k
, v
in layout
.items()])
572 _normalize_path(Path(parent
, *key
.split('.'))) == _normalize_path(value
)
573 for key
, value
in layout
.items()
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
582 >>> _parent_path("a", "src/a")
584 >>> _parent_path("b", "src/c")
587 parent
= pkg_path
[:-len(pkg
)] if pkg_path
.endswith(pkg
) else pkg_path
588 return parent
.rstrip("/" + os
.sep
)
591 def _find_packages(dist
: Distribution
) -> Iterator
[str]:
592 yield from iter(dist
.packages
or [])
594 py_modules
= dist
.py_modules
or []
595 nested_modules
= [mod
for mod
in py_modules
if "." in mod
]
597 yield dist
.ext_package
599 ext_modules
= dist
.ext_modules
or []
600 nested_modules
+= [x
.name
for x
in ext_modules
if "." in x
.name
]
602 for module
in nested_modules
:
603 package
, _
, _
= module
.rpartition(".")
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
)
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
)
616 def _find_package_roots(
617 packages
: Iterable
[str],
618 package_dir
: Mapping
[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
)
626 return _remove_nested(pkg_roots
)
629 def _absolute_root(path
: _Path
) -> str:
630 """Works for packages and top-level modules"""
632 parent
= path_
.parent
635 return str(path_
.resolve())
637 return str(parent
.resolve() / path_
.name
)
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.
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.
650 This function will try to find these kinds of namespaces.
652 for pkg
in pkg_roots
:
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
664 def _find_namespaces(
665 packages
: List
[str], pkg_roots
: Dict
[str, str]
666 ) -> Iterator
[Tuple
[str, List
[str]]]:
668 path
= find_package_path(pkg
, pkg_roots
, "")
669 if Path(path
).exists() and not Path(path
, "__init__.py").exists():
673 def _remove_nested(pkg_roots
: Dict
[str, str]) -> Dict
[str, str]:
674 output
= dict(pkg_roots
.copy())
676 for pkg
, path
in reversed(list(pkg_roots
.items())):
678 pkg
!= other
and _is_nested(pkg
, path
, other
, other_path
)
679 for other
, other_path
in pkg_roots
.items()
686 def _is_nested(pkg
: str, pkg_path
: str, parent
: str, parent_path
: str) -> bool:
688 Return ``True`` if ``pkg`` is nested inside ``parent`` both logically and in the
690 >>> _is_nested("a.b", "path/a/b", "a", "path/a")
692 >>> _is_nested("a.b", "path/a/b", "a", "otherpath/a")
694 >>> _is_nested("a.b", "path/a/b", "c", "path/c")
696 >>> _is_nested("a.a", "path/a/a", "a", "path/a")
698 >>> _is_nested("b.a", "path/b/a", "a", "path/a")
701 norm_pkg_path
= _normalize_path(pkg_path
)
702 rest
= pkg
.replace(parent
, "", 1).strip(".").split(".")
704 pkg
.startswith(parent
)
705 and norm_pkg_path
== _normalize_path(Path(parent_path
, *rest
))
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)))
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)
723 def _make_identifier(name
: str) -> str:
724 """Make a string safe to be used as Python identifier.
725 >>> _make_identifier("12abc")
727 >>> _make_identifier("__editable__.myns.pkg-78.9.3_local")
728 '__editable___myns_pkg_78_9_3_local'
730 safe
= re
.sub(r
'\W|^(?=\d)', '_', name
)
731 assert safe
.isidentifier()
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
744 def _get_target(self
):
745 """Installation target."""
746 return os
.path
.join(self
.installation_dir
, self
.editable_name
)
749 """Where the modules/packages should be loaded from."""
750 return repr(str(self
.src_root
))
753 _FINDER_TEMPLATE
= """\
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
761 MAPPING = {mapping!r}
762 NAMESPACES = {namespaces!r}
763 PATH_PLACEHOLDER = {name!r} + ".__path_hook__"
766 class _EditableFinder: # MetaPathFinder
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))
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)
785 class _EditableNamespaceFinder: # PathEntryFinder
787 def _path_hook(cls, path):
788 if path == PATH_PLACEHOLDER:
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]
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)
806 def find_module(cls, fullname):
811 if not any(finder == _EditableFinder for finder in sys.meta_path):
812 sys.meta_path.append(_EditableFinder)
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
825 def _finder_template(
826 name
: str, mapping
: Mapping
[str, str], namespaces
: Dict
[str, List
[str]]
828 """Create a string containing the code for the``MetaPathFinder`` and
831 mapping
= dict(sorted(mapping
.items(), key
=lambda p
: p
[0]))
832 return _FINDER_TEMPLATE
.format(name
=name
, mapping
=mapping
, namespaces
=namespaces
)
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...
843 class LinksNotSupported(errors
.FileError
):
844 """File system does not seem to support either symlinks or hard links."""