summaryrefslogtreecommitdiff
path: root/setuptools/tests
diff options
context:
space:
mode:
Diffstat (limited to 'setuptools/tests')
-rw-r--r--setuptools/tests/contexts.py23
-rw-r--r--setuptools/tests/namespaces.py23
-rw-r--r--setuptools/tests/test_build_ext.py92
-rw-r--r--setuptools/tests/test_build_meta.py90
-rw-r--r--setuptools/tests/test_build_py.py242
-rw-r--r--setuptools/tests/test_develop.py44
-rw-r--r--setuptools/tests/test_dist_info.py39
-rw-r--r--setuptools/tests/test_editable_install.py652
-rw-r--r--setuptools/tests/test_sdist.py41
9 files changed, 1121 insertions, 125 deletions
diff --git a/setuptools/tests/contexts.py b/setuptools/tests/contexts.py
index 58948824..7ddbc780 100644
--- a/setuptools/tests/contexts.py
+++ b/setuptools/tests/contexts.py
@@ -123,3 +123,26 @@ def session_locked_tmp_dir(request, tmp_path_factory, name):
# ^-- prevent multiple workers to access the directory at once
locked_dir.mkdir(exist_ok=True, parents=True)
yield locked_dir
+
+
+@contextlib.contextmanager
+def save_paths():
+ """Make sure ``sys.path``, ``sys.meta_path`` and ``sys.path_hooks`` are preserved"""
+ prev = sys.path[:], sys.meta_path[:], sys.path_hooks[:]
+
+ try:
+ yield
+ finally:
+ sys.path, sys.meta_path, sys.path_hooks = prev
+
+
+@contextlib.contextmanager
+def save_sys_modules():
+ """Make sure initial ``sys.modules`` is preserved"""
+ prev_modules = sys.modules
+
+ try:
+ sys.modules = sys.modules.copy()
+ yield
+ finally:
+ sys.modules = prev_modules
diff --git a/setuptools/tests/namespaces.py b/setuptools/tests/namespaces.py
index 245cf8ea..34e916f5 100644
--- a/setuptools/tests/namespaces.py
+++ b/setuptools/tests/namespaces.py
@@ -28,6 +28,29 @@ def build_namespace_package(tmpdir, name):
return src_dir
+def build_pep420_namespace_package(tmpdir, name):
+ src_dir = tmpdir / name
+ src_dir.mkdir()
+ pyproject = src_dir / "pyproject.toml"
+ namespace, sep, rest = name.rpartition(".")
+ script = f"""\
+ [build-system]
+ requires = ["setuptools"]
+ build-backend = "setuptools.build_meta"
+
+ [project]
+ name = "{name}"
+ version = "3.14159"
+ """
+ pyproject.write_text(textwrap.dedent(script), encoding='utf-8')
+ ns_pkg_dir = src_dir / namespace.replace(".", "/")
+ ns_pkg_dir.mkdir(parents=True)
+ pkg_mod = ns_pkg_dir / (rest + ".py")
+ some_functionality = f"name = {rest!r}"
+ pkg_mod.write_text(some_functionality, encoding='utf-8')
+ return src_dir
+
+
def make_site_dir(target):
"""
Add a sitecustomize.py module in target to cause
diff --git a/setuptools/tests/test_build_ext.py b/setuptools/tests/test_build_ext.py
index 3177a2cd..07ebcaf8 100644
--- a/setuptools/tests/test_build_ext.py
+++ b/setuptools/tests/test_build_ext.py
@@ -2,6 +2,7 @@ import os
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 @@ class TestBuildExt:
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_meta.py b/setuptools/tests/test_build_meta.py
index 36940e76..e70c71bd 100644
--- a/setuptools/tests/test_build_meta.py
+++ b/setuptools/tests/test_build_meta.py
@@ -8,6 +8,7 @@ import contextlib
from concurrent import futures
import re
from zipfile import ZipFile
+from pathlib import Path
import pytest
from jaraco import path
@@ -611,6 +612,71 @@ class TestBuildMetaBackend:
with pytest.raises(ImportError, match="^No module named 'hello'$"):
build_backend.build_sdist("temp")
+ _simple_pyproject_example = {
+ "pyproject.toml": DALS("""
+ [project]
+ name = "proj"
+ version = "42"
+ """),
+ "src": {
+ "proj": {"__init__.py": ""}
+ }
+ }
+
+ def _assert_link_tree(self, parent_dir):
+ """All files in the directory should be either links or hard links"""
+ files = list(Path(parent_dir).glob("**/*"))
+ assert files # Should not be empty
+ for file in files:
+ assert file.is_symlink() or os.stat(file).st_nlink > 0
+
+ @pytest.mark.filterwarnings("ignore::setuptools.SetuptoolsDeprecationWarning")
+ # Since the backend is running via a process pool, in some operating systems
+ # we may have problems to make assertions based on warnings/stdout/stderr...
+ # So the best is to ignore them for the time being.
+ def test_editable_with_global_option_still_works(self, tmpdir_cwd):
+ """The usage of --global-option is now discouraged in favour of --build-option.
+ This is required to make more sense of the provided scape hatch and align with
+ previous pip behaviour. See pypa/setuptools#1928.
+ """
+ path.build({**self._simple_pyproject_example, '_meta': {}})
+ build_backend = self.get_build_backend()
+ assert not Path("build").exists()
+
+ cfg = {"--global-option": ["--mode", "strict"]}
+ build_backend.prepare_metadata_for_build_editable("_meta", cfg)
+ build_backend.build_editable("temp", cfg, "_meta")
+
+ self._assert_link_tree(next(Path("build").glob("__editable__.*")))
+
+ def test_editable_without_config_settings(self, tmpdir_cwd):
+ """
+ Sanity check to ensure tests with --mode=strict are different from the ones
+ without --mode.
+
+ --mode=strict should create a local directory with a package tree.
+ The directory should not get created otherwise.
+ """
+ path.build(self._simple_pyproject_example)
+ build_backend = self.get_build_backend()
+ assert not Path("build").exists()
+ build_backend.build_editable("temp")
+ assert not Path("build").exists()
+
+ @pytest.mark.parametrize(
+ "config_settings", [
+ {"--build-option": ["--mode", "strict"]},
+ {"editable-mode": "strict"},
+ ]
+ )
+ def test_editable_with_config_settings(self, tmpdir_cwd, config_settings):
+ path.build({**self._simple_pyproject_example, '_meta': {}})
+ assert not Path("build").exists()
+ build_backend = self.get_build_backend()
+ build_backend.prepare_metadata_for_build_editable("_meta", config_settings)
+ build_backend.build_editable("temp", config_settings, "_meta")
+ self._assert_link_tree(next(Path("build").glob("__editable__.*")))
+
@pytest.mark.parametrize('setup_literal, requirements', [
("'foo'", ['foo']),
("['foo']", ['foo']),
@@ -764,3 +830,27 @@ class TestBuildMetaLegacyBackend(TestBuildMetaBackend):
build_backend = self.get_build_backend()
build_backend.build_sdist("temp")
+
+
+def test_legacy_editable_install(venv, tmpdir, tmpdir_cwd):
+ pyproject = """
+ [build-system]
+ requires = ["setuptools"]
+ build-backend = "setuptools.build_meta"
+ [project]
+ name = "myproj"
+ version = "42"
+ """
+ path.build({"pyproject.toml": DALS(pyproject), "mymod.py": ""})
+
+ # First: sanity check
+ cmd = ["pip", "install", "--no-build-isolation", "-e", "."]
+ output = str(venv.run(cmd, cwd=tmpdir), "utf-8").lower()
+ assert "running setup.py develop for myproj" not in output
+ assert "created wheel for myproj" in output
+
+ # Then: real test
+ env = {**os.environ, "SETUPTOOLS_ENABLE_FEATURES": "legacy-editable"}
+ cmd = ["pip", "install", "--no-build-isolation", "-e", "."]
+ output = str(venv.run(cmd, cwd=tmpdir, env=env), "utf-8").lower()
+ assert "running setup.py develop for myproj" in output
diff --git a/setuptools/tests/test_build_py.py b/setuptools/tests/test_build_py.py
index 13fa64de..77738f23 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]
+ """)
+}
+
+
+def test_get_outputs(tmpdir_cwd):
+ jaraco.path.build(EXAMPLE_ARBITRARY_MAPPING)
+ dist = Distribution({"script_name": "%test%"})
+ dist.parse_config_files()
- 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")
+ 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",
+ }
diff --git a/setuptools/tests/test_develop.py b/setuptools/tests/test_develop.py
index c52072ac..0dd60342 100644
--- a/setuptools/tests/test_develop.py
+++ b/setuptools/tests/test_develop.py
@@ -5,12 +5,10 @@ import os
import sys
import subprocess
import platform
-import pathlib
from setuptools.command import test
import pytest
-import pip_run.launch
from setuptools.command.develop import develop
from setuptools.dist import Distribution
@@ -165,45 +163,3 @@ class TestNamespaces:
]
with test.test.paths_on_pythonpath([str(target)]):
subprocess.check_call(pkg_resources_imp)
-
- @pytest.mark.xfail(
- platform.python_implementation() == 'PyPy',
- reason="Workaround fails on PyPy (why?)",
- )
- def test_editable_prefix(self, tmp_path, sample_project):
- """
- Editable install to a prefix should be discoverable.
- """
- prefix = tmp_path / 'prefix'
-
- # figure out where pip will likely install the package
- site_packages = prefix / next(
- pathlib.Path(path).relative_to(sys.prefix)
- for path in sys.path
- if 'site-packages' in path and path.startswith(sys.prefix)
- )
- site_packages.mkdir(parents=True)
-
- # install workaround
- pip_run.launch.inject_sitecustomize(str(site_packages))
-
- env = dict(os.environ, PYTHONPATH=str(site_packages))
- cmd = [
- sys.executable,
- '-m',
- 'pip',
- 'install',
- '--editable',
- str(sample_project),
- '--prefix',
- str(prefix),
- '--no-build-isolation',
- ]
- subprocess.check_call(cmd, env=env)
-
- # now run 'sample' with the prefix on the PYTHONPATH
- bin = 'Scripts' if platform.system() == 'Windows' else 'bin'
- exe = prefix / bin / 'sample'
- if sys.version_info < (3, 8) and platform.system() == 'Windows':
- exe = str(exe)
- subprocess.check_call([exe], env=env)
diff --git a/setuptools/tests/test_dist_info.py b/setuptools/tests/test_dist_info.py
index 813ef51d..350e6429 100644
--- a/setuptools/tests/test_dist_info.py
+++ b/setuptools/tests/test_dist_info.py
@@ -2,6 +2,7 @@
"""
import pathlib
import re
+import shutil
import subprocess
import sys
from functools import partial
@@ -91,6 +92,42 @@ class TestDistInfo:
dist_info = next(tmp_path.glob("*.dist-info"))
assert dist_info.name.startswith("proj-42")
+ def test_tag_arguments(self, tmp_path):
+ config = """
+ [metadata]
+ name=proj
+ version=42
+ [egg_info]
+ tag_date=1
+ tag_build=.post
+ """
+ (tmp_path / "setup.cfg").write_text(config, encoding="utf-8")
+
+ print(run_command("dist_info", "--no-date", cwd=tmp_path))
+ dist_info = next(tmp_path.glob("*.dist-info"))
+ assert dist_info.name.startswith("proj-42")
+ shutil.rmtree(dist_info)
+
+ print(run_command("dist_info", "--tag-build", ".a", cwd=tmp_path))
+ dist_info = next(tmp_path.glob("*.dist-info"))
+ assert dist_info.name.startswith("proj-42a")
+
+ @pytest.mark.parametrize("keep_egg_info", (False, True))
+ def test_output_dir(self, tmp_path, keep_egg_info):
+ config = "[metadata]\nname=proj\nversion=42\n"
+ (tmp_path / "setup.cfg").write_text(config, encoding="utf-8")
+ out = (tmp_path / "__out")
+ out.mkdir()
+ opts = ["--keep-egg-info"] if keep_egg_info else []
+ run_command("dist_info", "--output-dir", out, *opts, cwd=tmp_path)
+ assert len(list(out.glob("*.dist-info"))) == 1
+ assert len(list(tmp_path.glob("*.dist-info"))) == 0
+ expected_egg_info = 1 if keep_egg_info else 0
+ assert len(list(out.glob("*.egg-info"))) == expected_egg_info
+ assert len(list(tmp_path.glob("*.egg-info"))) == 0
+ assert len(list(out.glob("*.__bkp__"))) == 0
+ assert len(list(tmp_path.glob("*.__bkp__"))) == 0
+
class TestWheelCompatibility:
"""Make sure the .dist-info directory produced with the ``dist_info`` command
@@ -154,5 +191,5 @@ class TestWheelCompatibility:
def run_command(*cmd, **kwargs):
opts = {"stderr": subprocess.STDOUT, "text": True, **kwargs}
- cmd = [sys.executable, "-c", "__import__('setuptools').setup()", *cmd]
+ cmd = [sys.executable, "-c", "__import__('setuptools').setup()", *map(str, cmd)]
return subprocess.check_output(cmd, **opts)
diff --git a/setuptools/tests/test_editable_install.py b/setuptools/tests/test_editable_install.py
index aac4f5ee..57e31eda 100644
--- a/setuptools/tests/test_editable_install.py
+++ b/setuptools/tests/test_editable_install.py
@@ -1,27 +1,45 @@
+import os
+import stat
+import sys
import subprocess
+import platform
+from copy import deepcopy
+from importlib import import_module
+from pathlib import Path
from textwrap import dedent
+from unittest.mock import Mock
+from uuid import uuid4
-import pytest
import jaraco.envs
-import path
+import jaraco.path
+import pip_run.launch
+import pytest
+from path import Path as _Path
+from . import contexts, namespaces
+
+from setuptools._importlib import resources as importlib_resources
+from setuptools.command.editable_wheel import (
+ _LinkTree,
+ _find_virtual_namespaces,
+ _find_namespaces,
+ _find_package_roots,
+ _finder_template,
+)
+from setuptools.dist import Distribution
-@pytest.fixture
-def venv(tmp_path, setuptools_wheel):
- env = jaraco.envs.VirtualEnv()
- vars(env).update(
- root=path.Path(tmp_path), # workaround for error on windows
- name=".venv",
- create_opts=["--no-setuptools"],
- req=str(setuptools_wheel),
- )
- return env.create()
+
+@pytest.fixture(params=["strict", "lenient"])
+def editable_opts(request):
+ if request.param == "strict":
+ return ["--config-settings", "editable-mode=strict"]
+ return []
EXAMPLE = {
'pyproject.toml': dedent("""\
[build-system]
- requires = ["setuptools", "wheel"]
+ requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
@@ -51,6 +69,8 @@ EXAMPLE = {
"MANIFEST.in": dedent("""\
global-include *.py *.txt
global-exclude *.py[cod]
+ prune dist
+ prune build
""").strip(),
"README.rst": "This is a ``README``",
"LICENSE.txt": "---- placeholder MIT license ----",
@@ -85,24 +105,23 @@ EXAMPLE = {
SETUP_SCRIPT_STUB = "__import__('setuptools').setup()"
-MISSING_SETUP_SCRIPT = pytest.param(
- None,
- marks=pytest.mark.xfail(
- reason="Editable install is currently only supported with `setup.py`"
- )
-)
-@pytest.mark.parametrize("setup_script", [SETUP_SCRIPT_STUB, MISSING_SETUP_SCRIPT])
-def test_editable_with_pyproject(tmp_path, venv, setup_script):
+@pytest.mark.parametrize(
+ "files",
+ [
+ {**EXAMPLE, "setup.py": SETUP_SCRIPT_STUB}, # type: ignore
+ EXAMPLE, # No setup.py script
+ ]
+)
+def test_editable_with_pyproject(tmp_path, venv, files, editable_opts):
project = tmp_path / "mypkg"
- files = {**EXAMPLE, "setup.py": setup_script}
project.mkdir()
jaraco.path.build(files, prefix=project)
cmd = [venv.exe(), "-m", "pip", "install",
"--no-build-isolation", # required to force current version of setuptools
- "-e", str(project)]
+ "-e", str(project), *editable_opts]
print(str(subprocess.check_output(cmd), "utf-8"))
cmd = [venv.exe(), "-m", "mypkg"]
@@ -111,3 +130,590 @@ def test_editable_with_pyproject(tmp_path, venv, setup_script):
(project / "src/mypkg/data.txt").write_text("foobar")
(project / "src/mypkg/mod.py").write_text("x = 42")
assert subprocess.check_output(cmd).strip() == b"3.14159.post0 foobar 42"
+
+
+def test_editable_with_flat_layout(tmp_path, venv, editable_opts):
+ files = {
+ "mypkg": {
+ "pyproject.toml": dedent("""\
+ [build-system]
+ requires = ["setuptools", "wheel"]
+ build-backend = "setuptools.build_meta"
+
+ [project]
+ name = "mypkg"
+ version = "3.14159"
+
+ [tool.setuptools]
+ packages = ["pkg"]
+ py-modules = ["mod"]
+ """),
+ "pkg": {"__init__.py": "a = 4"},
+ "mod.py": "b = 2",
+ },
+ }
+ jaraco.path.build(files, prefix=tmp_path)
+ project = tmp_path / "mypkg"
+
+ cmd = [venv.exe(), "-m", "pip", "install",
+ "--no-build-isolation", # required to force current version of setuptools
+ "-e", str(project), *editable_opts]
+ print(str(subprocess.check_output(cmd), "utf-8"))
+ cmd = [venv.exe(), "-c", "import pkg, mod; print(pkg.a, mod.b)"]
+ assert subprocess.check_output(cmd).strip() == b"4 2"
+
+
+class TestLegacyNamespaces:
+ """Ported from test_develop"""
+
+ def test_namespace_package_importable(self, venv, tmp_path, editable_opts):
+ """
+ Installing two packages sharing the same namespace, one installed
+ naturally using pip or `--single-version-externally-managed`
+ and the other installed in editable mode should leave the namespace
+ intact and both packages reachable by import.
+ """
+ pkg_A = namespaces.build_namespace_package(tmp_path, 'myns.pkgA')
+ pkg_B = namespaces.build_namespace_package(tmp_path, 'myns.pkgB')
+ # use pip to install to the target directory
+ opts = editable_opts[:]
+ opts.append("--no-build-isolation") # force current version of setuptools
+ venv.run(["python", "-m", "pip", "install", str(pkg_A), *opts])
+ venv.run(["python", "-m", "pip", "install", "-e", str(pkg_B), *opts])
+ venv.run(["python", "-c", "import myns.pkgA; import myns.pkgB"])
+ # additionally ensure that pkg_resources import works
+ venv.run(["python", "-c", "import pkg_resources"])
+
+
+class TestPep420Namespaces:
+ def test_namespace_package_importable(self, venv, tmp_path, editable_opts):
+ """
+ Installing two packages sharing the same namespace, one installed
+ normally using pip and the other installed in editable mode
+ should allow importing both packages.
+ """
+ pkg_A = namespaces.build_pep420_namespace_package(tmp_path, 'myns.n.pkgA')
+ pkg_B = namespaces.build_pep420_namespace_package(tmp_path, 'myns.n.pkgB')
+ # use pip to install to the target directory
+ opts = editable_opts[:]
+ opts.append("--no-build-isolation") # force current version of setuptools
+ venv.run(["python", "-m", "pip", "install", str(pkg_A), *opts])
+ venv.run(["python", "-m", "pip", "install", "-e", str(pkg_B), *opts])
+ venv.run(["python", "-c", "import myns.n.pkgA; import myns.n.pkgB"])
+
+ def test_namespace_created_via_package_dir(self, venv, tmp_path, editable_opts):
+ """Currently users can create a namespace by tweaking `package_dir`"""
+ files = {
+ "pkgA": {
+ "pyproject.toml": dedent("""\
+ [build-system]
+ requires = ["setuptools", "wheel"]
+ build-backend = "setuptools.build_meta"
+
+ [project]
+ name = "pkgA"
+ version = "3.14159"
+
+ [tool.setuptools]
+ package-dir = {"myns.n.pkgA" = "src"}
+ """),
+ "src": {"__init__.py": "a = 1"},
+ },
+ }
+ jaraco.path.build(files, prefix=tmp_path)
+ pkg_A = tmp_path / "pkgA"
+ pkg_B = namespaces.build_pep420_namespace_package(tmp_path, 'myns.n.pkgB')
+ pkg_C = namespaces.build_pep420_namespace_package(tmp_path, 'myns.n.pkgC')
+
+ # use pip to install to the target directory
+ opts = editable_opts[:]
+ opts.append("--no-build-isolation") # force current version of setuptools
+ venv.run(["python", "-m", "pip", "install", str(pkg_A), *opts])
+ venv.run(["python", "-m", "pip", "install", "-e", str(pkg_B), *opts])
+ venv.run(["python", "-m", "pip", "install", "-e", str(pkg_C), *opts])
+ venv.run(["python", "-c", "from myns.n import pkgA, pkgB, pkgC"])
+
+
+# Moved here from test_develop:
+@pytest.mark.xfail(
+ platform.python_implementation() == 'PyPy',
+ reason="Workaround fails on PyPy (why?)",
+)
+def test_editable_with_prefix(tmp_path, sample_project, editable_opts):
+ """
+ Editable install to a prefix should be discoverable.
+ """
+ prefix = tmp_path / 'prefix'
+
+ # figure out where pip will likely install the package
+ site_packages = prefix / next(
+ Path(path).relative_to(sys.prefix)
+ for path in sys.path
+ if 'site-packages' in path and path.startswith(sys.prefix)
+ )
+ site_packages.mkdir(parents=True)
+
+ # install workaround
+ pip_run.launch.inject_sitecustomize(str(site_packages))
+
+ env = dict(os.environ, PYTHONPATH=str(site_packages))
+ cmd = [
+ sys.executable,
+ '-m',
+ 'pip',
+ 'install',
+ '--editable',
+ str(sample_project),
+ '--prefix',
+ str(prefix),
+ '--no-build-isolation',
+ *editable_opts,
+ ]
+ subprocess.check_call(cmd, env=env)
+
+ # now run 'sample' with the prefix on the PYTHONPATH
+ bin = 'Scripts' if platform.system() == 'Windows' else 'bin'
+ exe = prefix / bin / 'sample'
+ if sys.version_info < (3, 8) and platform.system() == 'Windows':
+ exe = str(exe)
+ subprocess.check_call([exe], env=env)
+
+
+class TestFinderTemplate:
+ """This test focus in getting a particular implementation detail right.
+ If at some point in time the implementation is changed for something different,
+ this test can be modified or even excluded.
+ """
+ def install_finder(self, finder):
+ loc = {}
+ exec(finder, loc, loc)
+ loc["install"]()
+
+ def test_packages(self, tmp_path):
+ files = {
+ "src1": {
+ "pkg1": {
+ "__init__.py": "",
+ "subpkg": {"mod1.py": "a = 42"},
+ },
+ },
+ "src2": {"mod2.py": "a = 43"},
+ }
+ jaraco.path.build(files, prefix=tmp_path)
+
+ mapping = {
+ "pkg1": str(tmp_path / "src1/pkg1"),
+ "mod2": str(tmp_path / "src2/mod2")
+ }
+ template = _finder_template(str(uuid4()), mapping, {})
+
+ with contexts.save_paths(), contexts.save_sys_modules():
+ for mod in ("pkg1", "pkg1.subpkg", "pkg1.subpkg.mod1", "mod2"):
+ sys.modules.pop(mod, None)
+
+ self.install_finder(template)
+ mod1 = import_module("pkg1.subpkg.mod1")
+ mod2 = import_module("mod2")
+ subpkg = import_module("pkg1.subpkg")
+
+ assert mod1.a == 42
+ assert mod2.a == 43
+ expected = str((tmp_path / "src1/pkg1/subpkg").resolve())
+ assert_path(subpkg, expected)
+
+ def test_namespace(self, tmp_path):
+ files = {"pkg": {"__init__.py": "a = 13", "text.txt": "abc"}}
+ jaraco.path.build(files, prefix=tmp_path)
+
+ mapping = {"ns.othername": str(tmp_path / "pkg")}
+ namespaces = {"ns": []}
+
+ template = _finder_template(str(uuid4()), mapping, namespaces)
+ with contexts.save_paths(), contexts.save_sys_modules():
+ for mod in ("ns", "ns.othername"):
+ sys.modules.pop(mod, None)
+
+ self.install_finder(template)
+ pkg = import_module("ns.othername")
+ text = importlib_resources.files(pkg) / "text.txt"
+
+ expected = str((tmp_path / "pkg").resolve())
+ assert_path(pkg, expected)
+ assert pkg.a == 13
+
+ # Make sure resources can also be found
+ assert text.read_text(encoding="utf-8") == "abc"
+
+ def test_combine_namespaces(self, tmp_path):
+ files = {
+ "src1": {"ns": {"pkg1": {"__init__.py": "a = 13"}}},
+ "src2": {"ns": {"mod2.py": "b = 37"}},
+ }
+ jaraco.path.build(files, prefix=tmp_path)
+
+ mapping = {
+ "ns.pkgA": str(tmp_path / "src1/ns/pkg1"),
+ "ns": str(tmp_path / "src2/ns"),
+ }
+ namespaces_ = {"ns": [str(tmp_path / "src1"), str(tmp_path / "src2")]}
+ template = _finder_template(str(uuid4()), mapping, namespaces_)
+
+ with contexts.save_paths(), contexts.save_sys_modules():
+ for mod in ("ns", "ns.pkgA", "ns.mod2"):
+ sys.modules.pop(mod, None)
+
+ self.install_finder(template)
+ pkgA = import_module("ns.pkgA")
+ mod2 = import_module("ns.mod2")
+
+ expected = str((tmp_path / "src1/ns/pkg1").resolve())
+ assert_path(pkgA, expected)
+ assert pkgA.a == 13
+ assert mod2.b == 37
+
+ def test_dynamic_path_computation(self, tmp_path):
+ # Follows the example in PEP 420
+ files = {
+ "project1": {"parent": {"child": {"one.py": "x = 1"}}},
+ "project2": {"parent": {"child": {"two.py": "x = 2"}}},
+ "project3": {"parent": {"child": {"three.py": "x = 3"}}},
+ }
+ jaraco.path.build(files, prefix=tmp_path)
+ mapping = {}
+ namespaces_ = {"parent": [str(tmp_path / "project1/parent")]}
+ template = _finder_template(str(uuid4()), mapping, namespaces_)
+
+ mods = (f"parent.child.{name}" for name in ("one", "two", "three"))
+ with contexts.save_paths(), contexts.save_sys_modules():
+ for mod in ("parent", "parent.child", "parent.child", *mods):
+ sys.modules.pop(mod, None)
+
+ self.install_finder(template)
+
+ one = import_module("parent.child.one")
+ assert one.x == 1
+
+ with pytest.raises(ImportError):
+ import_module("parent.child.two")
+
+ sys.path.append(str(tmp_path / "project2"))
+ two = import_module("parent.child.two")
+ assert two.x == 2
+
+ with pytest.raises(ImportError):
+ import_module("parent.child.three")
+
+ sys.path.append(str(tmp_path / "project3"))
+ three = import_module("parent.child.three")
+ assert three.x == 3
+
+
+def test_pkg_roots(tmp_path):
+ """This test focus in getting a particular implementation detail right.
+ If at some point in time the implementation is changed for something different,
+ this test can be modified or even excluded.
+ """
+ files = {
+ "a": {"b": {"__init__.py": "ab = 1"}, "__init__.py": "a = 1"},
+ "d": {"__init__.py": "d = 1", "e": {"__init__.py": "de = 1"}},
+ "f": {"g": {"h": {"__init__.py": "fgh = 1"}}},
+ "other": {"__init__.py": "abc = 1"},
+ "another": {"__init__.py": "abcxyz = 1"},
+ "yet_another": {"__init__.py": "mnopq = 1"},
+ }
+ jaraco.path.build(files, prefix=tmp_path)
+ package_dir = {
+ "a.b.c": "other",
+ "a.b.c.x.y.z": "another",
+ "m.n.o.p.q": "yet_another"
+ }
+ packages = [
+ "a",
+ "a.b",
+ "a.b.c",
+ "a.b.c.x.y",
+ "a.b.c.x.y.z",
+ "d",
+ "d.e",
+ "f",
+ "f.g",
+ "f.g.h",
+ "m.n.o.p.q",
+ ]
+ roots = _find_package_roots(packages, package_dir, tmp_path)
+ assert roots == {
+ "a": str(tmp_path / "a"),
+ "a.b.c": str(tmp_path / "other"),
+ "a.b.c.x.y.z": str(tmp_path / "another"),
+ "d": str(tmp_path / "d"),
+ "f": str(tmp_path / "f"),
+ "m.n.o.p.q": str(tmp_path / "yet_another"),
+ }
+
+ ns = set(dict(_find_namespaces(packages, roots)))
+ assert ns == {"f", "f.g"}
+
+ ns = set(_find_virtual_namespaces(roots))
+ assert ns == {"a.b.c.x", "a.b.c.x.y", "m", "m.n", "m.n.o", "m.n.o.p"}
+
+
+class TestOverallBehaviour:
+ PYPROJECT = """\
+ [build-system]
+ requires = ["setuptools"]
+ build-backend = "setuptools.build_meta"
+
+ [project]
+ name = "mypkg"
+ version = "3.14159"
+ """
+
+ FLAT_LAYOUT = {
+ "pyproject.toml": dedent(PYPROJECT),
+ "MANIFEST.in": EXAMPLE["MANIFEST.in"],
+ "otherfile.py": "",
+ "mypkg": {
+ "__init__.py": "",
+ "mod1.py": "var = 42",
+ "subpackage": {
+ "__init__.py": "",
+ "mod2.py": "var = 13",
+ "resource_file.txt": "resource 39",
+ },
+ },
+ }
+
+ EXAMPLES = {
+ "flat-layout": FLAT_LAYOUT,
+ "src-layout": {
+ "pyproject.toml": dedent(PYPROJECT),
+ "MANIFEST.in": EXAMPLE["MANIFEST.in"],
+ "otherfile.py": "",
+ "src": {"mypkg": FLAT_LAYOUT["mypkg"]},
+ },
+ "custom-layout": {
+ "pyproject.toml": dedent(PYPROJECT) + dedent("""\
+ [tool.setuptools]
+ packages = ["mypkg", "mypkg.subpackage"]
+
+ [tool.setuptools.package-dir]
+ "mypkg.subpackage" = "other"
+ """),
+ "MANIFEST.in": EXAMPLE["MANIFEST.in"],
+ "otherfile.py": "",
+ "mypkg": {
+ "__init__.py": "",
+ "mod1.py": FLAT_LAYOUT["mypkg"]["mod1.py"], # type: ignore
+ },
+ "other": FLAT_LAYOUT["mypkg"]["subpackage"], # type: ignore
+ },
+ "namespace": {
+ "pyproject.toml": dedent(PYPROJECT),
+ "MANIFEST.in": EXAMPLE["MANIFEST.in"],
+ "otherfile.py": "",
+ "src": {
+ "mypkg": {
+ "mod1.py": FLAT_LAYOUT["mypkg"]["mod1.py"], # type: ignore
+ "subpackage": FLAT_LAYOUT["mypkg"]["subpackage"], # type: ignore
+ },
+ },
+ },
+ }
+
+ @pytest.mark.parametrize("layout", EXAMPLES.keys())
+ def test_editable_install(self, tmp_path, venv, layout, editable_opts):
+ opts = editable_opts
+ project = install_project("mypkg", venv, tmp_path, self.EXAMPLES[layout], *opts)
+
+ # Ensure stray files are not importable
+ cmd_import_error = """\
+ try:
+ import otherfile
+ except ImportError as ex:
+ print(ex)
+ """
+ out = venv.run(["python", "-c", dedent(cmd_import_error)])
+ assert b"No module named 'otherfile'" in out
+
+ # Ensure the modules are importable
+ cmd_get_vars = """\
+ import mypkg, mypkg.mod1, mypkg.subpackage.mod2
+ print(mypkg.mod1.var, mypkg.subpackage.mod2.var)
+ """
+ out = venv.run(["python", "-c", dedent(cmd_get_vars)])
+ assert b"42 13" in out
+
+ # Ensure resources are reachable
+ cmd_get_resource = """\
+ import mypkg.subpackage
+ from setuptools._importlib import resources as importlib_resources
+ text = importlib_resources.files(mypkg.subpackage) / "resource_file.txt"
+ print(text.read_text(encoding="utf-8"))
+ """
+ out = venv.run(["python", "-c", dedent(cmd_get_resource)])
+ assert b"resource 39" in out
+
+ # Ensure files are editable
+ mod1 = next(project.glob("**/mod1.py"))
+ mod2 = next(project.glob("**/mod2.py"))
+ resource_file = next(project.glob("**/resource_file.txt"))
+
+ mod1.write_text("var = 17", encoding="utf-8")
+ mod2.write_text("var = 781", encoding="utf-8")
+ resource_file.write_text("resource 374", encoding="utf-8")
+
+ out = venv.run(["python", "-c", dedent(cmd_get_vars)])
+ assert b"42 13" not in out
+ assert b"17 781" in out
+
+ out = venv.run(["python", "-c", dedent(cmd_get_resource)])
+ assert b"resource 39" not in out
+ assert b"resource 374" in out
+
+
+class TestLinkTree:
+ FILES = deepcopy(TestOverallBehaviour.EXAMPLES["src-layout"])
+ FILES["pyproject.toml"] += dedent("""\
+ [tool.setuptools]
+ # Temporary workaround: both `include-package-data` and `package-data` configs
+ # can be removed after #3260 is fixed.
+ include-package-data = false
+ package-data = {"*" = ["*.txt"]}
+
+ [tool.setuptools.packages.find]
+ where = ["src"]
+ exclude = ["*.subpackage*"]
+ """)
+ FILES["src"]["mypkg"]["resource.not_in_manifest"] = "abc"
+
+ def test_generated_tree(self, tmp_path):
+ jaraco.path.build(self.FILES, prefix=tmp_path)
+
+ with _Path(tmp_path):
+ name = "mypkg-3.14159"
+ dist = Distribution({"script_name": "%PEP 517%"})
+ dist.parse_config_files()
+
+ wheel = Mock()
+ aux = tmp_path / ".aux"
+ build = tmp_path / ".build"
+ aux.mkdir()
+ build.mkdir()
+
+ build_py = dist.get_command_obj("build_py")
+ build_py.editable_mode = True
+ build_py.build_lib = str(build)
+ build_py.ensure_finalized()
+ outputs = build_py.get_outputs()
+ output_mapping = build_py.get_output_mapping()
+
+ make_tree = _LinkTree(dist, name, aux, build)
+ make_tree(wheel, outputs, output_mapping)
+
+ mod1 = next(aux.glob("**/mod1.py"))
+ expected = tmp_path / "src/mypkg/mod1.py"
+ assert_link_to(mod1, expected)
+
+ assert next(aux.glob("**/subpackage"), None) is None
+ assert next(aux.glob("**/mod2.py"), None) is None
+ assert next(aux.glob("**/resource_file.txt"), None) is None
+
+ assert next(aux.glob("**/resource.not_in_manifest"), None) is None
+
+ def test_strict_install(self, tmp_path, venv):
+ opts = ["--config-settings", "editable-mode=strict"]
+ install_project("mypkg", venv, tmp_path, self.FILES, *opts)
+
+ out = venv.run(["python", "-c", "import mypkg.mod1; print(mypkg.mod1.var)"])
+ assert b"42" in out
+
+ # Ensure packages excluded from distribution are not importable
+ cmd_import_error = """\
+ try:
+ from mypkg import subpackage
+ except ImportError as ex:
+ print(ex)
+ """
+ out = venv.run(["python", "-c", dedent(cmd_import_error)])
+ assert b"cannot import name 'subpackage'" in out
+
+ # Ensure resource files excluded from distribution are not reachable
+ cmd_get_resource = """\
+ import mypkg
+ from setuptools._importlib import resources as importlib_resources
+ try:
+ text = importlib_resources.files(mypkg) / "resource.not_in_manifest"
+ print(text.read_text(encoding="utf-8"))
+ except FileNotFoundError as ex:
+ print(ex)
+ """
+ out = venv.run(["python", "-c", dedent(cmd_get_resource)])
+ assert b"No such file or directory" in out
+ assert b"resource.not_in_manifest" in out
+
+
+@pytest.mark.filterwarnings("ignore:.*compat.*:setuptools.SetuptoolsDeprecationWarning")
+def test_compat_install(tmp_path, venv):
+ # TODO: Remove `compat` after Dec/2022.
+ opts = ["--config-settings", "editable-mode=compat"]
+ files = TestOverallBehaviour.EXAMPLES["custom-layout"]
+ install_project("mypkg", venv, tmp_path, files, *opts)
+
+ out = venv.run(["python", "-c", "import mypkg.mod1; print(mypkg.mod1.var)"])
+ assert b"42" in out
+
+ expected_path = comparable_path(str(tmp_path))
+
+ # Compatible behaviour will make spurious modules and excluded
+ # files importable directly from the original path
+ for cmd in (
+ "import otherfile; print(otherfile)",
+ "import other; print(other)",
+ "import mypkg; print(mypkg)",
+ ):
+ out = comparable_path(str(venv.run(["python", "-c", cmd]), "utf-8"))
+ assert expected_path in out
+
+ # Compatible behaviour will not consider custom mappings
+ cmd = """\
+ try:
+ from mypkg import subpackage;
+ except ImportError as ex:
+ print(ex)
+ """
+ out = str(venv.run(["python", "-c", dedent(cmd)]), "utf-8")
+ assert "cannot import name 'subpackage'" in out
+
+
+def install_project(name, venv, tmp_path, files, *opts):
+ project = tmp_path / name
+ project.mkdir()
+ jaraco.path.build(files, prefix=project)
+ opts = [*opts, "--no-build-isolation"] # force current version of setuptools
+ venv.run(["python", "-m", "pip", "install", "-e", str(project), *opts])
+ return project
+
+
+# ---- Assertion Helpers ----
+
+
+def assert_path(pkg, expected):
+ # __path__ is not guaranteed to exist, so we have to account for that
+ if pkg.__path__:
+ path = next(iter(pkg.__path__), None)
+ if path:
+ assert str(Path(path).resolve()) == expected
+
+
+def assert_link_to(file: Path, other: Path):
+ if file.is_symlink():
+ assert str(file.resolve()) == str(other.resolve())
+ else:
+ file_stat = file.stat()
+ other_stat = other.stat()
+ assert file_stat[stat.ST_INO] == other_stat[stat.ST_INO]
+ assert file_stat[stat.ST_DEV] == other_stat[stat.ST_DEV]
+
+
+def comparable_path(str_with_path: str) -> str:
+ return str_with_path.lower().replace(os.sep, "/").replace("//", "/")
diff --git a/setuptools/tests/test_sdist.py b/setuptools/tests/test_sdist.py
index 302cff73..4b0d2e17 100644
--- a/setuptools/tests/test_sdist.py
+++ b/setuptools/tests/test_sdist.py
@@ -10,6 +10,7 @@ from unittest import mock
import pytest
+from setuptools import Command
from setuptools._importlib import metadata
from setuptools import SetuptoolsDeprecationWarning
from setuptools.command.sdist import sdist
@@ -517,6 +518,46 @@ class TestSdistTest:
manifest = cmd.filelist.files
assert 'pyproject.toml' not in manifest
+ def test_build_subcommand_source_files(self, tmpdir):
+ touch(tmpdir / '.myfile~')
+
+ # Sanity check: without custom commands file list should not be affected
+ dist = Distribution({**SETUP_ATTRS, "script_name": "setup.py"})
+ cmd = sdist(dist)
+ cmd.ensure_finalized()
+ with quiet():
+ cmd.run()
+ manifest = cmd.filelist.files
+ assert '.myfile~' not in manifest
+
+ # Test: custom command should be able to augment file list
+ dist = Distribution({**SETUP_ATTRS, "script_name": "setup.py"})
+ build = dist.get_command_obj("build")
+ build.sub_commands = [*build.sub_commands, ("build_custom", None)]
+
+ class build_custom(Command):
+ def initialize_options(self):
+ ...
+
+ def finalize_options(self):
+ ...
+
+ def run(self):
+ ...
+
+ def get_source_files(self):
+ return ['.myfile~']
+
+ dist.cmdclass.update(build_custom=build_custom)
+
+ cmd = sdist(dist)
+ cmd.use_defaults = True
+ cmd.ensure_finalized()
+ with quiet():
+ cmd.run()
+ manifest = cmd.filelist.files
+ assert '.myfile~' in manifest
+
def test_default_revctrl():
"""