Skip to content

Commit

Permalink
Add get_output_mapping to build_py and build_ext (#3392)
Browse files Browse the repository at this point in the history
  • Loading branch information
abravalheri committed Jun 21, 2022
2 parents ecdeb22 + d019f49 commit cdd12f3
Show file tree
Hide file tree
Showing 6 changed files with 423 additions and 96 deletions.
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.
116 changes: 87 additions & 29 deletions setuptools/command/build_ext.py
Expand Up @@ -2,13 +2,16 @@
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.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 +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
Expand All @@ -80,24 +86,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:
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:
self.write_stub(package_dir or os.curdir, ext, True)
# 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 +170,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 +201,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 +244,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 @@ -211,8 +258,15 @@ def links_to_dynamic(self, 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
Expand All @@ -232,12 +286,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,16 +325,19 @@ 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)

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':
Expand Down
61 changes: 51 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,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)
Expand All @@ -50,7 +54,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 +128,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 +173,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 Expand Up @@ -200,6 +239,8 @@ def check_package(self, package, package_dir):
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)
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

0 comments on commit cdd12f3

Please sign in to comment.