summaryrefslogtreecommitdiff
path: root/setuptools/command
diff options
context:
space:
mode:
authorJason R. Coombs <jaraco@jaraco.com>2023-01-14 11:13:55 -0500
committerJason R. Coombs <jaraco@jaraco.com>2023-01-14 11:13:55 -0500
commit245da5441248eeb2d575034d04cbc241bf545161 (patch)
treed76526e1461252cc1119cd9482a64ef1e75f7838 /setuptools/command
parentd7b783a4b8b01e58135e40bd9a1db8a82c090982 (diff)
parent82eee6a998251b33ab3984f39b25c27ca72ba8b0 (diff)
downloadpython-setuptools-git-245da5441248eeb2d575034d04cbc241bf545161.tar.gz
Merge branch 'main' into debt/remove-legacy-version
Diffstat (limited to 'setuptools/command')
-rw-r--r--setuptools/command/__init__.py8
-rw-r--r--setuptools/command/build.py146
-rw-r--r--setuptools/command/build_clib.py2
-rw-r--r--setuptools/command/build_ext.py121
-rw-r--r--setuptools/command/build_py.py152
-rw-r--r--setuptools/command/dist_info.py124
-rw-r--r--setuptools/command/easy_install.py114
-rw-r--r--setuptools/command/editable_wheel.py844
-rw-r--r--setuptools/command/egg_info.py62
-rw-r--r--setuptools/command/install.py23
-rw-r--r--setuptools/command/sdist.py18
-rw-r--r--setuptools/command/test.py11
-rw-r--r--setuptools/command/upload_docs.py24
13 files changed, 1485 insertions, 164 deletions
diff --git a/setuptools/command/__init__.py b/setuptools/command/__init__.py
index b966dcea..5acd7687 100644
--- a/setuptools/command/__init__.py
+++ b/setuptools/command/__init__.py
@@ -2,7 +2,11 @@ from distutils.command.bdist import bdist
import sys
if 'egg' not in bdist.format_commands:
- bdist.format_command['egg'] = ('bdist_egg', "Python .egg file")
- bdist.format_commands.append('egg')
+ try:
+ bdist.format_commands['egg'] = ('bdist_egg', "Python .egg file")
+ except TypeError:
+ # For backward compatibility with older distutils (stdlib)
+ bdist.format_command['egg'] = ('bdist_egg', "Python .egg file")
+ bdist.format_commands.append('egg')
del bdist, sys
diff --git a/setuptools/command/build.py b/setuptools/command/build.py
new file mode 100644
index 00000000..fa3c99ef
--- /dev/null
+++ b/setuptools/command/build.py
@@ -0,0 +1,146 @@
+import sys
+import warnings
+from typing import TYPE_CHECKING, List, Dict
+from distutils.command.build import build as _build
+
+from setuptools import SetuptoolsDeprecationWarning
+
+if sys.version_info >= (3, 8):
+ from typing import Protocol
+elif TYPE_CHECKING:
+ from typing_extensions import Protocol
+else:
+ from abc import ABC as Protocol
+
+
+_ORIGINAL_SUBCOMMANDS = {"build_py", "build_clib", "build_ext", "build_scripts"}
+
+
+class build(_build):
+ # copy to avoid sharing the object with parent class
+ sub_commands = _build.sub_commands[:]
+
+ def get_sub_commands(self):
+ subcommands = {cmd[0] for cmd in _build.sub_commands}
+ if subcommands - _ORIGINAL_SUBCOMMANDS:
+ msg = """
+ It seems that you are using `distutils.command.build` to add
+ new subcommands. Using `distutils` directly is considered deprecated,
+ please use `setuptools.command.build`.
+ """
+ warnings.warn(msg, SetuptoolsDeprecationWarning)
+ self.sub_commands = _build.sub_commands
+ return super().get_sub_commands()
+
+
+class SubCommand(Protocol):
+ """In order to support editable installations (see :pep:`660`) all
+ build subcommands **SHOULD** implement this protocol. They also **MUST** inherit
+ from ``setuptools.Command``.
+
+ When creating an :pep:`editable wheel <660>`, ``setuptools`` will try to evaluate
+ custom ``build`` subcommands using the following procedure:
+
+ 1. ``setuptools`` will set the ``editable_mode`` attribute to ``True``
+ 2. ``setuptools`` will execute the ``run()`` command.
+
+ .. important::
+ Subcommands **SHOULD** take advantage of ``editable_mode=True`` to adequate
+ its behaviour or perform optimisations.
+
+ For example, if a subcommand doesn't need to generate an extra file and
+ all it does is to copy a source file into the build directory,
+ ``run()`` **SHOULD** simply "early return".
+
+ Similarly, if the subcommand creates files that would be placed alongside
+ Python files in the final distribution, during an editable install
+ the command **SHOULD** generate these files "in place" (i.e. write them to
+ the original source directory, instead of using the build directory).
+ Note that ``get_output_mapping()`` should reflect that and include mappings
+ for "in place" builds accordingly.
+
+ 3. ``setuptools`` use any knowledge it can derive from the return values of
+ ``get_outputs()`` and ``get_output_mapping()`` to create an editable wheel.
+ When relevant ``setuptools`` **MAY** attempt to use file links based on the value
+ of ``get_output_mapping()``. Alternatively, ``setuptools`` **MAY** attempt to use
+ :doc:`import hooks <python:reference/import>` to redirect any attempt to import
+ to the directory with the original source code and other files built in place.
+
+ Please note that custom sub-commands **SHOULD NOT** rely on ``run()`` being
+ executed (or not) to provide correct return values for ``get_outputs()``,
+ ``get_output_mapping()`` or ``get_source_files()``. The ``get_*`` methods should
+ work independently of ``run()``.
+ """
+
+ editable_mode: bool = False
+ """Boolean flag that will be set to ``True`` when setuptools is used for an
+ editable installation (see :pep:`660`).
+ Implementations **SHOULD** explicitly set the default value of this attribute to
+ ``False``.
+ When subcommands run, they can use this flag to perform optimizations or change
+ their behaviour accordingly.
+ """
+
+ build_lib: str
+ """String representing the directory where the build artifacts should be stored,
+ e.g. ``build/lib``.
+ For example, if a distribution wants to provide a Python module named ``pkg.mod``,
+ then a corresponding file should be written to ``{build_lib}/package/module.py``.
+ A way of thinking about this is that the files saved under ``build_lib``
+ would be eventually copied to one of the directories in :obj:`site.PREFIXES`
+ upon installation.
+
+ A command that produces platform-independent files (e.g. compiling text templates
+ into Python functions), **CAN** initialize ``build_lib`` by copying its value from
+ the ``build_py`` command. On the other hand, a command that produces
+ platform-specific files **CAN** initialize ``build_lib`` by copying its value from
+ the ``build_ext`` command. In general this is done inside the ``finalize_options``
+ method with the help of the ``set_undefined_options`` command::
+
+ def finalize_options(self):
+ self.set_undefined_options("build_py", ("build_lib", "build_lib"))
+ ...
+ """
+
+ def initialize_options(self):
+ """(Required by the original :class:`setuptools.Command` interface)"""
+
+ def finalize_options(self):
+ """(Required by the original :class:`setuptools.Command` interface)"""
+
+ def run(self):
+ """(Required by the original :class:`setuptools.Command` interface)"""
+
+ def get_source_files(self) -> List[str]:
+ """
+ Return a list of all files that are used by the command to create the expected
+ outputs.
+ For example, if your build command transpiles Java files into Python, you should
+ list here all the Java files.
+ The primary purpose of this function is to help populating the ``sdist``
+ with all the files necessary to build the distribution.
+ All files should be strings relative to the project root directory.
+ """
+
+ def get_outputs(self) -> List[str]:
+ """
+ Return a list of files intended for distribution as they would have been
+ produced by the build.
+ These files should be strings in the form of
+ ``"{build_lib}/destination/file/path"``.
+
+ .. note::
+ The return value of ``get_output()`` should include all files used as keys
+ in ``get_output_mapping()`` plus files that are generated during the build
+ and don't correspond to any source file already present in the project.
+ """
+
+ def get_output_mapping(self) -> Dict[str, str]:
+ """
+ Return a mapping between destination files as they would be produced by the
+ build (dict keys) into the respective existing (source) files (dict values).
+ Existing (source) files should be represented as strings relative to the project
+ root directory.
+ Destination files should be strings in the form of
+ ``"{build_lib}/destination/file/path"``.
+ """
diff --git a/setuptools/command/build_clib.py b/setuptools/command/build_clib.py
index 67ce2444..09483e69 100644
--- a/setuptools/command/build_clib.py
+++ b/setuptools/command/build_clib.py
@@ -28,7 +28,7 @@ class build_clib(orig.build_clib):
"in 'libraries' option (library '%s'), "
"'sources' must be present and must be "
"a list of source filenames" % lib_name)
- sources = list(sources)
+ sources = sorted(list(sources))
log.info("building '%s' library", lib_name)
diff --git a/setuptools/command/build_ext.py b/setuptools/command/build_ext.py
index c59eff8b..cbfe3ec1 100644
--- a/setuptools/command/build_ext.py
+++ b/setuptools/command/build_ext.py
@@ -2,14 +2,16 @@ import os
import sys
import itertools
from importlib.machinery import EXTENSION_SUFFIXES
+from importlib.util import cache_from_source as _compiled_file_name
+from typing import Dict, Iterator, List, Tuple
+
from distutils.command.build_ext import build_ext as _du_build_ext
-from distutils.file_util import copy_file
from distutils.ccompiler import new_compiler
from distutils.sysconfig import customize_compiler, get_config_var
-from distutils.errors import DistutilsError
from distutils import log
-from setuptools.extension import Library
+from setuptools.errors import BaseError
+from setuptools.extension import Extension, Library
try:
# Attempt to use Cython for building extensions, if available
@@ -73,6 +75,9 @@ def get_abi3_suffix():
class build_ext(_build_ext):
+ editable_mode: bool = False
+ inplace: bool = False
+
def run(self):
"""Build extensions in build directory, then copy if --inplace"""
old_inplace, self.inplace = self.inplace, 0
@@ -81,27 +86,62 @@ class build_ext(_build_ext):
if old_inplace:
self.copy_extensions_to_source()
+ def _get_inplace_equivalent(self, build_py, ext: Extension) -> Tuple[str, str]:
+ fullname = self.get_ext_fullname(ext.name)
+ filename = self.get_ext_filename(fullname)
+ modpath = fullname.split('.')
+ package = '.'.join(modpath[:-1])
+ package_dir = build_py.get_package_dir(package)
+ inplace_file = os.path.join(package_dir, os.path.basename(filename))
+ regular_file = os.path.join(self.build_lib, filename)
+ return (inplace_file, regular_file)
+
def copy_extensions_to_source(self):
build_py = self.get_finalized_command('build_py')
for ext in self.extensions:
- fullname = self.get_ext_fullname(ext.name)
- filename = self.get_ext_filename(fullname)
- modpath = fullname.split('.')
- package = '.'.join(modpath[:-1])
- package_dir = build_py.get_package_dir(package)
- dest_filename = os.path.join(package_dir,
- os.path.basename(filename))
- src_filename = os.path.join(self.build_lib, filename)
+ inplace_file, regular_file = self._get_inplace_equivalent(build_py, ext)
# Always copy, even if source is older than destination, to ensure
# that the right extensions for the current Python/platform are
# used.
- copy_file(
- src_filename, dest_filename, verbose=self.verbose,
- dry_run=self.dry_run
- )
+ if os.path.exists(regular_file) or not ext.optional:
+ self.copy_file(regular_file, inplace_file, level=self.verbose)
+
if ext._needs_stub:
- self.write_stub(package_dir or os.curdir, ext, True)
+ inplace_stub = self._get_equivalent_stub(ext, inplace_file)
+ self._write_stub_file(inplace_stub, ext, compile=True)
+ # Always compile stub and remove the original (leave the cache behind)
+ # (this behaviour was observed in previous iterations of the code)
+
+ def _get_equivalent_stub(self, ext: Extension, output_file: str) -> str:
+ dir_ = os.path.dirname(output_file)
+ _, _, name = ext.name.rpartition(".")
+ return f"{os.path.join(dir_, name)}.py"
+
+ def _get_output_mapping(self) -> Iterator[Tuple[str, str]]:
+ if not self.inplace:
+ return
+
+ build_py = self.get_finalized_command('build_py')
+ opt = self.get_finalized_command('install_lib').optimize or ""
+
+ for ext in self.extensions:
+ inplace_file, regular_file = self._get_inplace_equivalent(build_py, ext)
+ yield (regular_file, inplace_file)
+
+ if ext._needs_stub:
+ # This version of `build_ext` always builds artifacts in another dir,
+ # when "inplace=True" is given it just copies them back.
+ # This is done in the `copy_extensions_to_source` function, which
+ # always compile stub files via `_compile_and_remove_stub`.
+ # At the end of the process, a `.pyc` stub file is created without the
+ # corresponding `.py`.
+
+ inplace_stub = self._get_equivalent_stub(ext, inplace_file)
+ regular_stub = self._get_equivalent_stub(ext, regular_file)
+ inplace_cache = _compiled_file_name(inplace_stub, optimization=opt)
+ output_cache = _compiled_file_name(regular_stub, optimization=opt)
+ yield (output_cache, inplace_cache)
def get_ext_filename(self, fullname):
so_ext = os.getenv('SETUPTOOLS_EXT_SUFFIX')
@@ -131,6 +171,7 @@ class build_ext(_build_ext):
self.shlib_compiler = None
self.shlibs = []
self.ext_map = {}
+ self.editable_mode = False
def finalize_options(self):
_build_ext.finalize_options(self)
@@ -161,6 +202,9 @@ class build_ext(_build_ext):
if ltd and use_stubs and os.curdir not in ext.runtime_library_dirs:
ext.runtime_library_dirs.append(os.curdir)
+ if self.editable_mode:
+ self.inplace = True
+
def setup_shlib_compiler(self):
compiler = self.shlib_compiler = new_compiler(
compiler=self.compiler, dry_run=self.dry_run, force=self.force
@@ -201,8 +245,8 @@ class build_ext(_build_ext):
self.compiler = self.shlib_compiler
_build_ext.build_extension(self, ext)
if ext._needs_stub:
- cmd = self.get_finalized_command('build_py').build_lib
- self.write_stub(cmd, ext)
+ build_lib = self.get_finalized_command('build_py').build_lib
+ self.write_stub(build_lib, ext)
finally:
self.compiler = _compiler
@@ -215,8 +259,15 @@ class build_ext(_build_ext):
pkg = '.'.join(ext._full_name.split('.')[:-1] + [''])
return any(pkg + libname in libnames for libname in ext.libraries)
- def get_outputs(self):
- return _build_ext.get_outputs(self) + self.__get_stubs_outputs()
+ def get_outputs(self) -> List[str]:
+ if self.inplace:
+ return list(self.get_output_mapping().keys())
+ return sorted(_build_ext.get_outputs(self) + self.__get_stubs_outputs())
+
+ def get_output_mapping(self) -> Dict[str, str]:
+ """See :class:`setuptools.commands.build.SubCommand`"""
+ mapping = self._get_output_mapping()
+ return dict(sorted(mapping, key=lambda x: x[0]))
def __get_stubs_outputs(self):
# assemble the base name for each extension that needs a stub
@@ -236,12 +287,13 @@ class build_ext(_build_ext):
yield '.pyo'
def write_stub(self, output_dir, ext, compile=False):
- log.info("writing stub loader for %s to %s", ext._full_name,
- output_dir)
- stub_file = (os.path.join(output_dir, *ext._full_name.split('.')) +
- '.py')
+ stub_file = os.path.join(output_dir, *ext._full_name.split('.')) + '.py'
+ self._write_stub_file(stub_file, ext, compile)
+
+ def _write_stub_file(self, stub_file: str, ext: Extension, compile=False):
+ log.info("writing stub loader for %s to %s", ext._full_name, stub_file)
if compile and os.path.exists(stub_file):
- raise DistutilsError(stub_file + " already exists! Please delete.")
+ raise BaseError(stub_file + " already exists! Please delete.")
if not self.dry_run:
f = open(stub_file, 'w')
f.write(
@@ -274,16 +326,19 @@ class build_ext(_build_ext):
)
f.close()
if compile:
- from distutils.util import byte_compile
+ self._compile_and_remove_stub(stub_file)
+
+ def _compile_and_remove_stub(self, stub_file: str):
+ from distutils.util import byte_compile
- byte_compile([stub_file], optimize=0,
+ byte_compile([stub_file], optimize=0,
+ force=True, dry_run=self.dry_run)
+ optimize = self.get_finalized_command('install_lib').optimize
+ if optimize > 0:
+ byte_compile([stub_file], optimize=optimize,
force=True, dry_run=self.dry_run)
- optimize = self.get_finalized_command('install_lib').optimize
- if optimize > 0:
- byte_compile([stub_file], optimize=optimize,
- force=True, dry_run=self.dry_run)
- if os.path.exists(stub_file) and not self.dry_run:
- os.unlink(stub_file)
+ if os.path.exists(stub_file) and not self.dry_run:
+ os.unlink(stub_file)
if use_stubs or os.name == 'nt':
diff --git a/setuptools/command/build_py.py b/setuptools/command/build_py.py
index c3fdc092..ec062742 100644
--- a/setuptools/command/build_py.py
+++ b/setuptools/command/build_py.py
@@ -1,3 +1,4 @@
+from functools import partial
from glob import glob
from distutils.util import convert_path
import distutils.command.build_py as orig
@@ -8,6 +9,11 @@ import io
import distutils.errors
import itertools
import stat
+import warnings
+from pathlib import Path
+from typing import Dict, Iterable, Iterator, List, Optional, Tuple
+
+from setuptools._deprecation_warning import SetuptoolsDeprecationWarning
from setuptools.extern.more_itertools import unique_everseen
@@ -24,6 +30,8 @@ class build_py(orig.build_py):
Also, this version of the 'build_py' command allows you to specify both
'py_modules' and 'packages' in the same setup operation.
"""
+ editable_mode: bool = False
+ existing_egg_info_dir: Optional[str] = None #: Private API, internal use only.
def finalize_options(self):
orig.build_py.finalize_options(self)
@@ -33,9 +41,18 @@ class build_py(orig.build_py):
del self.__dict__['data_files']
self.__updated_files = []
+ def copy_file(self, infile, outfile, preserve_mode=1, preserve_times=1,
+ link=None, level=1):
+ # Overwrite base class to allow using links
+ if link:
+ infile = str(Path(infile).resolve())
+ outfile = str(Path(outfile).resolve())
+ return super().copy_file(infile, outfile, preserve_mode, preserve_times,
+ link, level)
+
def run(self):
"""Build modules, packages, and copy data files to build directory"""
- if not self.py_modules and not self.packages:
+ if not (self.py_modules or self.packages) or self.editable_mode:
return
if self.py_modules:
@@ -98,7 +115,7 @@ class build_py(orig.build_py):
package,
src_dir,
)
- globs_expanded = map(glob, patterns)
+ globs_expanded = map(partial(glob, recursive=True), patterns)
# flatten the expanded globs into an iterable of matches
globs_matches = itertools.chain.from_iterable(globs_expanded)
glob_files = filter(os.path.isfile, globs_matches)
@@ -108,16 +125,41 @@ class build_py(orig.build_py):
)
return self.exclude_data_files(package, src_dir, files)
- def build_package_data(self):
- """Copy data files into build directory"""
+ def get_outputs(self, include_bytecode=1) -> List[str]:
+ """See :class:`setuptools.commands.build.SubCommand`"""
+ if self.editable_mode:
+ return list(self.get_output_mapping().keys())
+ return super().get_outputs(include_bytecode)
+
+ def get_output_mapping(self) -> Dict[str, str]:
+ """See :class:`setuptools.commands.build.SubCommand`"""
+ mapping = itertools.chain(
+ self._get_package_data_output_mapping(),
+ self._get_module_mapping(),
+ )
+ return dict(sorted(mapping, key=lambda x: x[0]))
+
+ def _get_module_mapping(self) -> Iterator[Tuple[str, str]]:
+ """Iterate over all modules producing (dest, src) pairs."""
+ for (package, module, module_file) in self.find_all_modules():
+ package = package.split('.')
+ filename = self.get_module_outfile(self.build_lib, package, module)
+ yield (filename, module_file)
+
+ def _get_package_data_output_mapping(self) -> Iterator[Tuple[str, str]]:
+ """Iterate over package data producing (dest, src) pairs."""
for package, src_dir, build_dir, filenames in self.data_files:
for filename in filenames:
target = os.path.join(build_dir, filename)
- self.mkpath(os.path.dirname(target))
srcfile = os.path.join(src_dir, filename)
- outf, copied = self.copy_file(srcfile, target)
- make_writable(target)
- srcfile = os.path.abspath(srcfile)
+ yield (target, srcfile)
+
+ def build_package_data(self):
+ """Copy data files into build directory"""
+ for target, srcfile in self._get_package_data_output_mapping():
+ self.mkpath(os.path.dirname(target))
+ _outf, _copied = self.copy_file(srcfile, target)
+ make_writable(target)
def analyze_manifest(self):
self.manifest_files = mf = {}
@@ -128,9 +170,21 @@ class build_py(orig.build_py):
# Locate package source directory
src_dirs[assert_relative(self.get_package_dir(package))] = package
- self.run_command('egg_info')
- ei_cmd = self.get_finalized_command('egg_info')
- for path in ei_cmd.filelist.files:
+ if (
+ getattr(self, 'existing_egg_info_dir', None)
+ and Path(self.existing_egg_info_dir, "SOURCES.txt").exists()
+ ):
+ egg_info_dir = self.existing_egg_info_dir
+ manifest = Path(egg_info_dir, "SOURCES.txt")
+ files = manifest.read_text(encoding="utf-8").splitlines()
+ else:
+ self.run_command('egg_info')
+ ei_cmd = self.get_finalized_command('egg_info')
+ egg_info_dir = ei_cmd.egg_info
+ files = ei_cmd.filelist.files
+
+ check = _IncludePackageDataAbuse()
+ for path in self._filter_build_files(files, egg_info_dir):
d, f = os.path.split(assert_relative(path))
prev = None
oldf = f
@@ -139,10 +193,34 @@ class build_py(orig.build_py):
d, df = os.path.split(d)
f = os.path.join(df, f)
if d in src_dirs:
- if path.endswith('.py') and f == oldf:
- continue # it's a module, not data
+ if f == oldf:
+ if check.is_module(f):
+ continue # it's a module, not data
+ else:
+ importable = check.importable_subpackage(src_dirs[d], f)
+ if importable:
+ check.warn(importable)
mf.setdefault(src_dirs[d], []).append(path)
+ def _filter_build_files(self, files: Iterable[str], egg_info: str) -> Iterator[str]:
+ """
+ ``build_meta`` may try to create egg_info outside of the project directory,
+ and this can be problematic for certain plugins (reported in issue #3500).
+
+ Extensions might also include between their sources files created on the
+ ``build_lib`` and ``build_temp`` directories.
+
+ This function should filter this case of invalid files out.
+ """
+ build = self.get_finalized_command("build")
+ build_dirs = (egg_info, self.build_lib, build.build_temp, build.build_base)
+ norm_dirs = [os.path.normpath(p) for p in build_dirs if p]
+
+ for file in files:
+ norm_path = os.path.normpath(file)
+ if not os.path.isabs(file) or all(d not in norm_path for d in norm_dirs):
+ yield file
+
def get_data_files(self):
pass # Lazily compute data files in _get_data_files() function.
@@ -179,6 +257,8 @@ class build_py(orig.build_py):
def initialize_options(self):
self.packages_checked = {}
orig.build_py.initialize_options(self)
+ self.editable_mode = False
+ self.existing_egg_info_dir = None
def get_package_dir(self, package):
res = orig.build_py.get_package_dir(self, package)
@@ -240,3 +320,49 @@ def assert_relative(path):
% path
)
raise DistutilsSetupError(msg)
+
+
+class _IncludePackageDataAbuse:
+ """Inform users that package or module is included as 'data file'"""
+
+ MESSAGE = """\
+ Installing {importable!r} as data is deprecated, please list it in `packages`.
+ !!\n\n
+ ############################
+ # Package would be ignored #
+ ############################
+ Python recognizes {importable!r} as an importable package,
+ but it is not listed in the `packages` configuration of setuptools.
+
+ {importable!r} has been automatically added to the distribution only
+ because it may contain data files, but this behavior is likely to change
+ in future versions of setuptools (and therefore is considered deprecated).
+
+ Please make sure that {importable!r} is included as a package by using
+ the `packages` configuration field or the proper discovery methods
+ (for example by using `find_namespace_packages(...)`/`find_namespace:`
+ instead of `find_packages(...)`/`find:`).
+
+ You can read more about "package discovery" and "data files" on setuptools
+ documentation page.
+ \n\n!!
+ """
+
+ def __init__(self):
+ self._already_warned = set()
+
+ def is_module(self, file):
+ return file.endswith(".py") and file[:-len(".py")].isidentifier()
+
+ def importable_subpackage(self, parent, file):
+ pkg = Path(file).parent
+ parts = list(itertools.takewhile(str.isidentifier, pkg.parts))
+ if parts:
+ return ".".join([parent, *parts])
+ return None
+
+ def warn(self, importable):
+ if importable not in self._already_warned:
+ msg = textwrap.dedent(self.MESSAGE).format(importable=importable)
+ warnings.warn(msg, SetuptoolsDeprecationWarning, stacklevel=2)
+ self._already_warned.add(importable)
diff --git a/setuptools/command/dist_info.py b/setuptools/command/dist_info.py
index c45258fa..0685c945 100644
--- a/setuptools/command/dist_info.py
+++ b/setuptools/command/dist_info.py
@@ -4,9 +4,18 @@ As defined in the wheel specification
"""
import os
+import re
+import shutil
+import sys
+import warnings
+from contextlib import contextmanager
+from inspect import cleandoc
+from pathlib import Path
from distutils.core import Command
from distutils import log
+from setuptools.extern import packaging
+from setuptools._deprecation_warning import SetuptoolsDeprecationWarning
class dist_info(Command):
@@ -15,22 +24,119 @@ class dist_info(Command):
user_options = [
('egg-base=', 'e', "directory containing .egg-info directories"
- " (default: top of the source tree)"),
+ " (default: top of the source tree)"
+ " DEPRECATED: use --output-dir."),
+ ('output-dir=', 'o', "directory inside of which the .dist-info will be"
+ "created (default: top of the source tree)"),
+ ('tag-date', 'd', "Add date stamp (e.g. 20050528) to version number"),
+ ('tag-build=', 'b', "Specify explicit tag to add to version number"),
+ ('no-date', 'D', "Don't include date stamp [default]"),
+ ('keep-egg-info', None, "*TRANSITIONAL* will be removed in the future"),
]
+ boolean_options = ['tag-date', 'keep-egg-info']
+ negative_opt = {'no-date': 'tag-date'}
+
def initialize_options(self):
self.egg_base = None
+ self.output_dir = None
+ self.name = None
+ self.dist_info_dir = None
+ self.tag_date = None
+ self.tag_build = None
+ self.keep_egg_info = False
def finalize_options(self):
- pass
+ if self.egg_base:
+ msg = "--egg-base is deprecated for dist_info command. Use --output-dir."
+ warnings.warn(msg, SetuptoolsDeprecationWarning)
+ self.output_dir = self.egg_base or self.output_dir
+
+ dist = self.distribution
+ project_dir = dist.src_root or os.curdir
+ self.output_dir = Path(self.output_dir or project_dir)
+
+ egg_info = self.reinitialize_command("egg_info")
+ egg_info.egg_base = str(self.output_dir)
+
+ if self.tag_date:
+ egg_info.tag_date = self.tag_date
+ else:
+ self.tag_date = egg_info.tag_date
+
+ if self.tag_build:
+ egg_info.tag_build = self.tag_build
+ else:
+ self.tag_build = egg_info.tag_build
- def run(self):
- egg_info = self.get_finalized_command('egg_info')
- egg_info.egg_base = self.egg_base
egg_info.finalize_options()
- egg_info.run()
- dist_info_dir = egg_info.egg_info[:-len('.egg-info')] + '.dist-info'
- log.info("creating '{}'".format(os.path.abspath(dist_info_dir)))
+ self.egg_info = egg_info
+
+ name = _safe(dist.get_name())
+ version = _version(dist.get_version())
+ self.name = f"{name}-{version}"
+ self.dist_info_dir = os.path.join(self.output_dir, f"{self.name}.dist-info")
+
+ @contextmanager
+ def _maybe_bkp_dir(self, dir_path: str, requires_bkp: bool):
+ if requires_bkp:
+ bkp_name = f"{dir_path}.__bkp__"
+ _rm(bkp_name, ignore_errors=True)
+ _copy(dir_path, bkp_name, dirs_exist_ok=True, symlinks=True)
+ try:
+ yield
+ finally:
+ _rm(dir_path, ignore_errors=True)
+ shutil.move(bkp_name, dir_path)
+ else:
+ yield
+
+ def run(self):
+ self.output_dir.mkdir(parents=True, exist_ok=True)
+ self.egg_info.run()
+ egg_info_dir = self.egg_info.egg_info
+ assert os.path.isdir(egg_info_dir), ".egg-info dir should have been created"
+ log.info("creating '{}'".format(os.path.abspath(self.dist_info_dir)))
bdist_wheel = self.get_finalized_command('bdist_wheel')
- bdist_wheel.egg2dist(egg_info.egg_info, dist_info_dir)
+
+ # TODO: if bdist_wheel if merged into setuptools, just add "keep_egg_info" there
+ with self._maybe_bkp_dir(egg_info_dir, self.keep_egg_info):
+ bdist_wheel.egg2dist(egg_info_dir, self.dist_info_dir)
+
+
+def _safe(component: str) -> str:
+ """Escape a component used to form a wheel name according to PEP 491"""
+ return re.sub(r"[^\w\d.]+", "_", component)
+
+
+def _version(version: str) -> str:
+ """Convert an arbitrary string to a version string."""
+ v = version.replace(' ', '.')
+ try:
+ return str(packaging.version.Version(v)).replace("-", "_")
+ except packaging.version.InvalidVersion:
+ msg = f"""Invalid version: {version!r}.
+ !!\n\n
+ ###################
+ # Invalid version #
+ ###################
+ {version!r} is not valid according to PEP 440.\n
+ Please make sure specify a valid version for your package.
+ Also note that future releases of setuptools may halt the build process
+ if an invalid version is given.
+ \n\n!!
+ """
+ warnings.warn(cleandoc(msg))
+ return _safe(v).strip("_")
+
+
+def _rm(dir_name, **opts):
+ if os.path.isdir(dir_name):
+ shutil.rmtree(dir_name, **opts)
+
+
+def _copy(src, dst, **opts):
+ if sys.version_info < (3, 8):
+ opts.pop("dirs_exist_ok", None)
+ shutil.copytree(src, dst, **opts)
diff --git a/setuptools/command/easy_install.py b/setuptools/command/easy_install.py
index b1260dcd..444d3b33 100644
--- a/setuptools/command/easy_install.py
+++ b/setuptools/command/easy_install.py
@@ -56,19 +56,21 @@ from setuptools.package_index import (
from setuptools.command import bdist_egg, egg_info
from setuptools.wheel import Wheel
from pkg_resources import (
- yield_lines, normalize_path, resource_string,
+ normalize_path, resource_string,
get_distribution, find_distributions, Environment, Requirement,
Distribution, PathMetadata, EggMetadata, WorkingSet, DistributionNotFound,
VersionConflict, DEVELOP_DIST,
)
import pkg_resources
from .._path import ensure_directory
+from ..extern.jaraco.text import yield_lines
+
# Turn on PEP440Warnings
warnings.filterwarnings("default", category=pkg_resources.PEP440Warning)
__all__ = [
- 'samefile', 'easy_install', 'PthDistributions', 'extract_wininst_cfg',
+ 'easy_install', 'PthDistributions', 'extract_wininst_cfg',
'get_exe_prefixes',
]
@@ -77,22 +79,6 @@ def is_64bit():
return struct.calcsize("P") == 8
-def samefile(p1, p2):
- """
- Determine if two paths reference the same file.
-
- Augments os.path.samefile to work on Windows and
- suppresses errors if the path doesn't exist.
- """
- both_exist = os.path.exists(p1) and os.path.exists(p2)
- use_samefile = hasattr(os.path, 'samefile') and both_exist
- if use_samefile:
- return os.path.samefile(p1, p2)
- norm_p1 = os.path.normpath(os.path.normcase(p1))
- norm_p2 = os.path.normpath(os.path.normcase(p2))
- return norm_p1 == norm_p2
-
-
def _to_bytes(s):
return s.encode('utf8')
@@ -183,12 +169,8 @@ class easy_install(Command):
self.install_data = None
self.install_base = None
self.install_platbase = None
- if site.ENABLE_USER_SITE:
- self.install_userbase = site.USER_BASE
- self.install_usersite = site.USER_SITE
- else:
- self.install_userbase = None
- self.install_usersite = None
+ self.install_userbase = site.USER_BASE
+ self.install_usersite = site.USER_SITE
self.no_find_links = None
# Options not specifiable via command line
@@ -267,11 +249,9 @@ class easy_install(Command):
getattr(sys, 'windir', '').replace('.', ''),
)
- if site.ENABLE_USER_SITE:
- self.config_vars['userbase'] = self.install_userbase
- self.config_vars['usersite'] = self.install_usersite
-
- elif self.user:
+ self.config_vars['userbase'] = self.install_userbase
+ self.config_vars['usersite'] = self.install_usersite
+ if self.user and not site.ENABLE_USER_SITE:
log.warn("WARNING: The user site-packages directory is disabled.")
self._fix_install_dir_for_user_site()
@@ -307,27 +287,14 @@ class easy_install(Command):
self.script_dir = self.install_scripts
# default --record from the install command
self.set_undefined_options('install', ('record', 'record'))
- # Should this be moved to the if statement below? It's not used
- # elsewhere
- normpath = map(normalize_path, sys.path)
self.all_site_dirs = get_site_dirs()
- if self.site_dirs is not None:
- site_dirs = [
- os.path.expanduser(s.strip()) for s in
- self.site_dirs.split(',')
- ]
- for d in site_dirs:
- if not os.path.isdir(d):
- log.warn("%s (in --site-dirs) does not exist", d)
- elif normalize_path(d) not in normpath:
- raise DistutilsOptionError(
- d + " (in --site-dirs) is not on sys.path"
- )
- else:
- self.all_site_dirs.append(normalize_path(d))
+ self.all_site_dirs.extend(self._process_site_dirs(self.site_dirs))
+
if not self.editable:
self.check_site_dir()
- self.index_url = self.index_url or "https://pypi.org/simple/"
+ default_index = os.getenv("__EASYINSTALL_INDEX", "https://pypi.org/simple/")
+ # ^ Private API for testing purposes only
+ self.index_url = self.index_url or default_index
self.shadow_path = self.all_site_dirs[:]
for path_item in self.install_dir, normalize_path(self.script_dir):
if path_item not in self.shadow_path:
@@ -353,15 +320,7 @@ class easy_install(Command):
if not self.no_find_links:
self.package_index.add_find_links(self.find_links)
self.set_undefined_options('install_lib', ('optimize', 'optimize'))
- if not isinstance(self.optimize, int):
- try:
- self.optimize = int(self.optimize)
- if not (0 <= self.optimize <= 2):
- raise ValueError
- except ValueError as e:
- raise DistutilsOptionError(
- "--optimize must be 0, 1, or 2"
- ) from e
+ self.optimize = self._validate_optimize(self.optimize)
if self.editable and not self.build_directory:
raise DistutilsArgError(
@@ -373,11 +332,44 @@ class easy_install(Command):
self.outputs = []
+ @staticmethod
+ def _process_site_dirs(site_dirs):
+ if site_dirs is None:
+ return
+
+ normpath = map(normalize_path, sys.path)
+ site_dirs = [
+ os.path.expanduser(s.strip()) for s in
+ site_dirs.split(',')
+ ]
+ for d in site_dirs:
+ if not os.path.isdir(d):
+ log.warn("%s (in --site-dirs) does not exist", d)
+ elif normalize_path(d) not in normpath:
+ raise DistutilsOptionError(
+ d + " (in --site-dirs) is not on sys.path"
+ )
+ else:
+ yield normalize_path(d)
+
+ @staticmethod
+ def _validate_optimize(value):
+ try:
+ value = int(value)
+ if value not in range(3):
+ raise ValueError
+ except ValueError as e:
+ raise DistutilsOptionError(
+ "--optimize must be 0, 1, or 2"
+ ) from e
+
+ return value
+
def _fix_install_dir_for_user_site(self):
"""
Fix the install_dir if "--user" was used.
"""
- if not self.user or not site.ENABLE_USER_SITE:
+ if not self.user:
return
self.create_home_path()
@@ -926,7 +918,9 @@ class easy_install(Command):
ensure_directory(destination)
dist = self.egg_distribution(egg_path)
- if not samefile(egg_path, destination):
+ if not (
+ os.path.exists(destination) and os.path.samefile(egg_path, destination)
+ ):
if os.path.isdir(destination) and not os.path.islink(destination):
dir_util.remove_tree(destination, dry_run=self.dry_run)
elif os.path.exists(destination):
@@ -1657,14 +1651,14 @@ class PthDistributions(Environment):
if new_path:
self.paths.append(dist.location)
self.dirty = True
- Environment.add(self, dist)
+ super().add(dist)
def remove(self, dist):
"""Remove `dist` from the distribution map"""
while dist.location in self.paths:
self.paths.remove(dist.location)
self.dirty = True
- Environment.remove(self, dist)
+ super().remove(dist)
def make_relative(self, path):
npath, last = os.path.split(normalize_path(path))
diff --git a/setuptools/command/editable_wheel.py b/setuptools/command/editable_wheel.py
new file mode 100644
index 00000000..d60cfbeb
--- /dev/null
+++ b/setuptools/command/editable_wheel.py
@@ -0,0 +1,844 @@
+"""
+Create a wheel that, when installed, will make the source package 'editable'
+(add it to the interpreter's path, including metadata) per PEP 660. Replaces
+'setup.py develop'.
+
+.. note::
+ One of the mechanisms briefly mentioned in PEP 660 to implement editable installs is
+ to create a separated directory inside ``build`` and use a .pth file to point to that
+ directory. In the context of this file such directory is referred as
+ *auxiliary build directory* or ``auxiliary_dir``.
+"""
+
+import logging
+import os
+import re
+import shutil
+import sys
+import traceback
+import warnings
+from contextlib import suppress
+from enum import Enum
+from inspect import cleandoc
+from itertools import chain
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from typing import (
+ TYPE_CHECKING,
+ Dict,
+ Iterable,
+ Iterator,
+ List,
+ Mapping,
+ Optional,
+ Tuple,
+ TypeVar,
+ Union,
+)
+
+from setuptools import Command, SetuptoolsDeprecationWarning, errors, namespaces
+from setuptools.command.build_py import build_py as build_py_cls
+from setuptools.discovery import find_package_path
+from setuptools.dist import Distribution
+
+if TYPE_CHECKING:
+ from wheel.wheelfile import WheelFile # noqa
+
+if sys.version_info >= (3, 8):
+ from typing import Protocol
+elif TYPE_CHECKING:
+ from typing_extensions import Protocol
+else:
+ from abc import ABC as Protocol
+
+_Path = Union[str, Path]
+_P = TypeVar("_P", bound=_Path)
+_logger = logging.getLogger(__name__)
+
+
+class _EditableMode(Enum):
+ """
+ Possible editable installation modes:
+ `lenient` (new files automatically added to the package - DEFAULT);
+ `strict` (requires a new installation when files are added/removed); or
+ `compat` (attempts to emulate `python setup.py develop` - DEPRECATED).
+ """
+
+ STRICT = "strict"
+ LENIENT = "lenient"
+ COMPAT = "compat" # TODO: Remove `compat` after Dec/2022.
+
+ @classmethod
+ def convert(cls, mode: Optional[str]) -> "_EditableMode":
+ if not mode:
+ return _EditableMode.LENIENT # default
+
+ _mode = mode.upper()
+ if _mode not in _EditableMode.__members__:
+ raise errors.OptionError(f"Invalid editable mode: {mode!r}. Try: 'strict'.")
+
+ if _mode == "COMPAT":
+ msg = """
+ The 'compat' editable mode is transitional and will be removed
+ in future versions of `setuptools`.
+ Please adapt your code accordingly to use either the 'strict' or the
+ 'lenient' modes.
+
+ For more information, please check:
+ https://setuptools.pypa.io/en/latest/userguide/development_mode.html
+ """
+ warnings.warn(msg, SetuptoolsDeprecationWarning)
+
+ return _EditableMode[_mode]
+
+
+_STRICT_WARNING = """
+New or renamed files may not be automatically picked up without a new installation.
+"""
+
+_LENIENT_WARNING = """
+Options like `package-data`, `include/exclude-package-data` or
+`packages.find.exclude/include` may have no effect.
+"""
+
+
+class editable_wheel(Command):
+ """Build 'editable' wheel for development.
+ (This command is reserved for internal use of setuptools).
+ """
+
+ description = "create a PEP 660 'editable' wheel"
+
+ user_options = [
+ ("dist-dir=", "d", "directory to put final built distributions in"),
+ ("dist-info-dir=", "I", "path to a pre-build .dist-info directory"),
+ ("mode=", None, cleandoc(_EditableMode.__doc__ or "")),
+ ]
+
+ def initialize_options(self):
+ self.dist_dir = None
+ self.dist_info_dir = None
+ self.project_dir = None
+ self.mode = None
+
+ def finalize_options(self):
+ dist = self.distribution
+ self.project_dir = dist.src_root or os.curdir
+ self.package_dir = dist.package_dir or {}
+ self.dist_dir = Path(self.dist_dir or os.path.join(self.project_dir, "dist"))
+
+ def run(self):
+ try:
+ self.dist_dir.mkdir(exist_ok=True)
+ self._ensure_dist_info()
+
+ # Add missing dist_info files
+ self.reinitialize_command("bdist_wheel")
+ bdist_wheel = self.get_finalized_command("bdist_wheel")
+ bdist_wheel.write_wheelfile(self.dist_info_dir)
+
+ self._create_wheel_file(bdist_wheel)
+ except Exception as ex:
+ traceback.print_exc()
+ msg = """
+ Support for editable installs via PEP 660 was recently introduced
+ in `setuptools`. If you are seeing this error, please report to:
+
+ https://github.com/pypa/setuptools/issues
+
+ Meanwhile you can try the legacy behavior by setting an
+ environment variable and trying to install again:
+
+ SETUPTOOLS_ENABLE_FEATURES="legacy-editable"
+ """
+ raise errors.InternalError(cleandoc(msg)) from ex
+
+ def _ensure_dist_info(self):
+ if self.dist_info_dir is None:
+ dist_info = self.reinitialize_command("dist_info")
+ dist_info.output_dir = self.dist_dir
+ dist_info.ensure_finalized()
+ dist_info.run()
+ self.dist_info_dir = dist_info.dist_info_dir
+ else:
+ assert str(self.dist_info_dir).endswith(".dist-info")
+ assert Path(self.dist_info_dir, "METADATA").exists()
+
+ def _install_namespaces(self, installation_dir, pth_prefix):
+ # XXX: Only required to support the deprecated namespace practice
+ dist = self.distribution
+ if not dist.namespace_packages:
+ return
+
+ src_root = Path(self.project_dir, self.package_dir.get("", ".")).resolve()
+ installer = _NamespaceInstaller(dist, installation_dir, pth_prefix, src_root)
+ installer.install_namespaces()
+
+ def _find_egg_info_dir(self) -> Optional[str]:
+ parent_dir = Path(self.dist_info_dir).parent if self.dist_info_dir else Path()
+ candidates = map(str, parent_dir.glob("*.egg-info"))
+ return next(candidates, None)
+
+ def _configure_build(
+ self, name: str, unpacked_wheel: _Path, build_lib: _Path, tmp_dir: _Path
+ ):
+ """Configure commands to behave in the following ways:
+
+ - Build commands can write to ``build_lib`` if they really want to...
+ (but this folder is expected to be ignored and modules are expected to live
+ in the project directory...)
+ - Binary extensions should be built in-place (editable_mode = True)
+ - Data/header/script files are not part of the "editable" specification
+ so they are written directly to the unpacked_wheel directory.
+ """
+ # Non-editable files (data, headers, scripts) are written directly to the
+ # unpacked_wheel
+
+ dist = self.distribution
+ wheel = str(unpacked_wheel)
+ build_lib = str(build_lib)
+ data = str(Path(unpacked_wheel, f"{name}.data", "data"))
+ headers = str(Path(unpacked_wheel, f"{name}.data", "headers"))
+ scripts = str(Path(unpacked_wheel, f"{name}.data", "scripts"))
+
+ # egg-info may be generated again to create a manifest (used for package data)
+ egg_info = dist.reinitialize_command("egg_info", reinit_subcommands=True)
+ egg_info.egg_base = str(tmp_dir)
+ egg_info.ignore_egg_info_in_manifest = True
+
+ build = dist.reinitialize_command("build", reinit_subcommands=True)
+ install = dist.reinitialize_command("install", reinit_subcommands=True)
+
+ build.build_platlib = build.build_purelib = build.build_lib = build_lib
+ install.install_purelib = install.install_platlib = install.install_lib = wheel
+ install.install_scripts = build.build_scripts = scripts
+ install.install_headers = headers
+ install.install_data = data
+
+ install_scripts = dist.get_command_obj("install_scripts")
+ install_scripts.no_ep = True
+
+ build.build_temp = str(tmp_dir)
+
+ build_py = dist.get_command_obj("build_py")
+ build_py.compile = False
+ build_py.existing_egg_info_dir = self._find_egg_info_dir()
+
+ self._set_editable_mode()
+
+ build.ensure_finalized()
+ install.ensure_finalized()
+
+ def _set_editable_mode(self):
+ """Set the ``editable_mode`` flag in the build sub-commands"""
+ dist = self.distribution
+ build = dist.get_command_obj("build")
+ for cmd_name in build.get_sub_commands():
+ cmd = dist.get_command_obj(cmd_name)
+ if hasattr(cmd, "editable_mode"):
+ cmd.editable_mode = True
+ elif hasattr(cmd, "inplace"):
+ cmd.inplace = True # backward compatibility with distutils
+
+ def _collect_build_outputs(self) -> Tuple[List[str], Dict[str, str]]:
+ files: List[str] = []
+ mapping: Dict[str, str] = {}
+ build = self.get_finalized_command("build")
+
+ for cmd_name in build.get_sub_commands():
+ cmd = self.get_finalized_command(cmd_name)
+ if hasattr(cmd, "get_outputs"):
+ files.extend(cmd.get_outputs() or [])
+ if hasattr(cmd, "get_output_mapping"):
+ mapping.update(cmd.get_output_mapping() or {})
+
+ return files, mapping
+
+ def _run_build_commands(
+ self, dist_name: str, unpacked_wheel: _Path, build_lib: _Path, tmp_dir: _Path
+ ) -> Tuple[List[str], Dict[str, str]]:
+ self._configure_build(dist_name, unpacked_wheel, build_lib, tmp_dir)
+ self._run_build_subcommands()
+ files, mapping = self._collect_build_outputs()
+ self._run_install("headers")
+ self._run_install("scripts")
+ self._run_install("data")
+ return files, mapping
+
+ def _run_build_subcommands(self):
+ """
+ Issue #3501 indicates that some plugins/customizations might rely on:
+
+ 1. ``build_py`` not running
+ 2. ``build_py`` always copying files to ``build_lib``
+
+ However both these assumptions may be false in editable_wheel.
+ This method implements a temporary workaround to support the ecosystem
+ while the implementations catch up.
+ """
+ # TODO: Once plugins/customisations had the chance to catch up, replace
+ # `self._run_build_subcommands()` with `self.run_command("build")`.
+ # Also remove _safely_run, TestCustomBuildPy. Suggested date: Aug/2023.
+ build: Command = self.get_finalized_command("build")
+ for name in build.get_sub_commands():
+ cmd = self.get_finalized_command(name)
+ if name == "build_py" and type(cmd) != build_py_cls:
+ self._safely_run(name)
+ else:
+ self.run_command(name)
+
+ def _safely_run(self, cmd_name: str):
+ try:
+ return self.run_command(cmd_name)
+ except Exception:
+ msg = f"""{traceback.format_exc()}\n
+ If you are seeing this warning it is very likely that a setuptools
+ plugin or customization overrides the `{cmd_name}` command, without
+ taking into consideration how editable installs run build steps
+ starting from v64.0.0.
+
+ Plugin authors and developers relying on custom build steps are encouraged
+ to update their `{cmd_name}` implementation considering the information in
+ https://setuptools.pypa.io/en/latest/userguide/extension.html
+ about editable installs.
+
+ For the time being `setuptools` will silence this error and ignore
+ the faulty command, but this behaviour will change in future versions.\n
+ """
+ warnings.warn(msg, SetuptoolsDeprecationWarning, stacklevel=2)
+
+ def _create_wheel_file(self, bdist_wheel):
+ from wheel.wheelfile import WheelFile
+
+ dist_info = self.get_finalized_command("dist_info")
+ dist_name = dist_info.name
+ tag = "-".join(bdist_wheel.get_tag())
+ build_tag = "0.editable" # According to PEP 427 needs to start with digit
+ archive_name = f"{dist_name}-{build_tag}-{tag}.whl"
+ wheel_path = Path(self.dist_dir, archive_name)
+ if wheel_path.exists():
+ wheel_path.unlink()
+
+ unpacked_wheel = TemporaryDirectory(suffix=archive_name)
+ build_lib = TemporaryDirectory(suffix=".build-lib")
+ build_tmp = TemporaryDirectory(suffix=".build-temp")
+
+ with unpacked_wheel as unpacked, build_lib as lib, build_tmp as tmp:
+ unpacked_dist_info = Path(unpacked, Path(self.dist_info_dir).name)
+ shutil.copytree(self.dist_info_dir, unpacked_dist_info)
+ self._install_namespaces(unpacked, dist_info.name)
+ files, mapping = self._run_build_commands(dist_name, unpacked, lib, tmp)
+ strategy = self._select_strategy(dist_name, tag, lib)
+ with strategy, WheelFile(wheel_path, "w") as wheel_obj:
+ strategy(wheel_obj, files, mapping)
+ wheel_obj.write_files(unpacked)
+
+ return wheel_path
+
+ def _run_install(self, category: str):
+ has_category = getattr(self.distribution, f"has_{category}", None)
+ if has_category and has_category():
+ _logger.info(f"Installing {category} as non editable")
+ self.run_command(f"install_{category}")
+
+ def _select_strategy(
+ self,
+ name: str,
+ tag: str,
+ build_lib: _Path,
+ ) -> "EditableStrategy":
+ """Decides which strategy to use to implement an editable installation."""
+ build_name = f"__editable__.{name}-{tag}"
+ project_dir = Path(self.project_dir)
+ mode = _EditableMode.convert(self.mode)
+
+ if mode is _EditableMode.STRICT:
+ auxiliary_dir = _empty_dir(Path(self.project_dir, "build", build_name))
+ return _LinkTree(self.distribution, name, auxiliary_dir, build_lib)
+
+ packages = _find_packages(self.distribution)
+ has_simple_layout = _simple_layout(packages, self.package_dir, project_dir)
+ is_compat_mode = mode is _EditableMode.COMPAT
+ if set(self.package_dir) == {""} and has_simple_layout or is_compat_mode:
+ # src-layout(ish) is relatively safe for a simple pth file
+ src_dir = self.package_dir.get("", ".")
+ return _StaticPth(self.distribution, name, [Path(project_dir, src_dir)])
+
+ # Use a MetaPathFinder to avoid adding accidental top-level packages/modules
+ return _TopLevelFinder(self.distribution, name)
+
+
+class EditableStrategy(Protocol):
+ def __call__(self, wheel: "WheelFile", files: List[str], mapping: Dict[str, str]):
+ ...
+
+ def __enter__(self):
+ ...
+
+ def __exit__(self, _exc_type, _exc_value, _traceback):
+ ...
+
+
+class _StaticPth:
+ def __init__(self, dist: Distribution, name: str, path_entries: List[Path]):
+ self.dist = dist
+ self.name = name
+ self.path_entries = path_entries
+
+ def __call__(self, wheel: "WheelFile", files: List[str], mapping: Dict[str, str]):
+ entries = "\n".join((str(p.resolve()) for p in self.path_entries))
+ contents = bytes(f"{entries}\n", "utf-8")
+ wheel.writestr(f"__editable__.{self.name}.pth", contents)
+
+ def __enter__(self):
+ msg = f"""
+ Editable install will be performed using .pth file to extend `sys.path` with:
+ {list(map(os.fspath, self.path_entries))!r}
+ """
+ _logger.warning(msg + _LENIENT_WARNING)
+ return self
+
+ def __exit__(self, _exc_type, _exc_value, _traceback):
+ ...
+
+
+class _LinkTree(_StaticPth):
+ """
+ Creates a ``.pth`` file that points to a link tree in the ``auxiliary_dir``.
+
+ This strategy will only link files (not dirs), so it can be implemented in
+ any OS, even if that means using hardlinks instead of symlinks.
+
+ By collocating ``auxiliary_dir`` and the original source code, limitations
+ with hardlinks should be avoided.
+ """
+ def __init__(
+ self, dist: Distribution,
+ name: str,
+ auxiliary_dir: _Path,
+ build_lib: _Path,
+ ):
+ self.auxiliary_dir = Path(auxiliary_dir)
+ self.build_lib = Path(build_lib).resolve()
+ self._file = dist.get_command_obj("build_py").copy_file
+ super().__init__(dist, name, [self.auxiliary_dir])
+
+ def __call__(self, wheel: "WheelFile", files: List[str], mapping: Dict[str, str]):
+ self._create_links(files, mapping)
+ super().__call__(wheel, files, mapping)
+
+ def _normalize_output(self, file: str) -> Optional[str]:
+ # Files relative to build_lib will be normalized to None
+ with suppress(ValueError):
+ path = Path(file).resolve().relative_to(self.build_lib)
+ return str(path).replace(os.sep, '/')
+ return None
+
+ def _create_file(self, relative_output: str, src_file: str, link=None):
+ dest = self.auxiliary_dir / relative_output
+ if not dest.parent.is_dir():
+ dest.parent.mkdir(parents=True)
+ self._file(src_file, dest, link=link)
+
+ def _create_links(self, outputs, output_mapping):
+ self.auxiliary_dir.mkdir(parents=True, exist_ok=True)
+ link_type = "sym" if _can_symlink_files(self.auxiliary_dir) else "hard"
+ mappings = {
+ self._normalize_output(k): v
+ for k, v in output_mapping.items()
+ }
+ mappings.pop(None, None) # remove files that are not relative to build_lib
+
+ for output in outputs:
+ relative = self._normalize_output(output)
+ if relative and relative not in mappings:
+ self._create_file(relative, output)
+
+ for relative, src in mappings.items():
+ self._create_file(relative, src, link=link_type)
+
+ def __enter__(self):
+ msg = "Strict editable install will be performed using a link tree.\n"
+ _logger.warning(msg + _STRICT_WARNING)
+ return self
+
+ def __exit__(self, _exc_type, _exc_value, _traceback):
+ msg = f"""\n
+ Strict editable installation performed using the auxiliary directory:
+ {self.auxiliary_dir}
+
+ Please be careful to not remove this directory, otherwise you might not be able
+ to import/use your package.
+ """
+ warnings.warn(msg, InformationOnly)
+
+
+class _TopLevelFinder:
+ def __init__(self, dist: Distribution, name: str):
+ self.dist = dist
+ self.name = name
+
+ def __call__(self, wheel: "WheelFile", files: List[str], mapping: Dict[str, str]):
+ src_root = self.dist.src_root or os.curdir
+ top_level = chain(_find_packages(self.dist), _find_top_level_modules(self.dist))
+ package_dir = self.dist.package_dir or {}
+ roots = _find_package_roots(top_level, package_dir, src_root)
+
+ namespaces_: Dict[str, List[str]] = dict(chain(
+ _find_namespaces(self.dist.packages or [], roots),
+ ((ns, []) for ns in _find_virtual_namespaces(roots)),
+ ))
+
+ name = f"__editable__.{self.name}.finder"
+ finder = _make_identifier(name)
+ content = bytes(_finder_template(name, roots, namespaces_), "utf-8")
+ wheel.writestr(f"{finder}.py", content)
+
+ content = bytes(f"import {finder}; {finder}.install()", "utf-8")
+ wheel.writestr(f"__editable__.{self.name}.pth", content)
+
+ def __enter__(self):
+ msg = "Editable install will be performed using a meta path finder.\n"
+ _logger.warning(msg + _LENIENT_WARNING)
+ return self
+
+ def __exit__(self, _exc_type, _exc_value, _traceback):
+ msg = """\n
+ Please be careful with folders in your working directory with the same
+ name as your package as they may take precedence during imports.
+ """
+ warnings.warn(msg, InformationOnly)
+
+
+def _can_symlink_files(base_dir: Path) -> bool:
+ with TemporaryDirectory(dir=str(base_dir.resolve())) as tmp:
+ path1, path2 = Path(tmp, "file1.txt"), Path(tmp, "file2.txt")
+ path1.write_text("file1", encoding="utf-8")
+ with suppress(AttributeError, NotImplementedError, OSError):
+ os.symlink(path1, path2)
+ if path2.is_symlink() and path2.read_text(encoding="utf-8") == "file1":
+ return True
+
+ try:
+ os.link(path1, path2) # Ensure hard links can be created
+ except Exception as ex:
+ msg = (
+ "File system does not seem to support either symlinks or hard links. "
+ "Strict editable installs require one of them to be supported."
+ )
+ raise LinksNotSupported(msg) from ex
+ return False
+
+
+def _simple_layout(
+ packages: Iterable[str], package_dir: Dict[str, str], project_dir: Path
+) -> bool:
+ """Return ``True`` if:
+ - all packages are contained by the same parent directory, **and**
+ - all packages become importable if the parent directory is added to ``sys.path``.
+
+ >>> _simple_layout(['a'], {"": "src"}, "/tmp/myproj")
+ True
+ >>> _simple_layout(['a', 'a.b'], {"": "src"}, "/tmp/myproj")
+ True
+ >>> _simple_layout(['a', 'a.b'], {}, "/tmp/myproj")
+ True
+ >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"": "src"}, "/tmp/myproj")
+ True
+ >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"a": "a", "b": "b"}, ".")
+ True
+ >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"a": "_a", "b": "_b"}, ".")
+ False
+ >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"a": "_a"}, "/tmp/myproj")
+ False
+ >>> _simple_layout(['a', 'a.a1', 'a.a1.a2', 'b'], {"a.a1.a2": "_a2"}, ".")
+ False
+ >>> _simple_layout(['a', 'a.b'], {"": "src", "a.b": "_ab"}, "/tmp/myproj")
+ False
+ >>> # Special cases, no packages yet:
+ >>> _simple_layout([], {"": "src"}, "/tmp/myproj")
+ True
+ >>> _simple_layout([], {"a": "_a", "": "src"}, "/tmp/myproj")
+ False
+ """
+ layout = {
+ pkg: find_package_path(pkg, package_dir, project_dir)
+ for pkg in packages
+ }
+ if not layout:
+ return set(package_dir) in ({}, {""})
+ parent = os.path.commonpath([_parent_path(k, v) for k, v in layout.items()])
+ return all(
+ _normalize_path(Path(parent, *key.split('.'))) == _normalize_path(value)
+ for key, value in layout.items()
+ )
+
+
+def _parent_path(pkg, pkg_path):
+ """Infer the parent path containing a package, that if added to ``sys.path`` would
+ allow importing that package.
+ When ``pkg`` is directly mapped into a directory with a different name, return its
+ own path.
+ >>> _parent_path("a", "src/a")
+ 'src'
+ >>> _parent_path("b", "src/c")
+ 'src/c'
+ """
+ parent = pkg_path[:-len(pkg)] if pkg_path.endswith(pkg) else pkg_path
+ return parent.rstrip("/" + os.sep)
+
+
+def _find_packages(dist: Distribution) -> Iterator[str]:
+ yield from iter(dist.packages or [])
+
+ py_modules = dist.py_modules or []
+ nested_modules = [mod for mod in py_modules if "." in mod]
+ if dist.ext_package:
+ yield dist.ext_package
+ else:
+ ext_modules = dist.ext_modules or []
+ nested_modules += [x.name for x in ext_modules if "." in x.name]
+
+ for module in nested_modules:
+ package, _, _ = module.rpartition(".")
+ yield package
+
+
+def _find_top_level_modules(dist: Distribution) -> Iterator[str]:
+ py_modules = dist.py_modules or []
+ yield from (mod for mod in py_modules if "." not in mod)
+
+ if not dist.ext_package:
+ ext_modules = dist.ext_modules or []
+ yield from (x.name for x in ext_modules if "." not in x.name)
+
+
+def _find_package_roots(
+ packages: Iterable[str],
+ package_dir: Mapping[str, str],
+ src_root: _Path,
+) -> Dict[str, str]:
+ pkg_roots: Dict[str, str] = {
+ pkg: _absolute_root(find_package_path(pkg, package_dir, src_root))
+ for pkg in sorted(packages)
+ }
+
+ return _remove_nested(pkg_roots)
+
+
+def _absolute_root(path: _Path) -> str:
+ """Works for packages and top-level modules"""
+ path_ = Path(path)
+ parent = path_.parent
+
+ if path_.exists():
+ return str(path_.resolve())
+ else:
+ return str(parent.resolve() / path_.name)
+
+
+def _find_virtual_namespaces(pkg_roots: Dict[str, str]) -> Iterator[str]:
+ """By carefully designing ``package_dir``, it is possible to implement the logical
+ structure of PEP 420 in a package without the corresponding directories.
+
+ Moreover a parent package can be purposefully/accidentally skipped in the discovery
+ phase (e.g. ``find_packages(include=["mypkg.*"])``, when ``mypkg.foo`` is included
+ by ``mypkg`` itself is not).
+ We consider this case to also be a virtual namespace (ignoring the original
+ directory) to emulate a non-editable installation.
+
+ This function will try to find these kinds of namespaces.
+ """
+ for pkg in pkg_roots:
+ if "." not in pkg:
+ continue
+ parts = pkg.split(".")
+ for i in range(len(parts) - 1, 0, -1):
+ partial_name = ".".join(parts[:i])
+ path = Path(find_package_path(partial_name, pkg_roots, ""))
+ if not path.exists() or partial_name not in pkg_roots:
+ # partial_name not in pkg_roots ==> purposefully/accidentally skipped
+ yield partial_name
+
+
+def _find_namespaces(
+ packages: List[str], pkg_roots: Dict[str, str]
+) -> Iterator[Tuple[str, List[str]]]:
+ for pkg in packages:
+ path = find_package_path(pkg, pkg_roots, "")
+ if Path(path).exists() and not Path(path, "__init__.py").exists():
+ yield (pkg, [path])
+
+
+def _remove_nested(pkg_roots: Dict[str, str]) -> Dict[str, str]:
+ output = dict(pkg_roots.copy())
+
+ for pkg, path in reversed(list(pkg_roots.items())):
+ if any(
+ pkg != other and _is_nested(pkg, path, other, other_path)
+ for other, other_path in pkg_roots.items()
+ ):
+ output.pop(pkg)
+
+ return output
+
+
+def _is_nested(pkg: str, pkg_path: str, parent: str, parent_path: str) -> bool:
+ """
+ Return ``True`` if ``pkg`` is nested inside ``parent`` both logically and in the
+ file system.
+ >>> _is_nested("a.b", "path/a/b", "a", "path/a")
+ True
+ >>> _is_nested("a.b", "path/a/b", "a", "otherpath/a")
+ False
+ >>> _is_nested("a.b", "path/a/b", "c", "path/c")
+ False
+ >>> _is_nested("a.a", "path/a/a", "a", "path/a")
+ True
+ >>> _is_nested("b.a", "path/b/a", "a", "path/a")
+ False
+ """
+ norm_pkg_path = _normalize_path(pkg_path)
+ rest = pkg.replace(parent, "", 1).strip(".").split(".")
+ return (
+ pkg.startswith(parent)
+ and norm_pkg_path == _normalize_path(Path(parent_path, *rest))
+ )
+
+
+def _normalize_path(filename: _Path) -> str:
+ """Normalize a file/dir name for comparison purposes"""
+ # See pkg_resources.normalize_path
+ file = os.path.abspath(filename) if sys.platform == 'cygwin' else filename
+ return os.path.normcase(os.path.realpath(os.path.normpath(file)))
+
+
+def _empty_dir(dir_: _P) -> _P:
+ """Create a directory ensured to be empty. Existing files may be removed."""
+ shutil.rmtree(dir_, ignore_errors=True)
+ os.makedirs(dir_)
+ return dir_
+
+
+def _make_identifier(name: str) -> str:
+ """Make a string safe to be used as Python identifier.
+ >>> _make_identifier("12abc")
+ '_12abc'
+ >>> _make_identifier("__editable__.myns.pkg-78.9.3_local")
+ '__editable___myns_pkg_78_9_3_local'
+ """
+ safe = re.sub(r'\W|^(?=\d)', '_', name)
+ assert safe.isidentifier()
+ return safe
+
+
+class _NamespaceInstaller(namespaces.Installer):
+ def __init__(self, distribution, installation_dir, editable_name, src_root):
+ self.distribution = distribution
+ self.src_root = src_root
+ self.installation_dir = installation_dir
+ self.editable_name = editable_name
+ self.outputs = []
+ self.dry_run = False
+
+ def _get_target(self):
+ """Installation target."""
+ return os.path.join(self.installation_dir, self.editable_name)
+
+ def _get_root(self):
+ """Where the modules/packages should be loaded from."""
+ return repr(str(self.src_root))
+
+
+_FINDER_TEMPLATE = """\
+import sys
+from importlib.machinery import ModuleSpec
+from importlib.machinery import all_suffixes as module_suffixes
+from importlib.util import spec_from_file_location
+from itertools import chain
+from pathlib import Path
+
+MAPPING = {mapping!r}
+NAMESPACES = {namespaces!r}
+PATH_PLACEHOLDER = {name!r} + ".__path_hook__"
+
+
+class _EditableFinder: # MetaPathFinder
+ @classmethod
+ def find_spec(cls, fullname, path=None, target=None):
+ for pkg, pkg_path in reversed(list(MAPPING.items())):
+ if fullname == pkg or fullname.startswith(f"{{pkg}}."):
+ rest = fullname.replace(pkg, "", 1).strip(".").split(".")
+ return cls._find_spec(fullname, Path(pkg_path, *rest))
+
+ return None
+
+ @classmethod
+ def _find_spec(cls, fullname, candidate_path):
+ init = candidate_path / "__init__.py"
+ candidates = (candidate_path.with_suffix(x) for x in module_suffixes())
+ for candidate in chain([init], candidates):
+ if candidate.exists():
+ return spec_from_file_location(fullname, candidate)
+
+
+class _EditableNamespaceFinder: # PathEntryFinder
+ @classmethod
+ def _path_hook(cls, path):
+ if path == PATH_PLACEHOLDER:
+ return cls
+ raise ImportError
+
+ @classmethod
+ def _paths(cls, fullname):
+ # Ensure __path__ is not empty for the spec to be considered a namespace.
+ return NAMESPACES[fullname] or MAPPING.get(fullname) or [PATH_PLACEHOLDER]
+
+ @classmethod
+ def find_spec(cls, fullname, target=None):
+ if fullname in NAMESPACES:
+ spec = ModuleSpec(fullname, None, is_package=True)
+ spec.submodule_search_locations = cls._paths(fullname)
+ return spec
+ return None
+
+ @classmethod
+ def find_module(cls, fullname):
+ return None
+
+
+def install():
+ if not any(finder == _EditableFinder for finder in sys.meta_path):
+ sys.meta_path.append(_EditableFinder)
+
+ if not NAMESPACES:
+ return
+
+ if not any(hook == _EditableNamespaceFinder._path_hook for hook in sys.path_hooks):
+ # PathEntryFinder is needed to create NamespaceSpec without private APIS
+ sys.path_hooks.append(_EditableNamespaceFinder._path_hook)
+ if PATH_PLACEHOLDER not in sys.path:
+ sys.path.append(PATH_PLACEHOLDER) # Used just to trigger the path hook
+"""
+
+
+def _finder_template(
+ name: str, mapping: Mapping[str, str], namespaces: Dict[str, List[str]]
+) -> str:
+ """Create a string containing the code for the``MetaPathFinder`` and
+ ``PathEntryFinder``.
+ """
+ mapping = dict(sorted(mapping.items(), key=lambda p: p[0]))
+ return _FINDER_TEMPLATE.format(name=name, mapping=mapping, namespaces=namespaces)
+
+
+class InformationOnly(UserWarning):
+ """Currently there is no clear way of displaying messages to the users
+ that use the setuptools backend directly via ``pip``.
+ The only thing that might work is a warning, although it is not the
+ most appropriate tool for the job...
+ """
+
+
+class LinksNotSupported(errors.FileError):
+ """File system does not seem to support either symlinks or hard links."""
diff --git a/setuptools/command/egg_info.py b/setuptools/command/egg_info.py
index d55797bc..1885efb0 100644
--- a/setuptools/command/egg_info.py
+++ b/setuptools/command/egg_info.py
@@ -17,6 +17,9 @@ import warnings
import time
import collections
+from .._importlib import metadata
+from .. import _entry_points
+
from setuptools import Command
from setuptools.command.sdist import sdist
from setuptools.command.sdist import walk_revctrl
@@ -24,11 +27,12 @@ from setuptools.command.setopt import edit_config
from setuptools.command import bdist_egg
from pkg_resources import (
Requirement, safe_name, parse_version,
- safe_version, yield_lines, EntryPoint, iter_entry_points, to_filename)
+ safe_version, to_filename)
import setuptools.unicode_utils as unicode_utils
from setuptools.glob import glob
from setuptools.extern import packaging
+from setuptools.extern.jaraco.text import yield_lines
from setuptools import SetuptoolsDeprecationWarning
@@ -132,11 +136,21 @@ class InfoCommon:
in which case the version string already contains all tags.
"""
return (
- version if self.vtags and version.endswith(self.vtags)
+ version if self.vtags and self._already_tagged(version)
else version + self.vtags
)
- def tags(self):
+ def _already_tagged(self, version: str) -> bool:
+ # Depending on their format, tags may change with version normalization.
+ # So in addition the regular tags, we have to search for the normalized ones.
+ return version.endswith(self.vtags) or version.endswith(self._safe_tags())
+
+ def _safe_tags(self) -> str:
+ # To implement this we can rely on `safe_version` pretending to be version 0
+ # followed by tags. Then we simply discard the starting 0 (fake version number)
+ return safe_version(f"0{self.vtags}")[1:]
+
+ def tags(self) -> str:
version = ''
if self.tag_build:
version += self.tag_build
@@ -168,6 +182,7 @@ class egg_info(InfoCommon, Command):
self.egg_info = None
self.egg_version = None
self.broken_egg_info = False
+ self.ignore_egg_info_in_manifest = False
####################################
# allow the 'tag_svn_revision' to be detected and
@@ -280,11 +295,13 @@ class egg_info(InfoCommon, Command):
def run(self):
self.mkpath(self.egg_info)
- os.utime(self.egg_info, None)
- installer = self.distribution.fetch_build_egg
- for ep in iter_entry_points('egg_info.writers'):
- ep.require(installer=installer)
- writer = ep.resolve()
+ try:
+ os.utime(self.egg_info, None)
+ except OSError as e:
+ msg = f"Cannot update time stamp of directory '{self.egg_info}'"
+ raise distutils.errors.DistutilsFileError(msg) from e
+ for ep in metadata.entry_points(group='egg_info.writers'):
+ writer = ep.load()
writer(self, ep.name, os.path.join(self.egg_info, ep.name))
# Get rid of native_libs.txt if it was put there by older bdist_egg
@@ -298,6 +315,7 @@ class egg_info(InfoCommon, Command):
"""Generate SOURCES.txt manifest file"""
manifest_filename = os.path.join(self.egg_info, "SOURCES.txt")
mm = manifest_maker(self.distribution)
+ mm.ignore_egg_info_dir = self.ignore_egg_info_in_manifest
mm.manifest = manifest_filename
mm.run()
self.filelist = mm.filelist
@@ -321,6 +339,10 @@ class egg_info(InfoCommon, Command):
class FileList(_FileList):
# Implementations of the various MANIFEST.in commands
+ def __init__(self, warn=None, debug_print=None, ignore_egg_info_dir=False):
+ super().__init__(warn, debug_print)
+ self.ignore_egg_info_dir = ignore_egg_info_dir
+
def process_template_line(self, line):
# Parse the line: split it up, make sure the right number of words
# is there, and return the relevant words. 'action' is always
@@ -510,6 +532,10 @@ class FileList(_FileList):
return False
try:
+ # ignore egg-info paths
+ is_egg_info = ".egg-info" in u_path or b".egg-info" in utf8_path
+ if self.ignore_egg_info_dir and is_egg_info:
+ return False
# accept is either way checks out
if os.path.exists(u_path) or os.path.exists(utf8_path):
return True
@@ -526,12 +552,13 @@ class manifest_maker(sdist):
self.prune = 1
self.manifest_only = 1
self.force_manifest = 1
+ self.ignore_egg_info_dir = False
def finalize_options(self):
pass
def run(self):
- self.filelist = FileList()
+ self.filelist = FileList(ignore_egg_info_dir=self.ignore_egg_info_dir)
if not os.path.exists(self.manifest):
self.write_manifest() # it must exist so it'll get in the list
self.add_defaults()
@@ -715,20 +742,9 @@ def write_arg(cmd, basename, filename, force=False):
def write_entries(cmd, basename, filename):
- ep = cmd.distribution.entry_points
-
- if isinstance(ep, str) or ep is None:
- data = ep
- elif ep is not None:
- data = []
- for section, contents in sorted(ep.items()):
- if not isinstance(contents, str):
- contents = EntryPoint.parse_group(section, contents)
- contents = '\n'.join(sorted(map(str, contents.values())))
- data.append('[%s]\n%s\n\n' % (section, contents))
- data = ''.join(data)
-
- cmd.write_or_delete_file('entry points', filename, data, True)
+ eps = _entry_points.load(cmd.distribution.entry_points)
+ defn = _entry_points.render(eps)
+ cmd.write_or_delete_file('entry points', filename, defn, True)
def get_pkg_info_revision():
diff --git a/setuptools/command/install.py b/setuptools/command/install.py
index 35e54d20..55fdb124 100644
--- a/setuptools/command/install.py
+++ b/setuptools/command/install.py
@@ -91,14 +91,21 @@ class install(orig.install):
msg = "For best results, pass -X:Frames to enable call stack."
warnings.warn(msg)
return True
- res = inspect.getouterframes(run_frame)[2]
- caller, = res[:1]
- info = inspect.getframeinfo(caller)
- caller_module = caller.f_globals.get('__name__', '')
- return (
- caller_module == 'distutils.dist'
- and info.function == 'run_commands'
- )
+
+ frames = inspect.getouterframes(run_frame)
+ for frame in frames[2:4]:
+ caller, = frame[:1]
+ info = inspect.getframeinfo(caller)
+ caller_module = caller.f_globals.get('__name__', '')
+
+ if caller_module == "setuptools.dist" and info.function == "run_command":
+ # Starting from v61.0.0 setuptools overwrites dist.run_command
+ continue
+
+ return (
+ caller_module == 'distutils.dist'
+ and info.function == 'run_commands'
+ )
def do_egg_install(self):
diff --git a/setuptools/command/sdist.py b/setuptools/command/sdist.py
index 0285b690..4a8cde7e 100644
--- a/setuptools/command/sdist.py
+++ b/setuptools/command/sdist.py
@@ -4,17 +4,19 @@ import os
import sys
import io
import contextlib
+from itertools import chain
from .py36compat import sdist_add_defaults
-import pkg_resources
+from .._importlib import metadata
+from .build import _ORIGINAL_SUBCOMMANDS
_default_revctrl = list
def walk_revctrl(dirname=''):
"""Find all files under revision control"""
- for ep in pkg_resources.iter_entry_points('setuptools.file_finders'):
+ for ep in metadata.entry_points(group='setuptools.file_finders'):
for item in ep.load()(dirname):
yield item
@@ -100,6 +102,10 @@ class sdist(sdist_add_defaults, orig.sdist):
if orig_val is not NoValue:
setattr(os, 'link', orig_val)
+ def add_defaults(self):
+ super().add_defaults()
+ self._add_defaults_build_sub_commands()
+
def _add_defaults_optional(self):
super()._add_defaults_optional()
if os.path.isfile('pyproject.toml'):
@@ -112,6 +118,14 @@ class sdist(sdist_add_defaults, orig.sdist):
self.filelist.extend(build_py.get_source_files())
self._add_data_files(self._safe_data_files(build_py))
+ def _add_defaults_build_sub_commands(self):
+ build = self.get_finalized_command("build")
+ missing_cmds = set(build.get_sub_commands()) - _ORIGINAL_SUBCOMMANDS
+ # ^-- the original built-in sub-commands are already handled by default.
+ cmds = (self.get_finalized_command(c) for c in missing_cmds)
+ files = (c.get_source_files() for c in cmds if hasattr(c, "get_source_files"))
+ self.filelist.extend(chain.from_iterable(files))
+
def _safe_data_files(self, build_py):
"""
Since the ``sdist`` class is also used to compute the MANIFEST
diff --git a/setuptools/command/test.py b/setuptools/command/test.py
index 4a389e4d..8dde513c 100644
--- a/setuptools/command/test.py
+++ b/setuptools/command/test.py
@@ -16,10 +16,11 @@ from pkg_resources import (
evaluate_marker,
add_activation_listener,
require,
- EntryPoint,
)
+from .._importlib import metadata
from setuptools import Command
from setuptools.extern.more_itertools import unique_everseen
+from setuptools.extern.jaraco.functools import pass_none
class ScanningLoader(TestLoader):
@@ -117,7 +118,7 @@ class test(Command):
return list(self._test_args())
def _test_args(self):
- if not self.test_suite and sys.version_info >= (2, 7):
+ if not self.test_suite:
yield 'discover'
if self.verbose:
yield '--verbose'
@@ -241,12 +242,10 @@ class test(Command):
return ['unittest'] + self.test_args
@staticmethod
+ @pass_none
def _resolve_as_ep(val):
"""
Load the indicated attribute value, called, as a as if it were
specified as an entry point.
"""
- if val is None:
- return
- parsed = EntryPoint.parse("x=" + val)
- return parsed.resolve()()
+ return metadata.EntryPoint(value=val, name=None, group=None).load()()
diff --git a/setuptools/command/upload_docs.py b/setuptools/command/upload_docs.py
index 845bff44..63eb28c7 100644
--- a/setuptools/command/upload_docs.py
+++ b/setuptools/command/upload_docs.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
"""upload_docs
Implements a Distutils 'upload_docs' subcommand (upload documentation to
@@ -17,8 +16,11 @@ import itertools
import functools
import http.client
import urllib.parse
+import warnings
+
+from .._importlib import metadata
+from .. import SetuptoolsDeprecationWarning
-from pkg_resources import iter_entry_points
from .upload import upload
@@ -43,9 +45,10 @@ class upload_docs(upload):
boolean_options = upload.boolean_options
def has_sphinx(self):
- if self.upload_dir is None:
- for ep in iter_entry_points('distutils.commands', 'build_sphinx'):
- return True
+ return bool(
+ self.upload_dir is None
+ and metadata.entry_points(group='distutils.commands', name='build_sphinx')
+ )
sub_commands = [('build_sphinx', has_sphinx)]
@@ -55,6 +58,9 @@ class upload_docs(upload):
self.target_dir = None
def finalize_options(self):
+ log.warn(
+ "Upload_docs command is deprecated. Use Read the Docs "
+ "(https://readthedocs.org) instead.")
upload.finalize_options(self)
if self.upload_dir is None:
if self.has_sphinx():
@@ -66,8 +72,6 @@ class upload_docs(upload):
else:
self.ensure_dirname('upload_dir')
self.target_dir = self.upload_dir
- if 'pypi.python.org' in self.repository:
- log.warn("Upload_docs command is deprecated for PyPi. Use RTD instead.")
self.announce('Using upload directory %s' % self.target_dir)
def create_zipfile(self, filename):
@@ -87,6 +91,12 @@ class upload_docs(upload):
zip_file.close()
def run(self):
+ warnings.warn(
+ "upload_docs is deprecated and will be removed in a future "
+ "version. Use tools like httpie or curl instead.",
+ SetuptoolsDeprecationWarning,
+ )
+
# Run sub commands
for cmd_name in self.get_sub_commands():
self.run_command(cmd_name)