Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add get_output_mapping to build_py and build_ext #3392

Merged
merged 6 commits into from Jun 21, 2022
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions changelog.d/3392.change.rst
@@ -0,0 +1,5 @@
Exposed ``get_output_mapping()`` from ``build_py`` and ``build_ext``
subcommands. This interface is reserved for the use of ``setuptools``
Extensions and third part packages are explicitly disallowed to calling it.
However, any implementation overwriting ``build_py`` or ``build_ext`` are
required to honour this interface.
118 changes: 90 additions & 28 deletions setuptools/command/build_ext.py
Expand Up @@ -2,13 +2,17 @@
import sys
import itertools
from importlib.machinery import EXTENSION_SUFFIXES
from importlib.util import cache_from_source as _compiled_file_name
from pathlib import Path
from typing import Dict, Iterator, List, Tuple, Union

from distutils.command.build_ext import build_ext as _du_build_ext
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
Expand Down Expand Up @@ -72,6 +76,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
Expand All @@ -80,24 +87,61 @@ def run(self):
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.
build_py.copy_file(src_filename, dest_filename)
build_py.copy_file(regular_file, inplace_file)

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')
Expand Down Expand Up @@ -127,6 +171,7 @@ def initialize_options(self):
self.shlib_compiler = None
self.shlibs = []
self.ext_map = {}
self.editable_mode = False

def finalize_options(self):
_build_ext.finalize_options(self)
Expand Down Expand Up @@ -157,6 +202,9 @@ def finalize_options(self):
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
Expand Down Expand Up @@ -197,8 +245,8 @@ def build_extension(self, 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

Expand All @@ -212,7 +260,14 @@ def links_to_dynamic(self, ext):
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()
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
Expand All @@ -232,12 +287,13 @@ def __get_output_extensions(self):
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(
Expand Down Expand Up @@ -270,17 +326,23 @@ def write_stub(self, output_dir, ext, compile=False):
)
f.close()
if compile:
from distutils.util import byte_compile
self._compile_and_remove_stub(stub_file)

byte_compile([stub_file], optimize=0,
def _compile_and_remove_stub(self, stub_file: str):
from distutils.util import byte_compile

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)


def _file_with_suffix(directory: str, name: str, suffix: str) -> str:
return f"{os.path.join(directory, name)}.{suffix}"

if use_stubs or os.name == 'nt':
# Build shared libraries
Expand Down
64 changes: 54 additions & 10 deletions setuptools/command/build_py.py
Expand Up @@ -11,6 +11,8 @@
import stat
import warnings
from pathlib import Path
from typing import Dict, Iterator, List, Optional, Tuple

from setuptools._deprecation_warning import SetuptoolsDeprecationWarning
from setuptools.extern.more_itertools import unique_everseen

Expand All @@ -28,6 +30,13 @@ 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 initialize_options(self):
super().initialize_options()
self.editable_mode = False
self.existing_egg_info_dir = None

def finalize_options(self):
orig.build_py.finalize_options(self)
Expand All @@ -50,7 +59,8 @@ def copy_file(self, infile, outfile, preserve_mode=1, preserve_times=1,

def run(self):
"""Build modules, packages, and copy data files to build directory"""
if not self.py_modules and not self.packages:
# if self.editable_mode or not (self.py_modules and self.packages):
if not (self.py_modules or self.packages) or self.editable_mode:
return

if self.py_modules:
Expand Down Expand Up @@ -123,16 +133,41 @@ def find_data_files(self, package, src_dir):
)
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 = {}
Expand All @@ -143,10 +178,19 @@ def analyze_manifest(self):
# Locate package source directory
src_dirs[assert_relative(self.get_package_dir(package))] = package

self.run_command('egg_info')
if (
getattr(self, 'existing_egg_info_dir', None)
and Path(self.existing_egg_info_dir, "SOURCES.txt").exists()
):
manifest = Path(self.existing_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')
files = ei_cmd.filelist.files

check = _IncludePackageDataAbuse()
ei_cmd = self.get_finalized_command('egg_info')
for path in ei_cmd.filelist.files:
for path in files:
d, f = os.path.split(assert_relative(path))
prev = None
oldf = f
Expand Down
3 changes: 3 additions & 0 deletions setuptools/extension.py
Expand Up @@ -113,6 +113,9 @@ class Extension(_Extension):
:keyword bool optional:
specifies that a build failure in the extension should not abort the
build process, but simply not install the failing extension.

:keyword bool py_limited_api:
opt-in flag for the usage of :doc:`Python's limited API <python:c-api/stable>`.
"""

def __init__(self, name, sources, *args, **kw):
Expand Down