diff --git a/changelog.d/3392.change.rst b/changelog.d/3392.change.rst new file mode 100644 index 0000000000..8ae7fd90de --- /dev/null +++ b/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. diff --git a/setuptools/command/build_ext.py b/setuptools/command/build_ext.py index 1719d17a21..31ef47bf3e 100644 --- a/setuptools/command/build_ext.py +++ b/setuptools/command/build_ext.py @@ -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 @@ -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 @@ -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') @@ -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) @@ -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 @@ -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 @@ -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 @@ -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( @@ -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': diff --git a/setuptools/command/build_py.py b/setuptools/command/build_py.py index 9575cdf868..a2a6fe2cc8 100644 --- a/setuptools/command/build_py.py +++ b/setuptools/command/build_py.py @@ -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 @@ -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) @@ -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: @@ -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 = {} @@ -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 @@ -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) diff --git a/setuptools/extension.py b/setuptools/extension.py index 64baf1147b..b9a2bad321 100644 --- a/setuptools/extension.py +++ b/setuptools/extension.py @@ -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 `. """ def __init__(self, name, sources, *args, **kw): diff --git a/setuptools/tests/test_build_ext.py b/setuptools/tests/test_build_ext.py index 3177a2cdd6..07ebcaf82b 100644 --- a/setuptools/tests/test_build_ext.py +++ b/setuptools/tests/test_build_ext.py @@ -2,6 +2,7 @@ import sys import distutils.command.build_ext as orig from distutils.sysconfig import get_config_var +from importlib.util import cache_from_source as _compiled_file_name from jaraco import path @@ -83,6 +84,97 @@ def test_ext_suffix_override(self): finally: del os.environ['SETUPTOOLS_EXT_SUFFIX'] + def dist_with_example(self): + files = { + "src": {"mypkg": {"subpkg": {"ext2.c": ""}}}, + "c-extensions": {"ext1": {"main.c": ""}}, + } + + ext1 = Extension("mypkg.ext1", ["c-extensions/ext1/main.c"]) + ext2 = Extension("mypkg.subpkg.ext2", ["src/mypkg/subpkg/ext2.c"]) + ext3 = Extension("ext3", ["c-extension/ext3.c"]) + + path.build(files) + dist = Distribution({ + "script_name": "%test%", + "ext_modules": [ext1, ext2, ext3], + "package_dir": {"": "src"}, + }) + return dist + + def test_get_outputs(self, tmpdir_cwd, monkeypatch): + monkeypatch.setenv('SETUPTOOLS_EXT_SUFFIX', '.mp3') # make test OS-independent + monkeypatch.setattr('setuptools.command.build_ext.use_stubs', False) + dist = self.dist_with_example() + + # Regular build: get_outputs not empty, but get_output_mappings is empty + build_ext = dist.get_command_obj("build_ext") + build_ext.editable_mode = False + build_ext.ensure_finalized() + build_lib = build_ext.build_lib.replace(os.sep, "/") + outputs = [x.replace(os.sep, "/") for x in build_ext.get_outputs()] + assert outputs == [ + f"{build_lib}/ext3.mp3", + f"{build_lib}/mypkg/ext1.mp3", + f"{build_lib}/mypkg/subpkg/ext2.mp3", + ] + assert build_ext.get_output_mapping() == {} + + # Editable build: get_output_mappings should contain everything in get_outputs + dist.reinitialize_command("build_ext") + build_ext.editable_mode = True + build_ext.ensure_finalized() + mapping = { + k.replace(os.sep, "/"): v.replace(os.sep, "/") + for k, v in build_ext.get_output_mapping().items() + } + assert mapping == { + f"{build_lib}/ext3.mp3": "src/ext3.mp3", + f"{build_lib}/mypkg/ext1.mp3": "src/mypkg/ext1.mp3", + f"{build_lib}/mypkg/subpkg/ext2.mp3": "src/mypkg/subpkg/ext2.mp3", + } + + def test_get_output_mapping_with_stub(self, tmpdir_cwd, monkeypatch): + monkeypatch.setenv('SETUPTOOLS_EXT_SUFFIX', '.mp3') # make test OS-independent + monkeypatch.setattr('setuptools.command.build_ext.use_stubs', True) + dist = self.dist_with_example() + + # Editable build should create compiled stubs (.pyc files only, no .py) + build_ext = dist.get_command_obj("build_ext") + build_ext.editable_mode = True + build_ext.ensure_finalized() + for ext in build_ext.extensions: + monkeypatch.setattr(ext, "_needs_stub", True) + + build_lib = build_ext.build_lib.replace(os.sep, "/") + mapping = { + k.replace(os.sep, "/"): v.replace(os.sep, "/") + for k, v in build_ext.get_output_mapping().items() + } + + def C(file): + """Make it possible to do comparisons and tests in a OS-independent way""" + return _compiled_file_name(file).replace(os.sep, "/") + + assert mapping == { + C(f"{build_lib}/ext3.py"): C("src/ext3.py"), + f"{build_lib}/ext3.mp3": "src/ext3.mp3", + C(f"{build_lib}/mypkg/ext1.py"): C("src/mypkg/ext1.py"), + f"{build_lib}/mypkg/ext1.mp3": "src/mypkg/ext1.mp3", + C(f"{build_lib}/mypkg/subpkg/ext2.py"): C("src/mypkg/subpkg/ext2.py"), + f"{build_lib}/mypkg/subpkg/ext2.mp3": "src/mypkg/subpkg/ext2.mp3", + } + + # Ensure only the compiled stubs are present not the raw .py stub + assert f"{build_lib}/mypkg/ext1.py" not in mapping + assert f"{build_lib}/mypkg/subpkg/ext2.py" not in mapping + + # Visualize what the cached stub files look like + example_stub = C(f"{build_lib}/mypkg/ext1.py") + assert example_stub in mapping + assert example_stub.startswith(f"{build_lib}/mypkg/__pycache__/ext1") + assert example_stub.endswith(".pyc") + def test_build_ext_config_handling(tmpdir_cwd): files = { diff --git a/setuptools/tests/test_build_py.py b/setuptools/tests/test_build_py.py index 13fa64de9e..2b32edbc59 100644 --- a/setuptools/tests/test_build_py.py +++ b/setuptools/tests/test_build_py.py @@ -1,10 +1,11 @@ import os import stat import shutil +from pathlib import Path +from unittest.mock import Mock import pytest import jaraco.path -from path import Path from setuptools import SetuptoolsDeprecationWarning from setuptools.dist import Distribution @@ -109,67 +110,194 @@ def test_executable_data(tmpdir_cwd): "Script is not executable" -def test_excluded_subpackages(tmp_path): - files = { - "setup.cfg": DALS(""" - [metadata] - name = mypkg - version = 42 +EXAMPLE_WITH_MANIFEST = { + "setup.cfg": DALS(""" + [metadata] + name = mypkg + version = 42 - [options] - include_package_data = True - packages = find: + [options] + include_package_data = True + packages = find: - [options.packages.find] - exclude = *.tests* - """), + [options.packages.find] + exclude = *.tests* + """), + "mypkg": { + "__init__.py": "", + "resource_file.txt": "", + "tests": { + "__init__.py": "", + "test_mypkg.py": "", + "test_file.txt": "", + } + }, + "MANIFEST.in": DALS(""" + global-include *.py *.txt + global-exclude *.py[cod] + prune dist + prune build + prune *.egg-info + """) +} + + +def test_excluded_subpackages(tmpdir_cwd): + jaraco.path.build(EXAMPLE_WITH_MANIFEST) + dist = Distribution({"script_name": "%PEP 517%"}) + dist.parse_config_files() + + build_py = dist.get_command_obj("build_py") + msg = r"Python recognizes 'mypkg\.tests' as an importable package" + with pytest.warns(SetuptoolsDeprecationWarning, match=msg): + # TODO: To fix #3260 we need some transition period to deprecate the + # existing behavior of `include_package_data`. After the transition, we + # should remove the warning and fix the behaviour. + build_py.finalize_options() + build_py.run() + + build_dir = Path(dist.get_command_obj("build_py").build_lib) + assert (build_dir / "mypkg/__init__.py").exists() + assert (build_dir / "mypkg/resource_file.txt").exists() + + # Setuptools is configured to ignore `mypkg.tests`, therefore the following + # files/dirs should not be included in the distribution. + for f in [ + "mypkg/tests/__init__.py", + "mypkg/tests/test_mypkg.py", + "mypkg/tests/test_file.txt", + "mypkg/tests", + ]: + with pytest.raises(AssertionError): + # TODO: Enforce the following assertion once #3260 is fixed + # (remove context manager and the following xfail). + assert not (build_dir / f).exists() + + pytest.xfail("#3260") + + +@pytest.mark.filterwarnings("ignore::setuptools.SetuptoolsDeprecationWarning") +def test_existing_egg_info(tmpdir_cwd, monkeypatch): + """When provided with the ``existing_egg_info_dir`` attribute, build_py should not + attempt to run egg_info again. + """ + # == Pre-condition == + # Generate an egg-info dir + jaraco.path.build(EXAMPLE_WITH_MANIFEST) + dist = Distribution({"script_name": "%PEP 517%"}) + dist.parse_config_files() + assert dist.include_package_data + + egg_info = dist.get_command_obj("egg_info") + dist.run_command("egg_info") + egg_info_dir = next(Path(egg_info.egg_base).glob("*.egg-info")) + assert egg_info_dir.is_dir() + + # == Setup == + build_py = dist.get_command_obj("build_py") + build_py.finalize_options() + egg_info = dist.get_command_obj("egg_info") + egg_info_run = Mock(side_effect=egg_info.run) + monkeypatch.setattr(egg_info, "run", egg_info_run) + + # == Remove caches == + # egg_info is called when build_py looks for data_files, which gets cached. + # We need to ensure it is not cached yet, otherwise it may impact on the tests + build_py.__dict__.pop('data_files', None) + dist.reinitialize_command(egg_info) + + # == Sanity check == + # Ensure that if existing_egg_info is not given, build_py attempts to run egg_info + build_py.existing_egg_info_dir = None + build_py.run() + egg_info_run.assert_called() + + # == Remove caches == + egg_info_run.reset_mock() + build_py.__dict__.pop('data_files', None) + dist.reinitialize_command(egg_info) + + # == Actual test == + # Ensure that if existing_egg_info_dir is given, egg_info doesn't run + build_py.existing_egg_info_dir = egg_info_dir + build_py.run() + egg_info_run.assert_not_called() + assert build_py.data_files + + # Make sure the list of outputs is actually OK + outputs = map(lambda x: x.replace(os.sep, "/"), build_py.get_outputs()) + assert outputs + example = str(Path(build_py.build_lib, "mypkg/__init__.py")).replace(os.sep, "/") + assert example in outputs + + +EXAMPLE_ARBITRARY_MAPPING = { + "pyproject.toml": DALS(""" + [project] + name = "mypkg" + version = "42" + + [tool.setuptools] + packages = ["mypkg", "mypkg.sub1", "mypkg.sub2", "mypkg.sub2.nested"] + + [tool.setuptools.package-dir] + "" = "src" + "mypkg.sub2" = "src/mypkg/_sub2" + "mypkg.sub2.nested" = "other" + """), + "src": { "mypkg": { "__init__.py": "", "resource_file.txt": "", - "tests": { + "sub1": { "__init__.py": "", - "test_mypkg.py": "", - "test_file.txt": "", - } + "mod1.py": "", + }, + "_sub2": { + "mod2.py": "", + }, }, - "MANIFEST.in": DALS(""" - global-include *.py *.txt - global-exclude *.py[cod] - prune dist - prune build - prune *.egg-info - """) - } + }, + "other": { + "__init__.py": "", + "mod3.py": "", + }, + "MANIFEST.in": DALS(""" + global-include *.py *.txt + global-exclude *.py[cod] + """) +} + - with Path(tmp_path): - jaraco.path.build(files) - dist = Distribution({"script_name": "%PEP 517%"}) - dist.parse_config_files() - - build_py = dist.get_command_obj("build_py") - msg = r"Python recognizes 'mypkg\.tests' as an importable package" - with pytest.warns(SetuptoolsDeprecationWarning, match=msg): - # TODO: To fix #3260 we need some transition period to deprecate the - # existing behavior of `include_package_data`. After the transition, we - # should remove the warning and fix the behaviour. - build_py.finalize_options() - build_py.run() - - build_dir = Path(dist.get_command_obj("build_py").build_lib) - assert (build_dir / "mypkg/__init__.py").exists() - assert (build_dir / "mypkg/resource_file.txt").exists() - - # Setuptools is configured to ignore `mypkg.tests`, therefore the following - # files/dirs should not be included in the distribution. - for f in [ - "mypkg/tests/__init__.py", - "mypkg/tests/test_mypkg.py", - "mypkg/tests/test_file.txt", - "mypkg/tests", - ]: - with pytest.raises(AssertionError): - # TODO: Enforce the following assertion once #3260 is fixed - # (remove context manager and the following xfail). - assert not (build_dir / f).exists() - - pytest.xfail("#3260") +def test_get_outputs(tmpdir_cwd): + jaraco.path.build(EXAMPLE_ARBITRARY_MAPPING) + dist = Distribution({"script_name": "%test%"}) + dist.parse_config_files() + + build_py = dist.get_command_obj("build_py") + build_py.editable_mode = True + build_py.ensure_finalized() + build_lib = build_py.build_lib.replace(os.sep, "/") + outputs = [x.replace(os.sep, "/") for x in build_py.get_outputs()] + assert outputs == [ + f"{build_lib}/mypkg/__init__.py", + f"{build_lib}/mypkg/resource_file.txt", + f"{build_lib}/mypkg/sub1/__init__.py", + f"{build_lib}/mypkg/sub1/mod1.py", + f"{build_lib}/mypkg/sub2/mod2.py", + f"{build_lib}/mypkg/sub2/nested/__init__.py", + f"{build_lib}/mypkg/sub2/nested/mod3.py", + ] + mapping = { + k.replace(os.sep, "/"): v.replace(os.sep, "/") + for k, v in build_py.get_output_mapping().items() + } + assert mapping == { + f"{build_lib}/mypkg/__init__.py": "src/mypkg/__init__.py", + f"{build_lib}/mypkg/resource_file.txt": "src/mypkg/resource_file.txt", + f"{build_lib}/mypkg/sub1/__init__.py": "src/mypkg/sub1/__init__.py", + f"{build_lib}/mypkg/sub1/mod1.py": "src/mypkg/sub1/mod1.py", + f"{build_lib}/mypkg/sub2/mod2.py": "src/mypkg/_sub2/mod2.py", + f"{build_lib}/mypkg/sub2/nested/__init__.py": "other/__init__.py", + f"{build_lib}/mypkg/sub2/nested/mod3.py": "other/mod3.py", + }