aboutsummaryrefslogtreecommitdiff
path: root/.venv/lib/python3.12/site-packages/setuptools/tests/test_editable_install.py
diff options
context:
space:
mode:
Diffstat (limited to '.venv/lib/python3.12/site-packages/setuptools/tests/test_editable_install.py')
-rw-r--r--.venv/lib/python3.12/site-packages/setuptools/tests/test_editable_install.py1289
1 files changed, 1289 insertions, 0 deletions
diff --git a/.venv/lib/python3.12/site-packages/setuptools/tests/test_editable_install.py b/.venv/lib/python3.12/site-packages/setuptools/tests/test_editable_install.py
new file mode 100644
index 00000000..038dcadf
--- /dev/null
+++ b/.venv/lib/python3.12/site-packages/setuptools/tests/test_editable_install.py
@@ -0,0 +1,1289 @@
+from __future__ import annotations
+
+import os
+import platform
+import stat
+import subprocess
+import sys
+from copy import deepcopy
+from importlib import import_module
+from importlib.machinery import EXTENSION_SUFFIXES
+from pathlib import Path
+from textwrap import dedent
+from typing import Any
+from unittest.mock import Mock
+from uuid import uuid4
+
+import jaraco.envs
+import jaraco.path
+import pytest
+from path import Path as _Path
+
+from setuptools._importlib import resources as importlib_resources
+from setuptools.command.editable_wheel import (
+ _DebuggingTips,
+ _encode_pth,
+ _find_namespaces,
+ _find_package_roots,
+ _find_virtual_namespaces,
+ _finder_template,
+ _LinkTree,
+ _TopLevelFinder,
+ editable_wheel,
+)
+from setuptools.dist import Distribution
+from setuptools.extension import Extension
+from setuptools.warnings import SetuptoolsDeprecationWarning
+
+from . import contexts, namespaces
+
+from distutils.core import run_setup
+
+
+@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"]
+ build-backend = "setuptools.build_meta"
+
+ [project]
+ name = "mypkg"
+ version = "3.14159"
+ license = {text = "MIT"}
+ description = "This is a Python package"
+ dynamic = ["readme"]
+ classifiers = [
+ "Development Status :: 5 - Production/Stable",
+ "Intended Audience :: Developers"
+ ]
+ urls = {Homepage = "https://github.com"}
+
+ [tool.setuptools]
+ package-dir = {"" = "src"}
+ packages = {find = {where = ["src"]}}
+ license-files = ["LICENSE*"]
+
+ [tool.setuptools.dynamic]
+ readme = {file = "README.rst"}
+
+ [tool.distutils.egg_info]
+ tag-build = ".post0"
+ """
+ ),
+ "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 ----",
+ "src": {
+ "mypkg": {
+ "__init__.py": dedent(
+ """\
+ import sys
+ from importlib.metadata import PackageNotFoundError, version
+
+ try:
+ __version__ = version(__name__)
+ except PackageNotFoundError:
+ __version__ = "unknown"
+ """
+ ),
+ "__main__.py": dedent(
+ """\
+ from importlib.resources import read_text
+ from . import __version__, __name__ as parent
+ from .mod import x
+
+ data = read_text(parent, "data.txt")
+ print(__version__, data, x)
+ """
+ ),
+ "mod.py": "x = ''",
+ "data.txt": "Hello World",
+ }
+ },
+}
+
+
+SETUP_SCRIPT_STUB = "__import__('setuptools').setup()"
+
+
+@pytest.mark.xfail(sys.platform == "darwin", reason="pypa/setuptools#4328")
+@pytest.mark.parametrize(
+ "files",
+ [
+ {**EXAMPLE, "setup.py": SETUP_SCRIPT_STUB},
+ EXAMPLE, # No setup.py script
+ ],
+)
+def test_editable_with_pyproject(tmp_path, venv, files, editable_opts):
+ project = tmp_path / "mypkg"
+ project.mkdir()
+ jaraco.path.build(files, prefix=project)
+
+ cmd = [
+ "python",
+ "-m",
+ "pip",
+ "install",
+ "--no-build-isolation", # required to force current version of setuptools
+ "-e",
+ str(project),
+ *editable_opts,
+ ]
+ print(venv.run(cmd))
+
+ cmd = ["python", "-m", "mypkg"]
+ assert venv.run(cmd).strip() == "3.14159.post0 Hello World"
+
+ (project / "src/mypkg/data.txt").write_text("foobar", encoding="utf-8")
+ (project / "src/mypkg/mod.py").write_text("x = 42", encoding="utf-8")
+ assert venv.run(cmd).strip() == "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 = [
+ "python",
+ "-m",
+ "pip",
+ "install",
+ "--no-build-isolation", # required to force current version of setuptools
+ "-e",
+ str(project),
+ *editable_opts,
+ ]
+ print(venv.run(cmd))
+ cmd = ["python", "-c", "import pkg, mod; print(pkg.a, mod.b)"]
+ assert venv.run(cmd).strip() == "4 2"
+
+
+def test_editable_with_single_module(tmp_path, venv, editable_opts):
+ files = {
+ "mypkg": {
+ "pyproject.toml": dedent(
+ """\
+ [build-system]
+ requires = ["setuptools", "wheel"]
+ build-backend = "setuptools.build_meta"
+
+ [project]
+ name = "mod"
+ version = "3.14159"
+
+ [tool.setuptools]
+ py-modules = ["mod"]
+ """
+ ),
+ "mod.py": "b = 2",
+ },
+ }
+ jaraco.path.build(files, prefix=tmp_path)
+ project = tmp_path / "mypkg"
+
+ cmd = [
+ "python",
+ "-m",
+ "pip",
+ "install",
+ "--no-build-isolation", # required to force current version of setuptools
+ "-e",
+ str(project),
+ *editable_opts,
+ ]
+ print(venv.run(cmd))
+ cmd = ["python", "-c", "import mod; print(mod.b)"]
+ assert venv.run(cmd).strip() == "2"
+
+
+class TestLegacyNamespaces:
+ # legacy => pkg_resources.declare_namespace(...) + setup(namespace_packages=...)
+
+ def test_nspkg_file_is_unique(self, tmp_path, monkeypatch):
+ deprecation = pytest.warns(
+ SetuptoolsDeprecationWarning, match=".*namespace_packages parameter.*"
+ )
+ installation_dir = tmp_path / ".installation_dir"
+ installation_dir.mkdir()
+ examples = (
+ "myns.pkgA",
+ "myns.pkgB",
+ "myns.n.pkgA",
+ "myns.n.pkgB",
+ )
+
+ for name in examples:
+ pkg = namespaces.build_namespace_package(tmp_path, name, version="42")
+ with deprecation, monkeypatch.context() as ctx:
+ ctx.chdir(pkg)
+ dist = run_setup("setup.py", stop_after="config")
+ cmd = editable_wheel(dist)
+ cmd.finalize_options()
+ editable_name = cmd.get_finalized_command("dist_info").name
+ cmd._install_namespaces(installation_dir, editable_name)
+
+ files = list(installation_dir.glob("*-nspkg.pth"))
+ assert len(files) == len(examples)
+
+ @pytest.mark.parametrize(
+ "impl",
+ (
+ "pkg_resources",
+ # "pkgutil", => does not work
+ ),
+ )
+ @pytest.mark.parametrize("ns", ("myns.n",))
+ def test_namespace_package_importable(
+ self, venv, tmp_path, ns, impl, 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.
+ (Ported from test_develop).
+ """
+ build_system = """\
+ [build-system]
+ requires = ["setuptools"]
+ build-backend = "setuptools.build_meta"
+ """
+ pkg_A = namespaces.build_namespace_package(tmp_path, f"{ns}.pkgA", impl=impl)
+ pkg_B = namespaces.build_namespace_package(tmp_path, f"{ns}.pkgB", impl=impl)
+ (pkg_A / "pyproject.toml").write_text(build_system, encoding="utf-8")
+ (pkg_B / "pyproject.toml").write_text(build_system, encoding="utf-8")
+ # 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", f"import {ns}.pkgA; import {ns}.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"])
+
+ def test_namespace_accidental_config_in_lenient_mode(self, venv, tmp_path):
+ """Sometimes users might specify an ``include`` pattern that ignores parent
+ packages. In a normal installation this would ignore all modules inside the
+ parent packages, and make them namespaces (reported in issue #3504),
+ so the editable mode should preserve this behaviour.
+ """
+ files = {
+ "pkgA": {
+ "pyproject.toml": dedent(
+ """\
+ [build-system]
+ requires = ["setuptools", "wheel"]
+ build-backend = "setuptools.build_meta"
+
+ [project]
+ name = "pkgA"
+ version = "3.14159"
+
+ [tool.setuptools]
+ packages.find.include = ["mypkg.*"]
+ """
+ ),
+ "mypkg": {
+ "__init__.py": "",
+ "other.py": "b = 1",
+ "n": {
+ "__init__.py": "",
+ "pkgA.py": "a = 1",
+ },
+ },
+ "MANIFEST.in": EXAMPLE["MANIFEST.in"],
+ },
+ }
+ jaraco.path.build(files, prefix=tmp_path)
+ pkg_A = tmp_path / "pkgA"
+
+ # use pip to install to the target directory
+ opts = ["--no-build-isolation"] # force current version of setuptools
+ venv.run(["python", "-m", "pip", "-v", "install", "-e", str(pkg_A), *opts])
+ out = venv.run(["python", "-c", "from mypkg.n import pkgA; print(pkgA.a)"])
+ assert out.strip() == "1"
+ cmd = """\
+ try:
+ import mypkg.other
+ except ImportError:
+ print("mypkg.other not defined")
+ """
+ out = venv.run(["python", "-c", dedent(cmd)])
+ assert "mypkg.other not defined" in out
+
+
+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_all = [
+ prefix / Path(path).relative_to(sys.prefix)
+ for path in sys.path
+ if 'site-packages' in path and path.startswith(sys.prefix)
+ ]
+
+ for sp in site_packages_all:
+ sp.mkdir(parents=True)
+
+ # install workaround
+ _addsitedirs(site_packages_all)
+
+ env = dict(os.environ, PYTHONPATH=os.pathsep.join(map(str, site_packages_all)))
+ 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'
+ 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_combine_namespaces_nested(self, tmp_path):
+ """
+ Users may attempt to combine namespace packages in a nested way via
+ ``package_dir`` as shown in pypa/setuptools#4248.
+ """
+
+ files = {
+ "src": {"my_package": {"my_module.py": "a = 13"}},
+ "src2": {"my_package2": {"my_module2.py": "b = 37"}},
+ }
+
+ stack = jaraco.path.DirectoryStack()
+ with stack.context(tmp_path):
+ jaraco.path.build(files)
+ attrs = {
+ "script_name": "%PEP 517%",
+ "package_dir": {
+ "different_name": "src/my_package",
+ "different_name.subpkg": "src2/my_package2",
+ },
+ "packages": ["different_name", "different_name.subpkg"],
+ }
+ dist = Distribution(attrs)
+ finder = _TopLevelFinder(dist, str(uuid4()))
+ code = next(v for k, v in finder.get_implementation() if k.endswith(".py"))
+
+ with contexts.save_paths(), contexts.save_sys_modules():
+ for mod in attrs["packages"]:
+ sys.modules.pop(mod, None)
+
+ self.install_finder(code)
+ mod1 = import_module("different_name.my_module")
+ mod2 = import_module("different_name.subpkg.my_module2")
+
+ expected = str((tmp_path / "src/my_package/my_module.py").resolve())
+ assert str(Path(mod1.__file__).resolve()) == expected
+
+ expected = str((tmp_path / "src2/my_package2/my_module2.py").resolve())
+ assert str(Path(mod2.__file__).resolve()) == expected
+
+ assert mod1.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_no_recursion(self, tmp_path):
+ # See issue #3550
+ files = {
+ "pkg": {
+ "__init__.py": "from . import pkg",
+ },
+ }
+ jaraco.path.build(files, prefix=tmp_path)
+
+ mapping = {
+ "pkg": str(tmp_path / "pkg"),
+ }
+ template = _finder_template(str(uuid4()), mapping, {})
+
+ with contexts.save_paths(), contexts.save_sys_modules():
+ sys.modules.pop("pkg", None)
+
+ self.install_finder(template)
+ with pytest.raises(ImportError, match="pkg"):
+ import_module("pkg")
+
+ def test_similar_name(self, tmp_path):
+ files = {
+ "foo": {
+ "__init__.py": "",
+ "bar": {
+ "__init__.py": "",
+ },
+ },
+ }
+ jaraco.path.build(files, prefix=tmp_path)
+
+ mapping = {
+ "foo": str(tmp_path / "foo"),
+ }
+ template = _finder_template(str(uuid4()), mapping, {})
+
+ with contexts.save_paths(), contexts.save_sys_modules():
+ sys.modules.pop("foo", None)
+ sys.modules.pop("foo.bar", None)
+
+ self.install_finder(template)
+ with pytest.raises(ImportError, match="foobar"):
+ import_module("foobar")
+
+ def test_case_sensitivity(self, tmp_path):
+ files = {
+ "foo": {
+ "__init__.py": "",
+ "lowercase.py": "x = 1",
+ "bar": {
+ "__init__.py": "",
+ "lowercase.py": "x = 2",
+ },
+ },
+ }
+ jaraco.path.build(files, prefix=tmp_path)
+ mapping = {
+ "foo": str(tmp_path / "foo"),
+ }
+ template = _finder_template(str(uuid4()), mapping, {})
+ with contexts.save_paths(), contexts.save_sys_modules():
+ sys.modules.pop("foo", None)
+
+ self.install_finder(template)
+ with pytest.raises(ImportError, match="'FOO'"):
+ import_module("FOO")
+
+ with pytest.raises(ImportError, match="'foo\\.LOWERCASE'"):
+ import_module("foo.LOWERCASE")
+
+ with pytest.raises(ImportError, match="'foo\\.bar\\.Lowercase'"):
+ import_module("foo.bar.Lowercase")
+
+ with pytest.raises(ImportError, match="'foo\\.BAR'"):
+ import_module("foo.BAR.lowercase")
+
+ with pytest.raises(ImportError, match="'FOO'"):
+ import_module("FOO.bar.lowercase")
+
+ mod = import_module("foo.lowercase")
+ assert mod.x == 1
+
+ mod = import_module("foo.bar.lowercase")
+ assert mod.x == 2
+
+ def test_namespace_case_sensitivity(self, tmp_path):
+ files = {
+ "pkg": {
+ "__init__.py": "a = 13",
+ "foo": {
+ "__init__.py": "b = 37",
+ "bar.py": "c = 42",
+ },
+ },
+ }
+ 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")
+ expected = str((tmp_path / "pkg").resolve())
+ assert_path(pkg, expected)
+ assert pkg.a == 13
+
+ foo = import_module("ns.othername.foo")
+ assert foo.b == 37
+
+ bar = import_module("ns.othername.foo.bar")
+ assert bar.c == 42
+
+ with pytest.raises(ImportError, match="'NS'"):
+ import_module("NS.othername.foo")
+
+ with pytest.raises(ImportError, match="'ns\\.othername\\.FOO\\'"):
+ import_module("ns.othername.FOO")
+
+ with pytest.raises(ImportError, match="'ns\\.othername\\.foo\\.BAR\\'"):
+ import_module("ns.othername.foo.BAR")
+
+ def test_intermediate_packages(self, tmp_path):
+ """
+ The finder should not import ``fullname`` if the intermediate segments
+ don't exist (see pypa/setuptools#4019).
+ """
+ files = {
+ "src": {
+ "mypkg": {
+ "__init__.py": "",
+ "config.py": "a = 13",
+ "helloworld.py": "b = 13",
+ "components": {
+ "config.py": "a = 37",
+ },
+ },
+ }
+ }
+ jaraco.path.build(files, prefix=tmp_path)
+
+ mapping = {"mypkg": str(tmp_path / "src/mypkg")}
+ template = _finder_template(str(uuid4()), mapping, {})
+
+ with contexts.save_paths(), contexts.save_sys_modules():
+ for mod in (
+ "mypkg",
+ "mypkg.config",
+ "mypkg.helloworld",
+ "mypkg.components",
+ "mypkg.components.config",
+ "mypkg.components.helloworld",
+ ):
+ sys.modules.pop(mod, None)
+
+ self.install_finder(template)
+
+ config = import_module("mypkg.components.config")
+ assert config.a == 37
+
+ helloworld = import_module("mypkg.helloworld")
+ assert helloworld.b == 13
+
+ with pytest.raises(ImportError):
+ import_module("mypkg.components.helloworld")
+
+
+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", "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"
+ """
+
+ # Any: Would need a TypedDict. Keep it simple for tests
+ FLAT_LAYOUT: dict[str, Any] = {
+ "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"],
+ },
+ "other": FLAT_LAYOUT["mypkg"]["subpackage"],
+ },
+ "namespace": {
+ "pyproject.toml": dedent(PYPROJECT),
+ "MANIFEST.in": EXAMPLE["MANIFEST.in"],
+ "otherfile.py": "",
+ "src": {
+ "mypkg": {
+ "mod1.py": FLAT_LAYOUT["mypkg"]["mod1.py"],
+ "subpackage": FLAT_LAYOUT["mypkg"]["subpackage"],
+ },
+ },
+ },
+ }
+
+ @pytest.mark.xfail(sys.platform == "darwin", reason="pypa/setuptools#4328")
+ @pytest.mark.parametrize("layout", EXAMPLES.keys())
+ def test_editable_install(self, tmp_path, venv, layout, editable_opts):
+ project, _ = install_project(
+ "mypkg", venv, tmp_path, self.EXAMPLES[layout], *editable_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 "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 "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 "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 "42 13" not in out
+ assert "17 781" in out
+
+ out = venv.run(["python", "-c", dedent(cmd_get_resource)])
+ assert "resource 39" not in out
+ assert "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 "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 "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 "No such file or directory" in out
+ assert "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 "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(venv.run(["python", "-c", cmd]))
+ 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 = venv.run(["python", "-c", dedent(cmd)])
+ assert "cannot import name 'subpackage'" in out
+
+
+def test_pbr_integration(tmp_path, venv, editable_opts):
+ """Ensure editable installs work with pbr, issue #3500"""
+ files = {
+ "pyproject.toml": dedent(
+ """\
+ [build-system]
+ requires = ["setuptools"]
+ build-backend = "setuptools.build_meta"
+ """
+ ),
+ "setup.py": dedent(
+ """\
+ __import__('setuptools').setup(
+ pbr=True,
+ setup_requires=["pbr"],
+ )
+ """
+ ),
+ "setup.cfg": dedent(
+ """\
+ [metadata]
+ name = mypkg
+
+ [files]
+ packages =
+ mypkg
+ """
+ ),
+ "mypkg": {
+ "__init__.py": "",
+ "hello.py": "print('Hello world!')",
+ },
+ "other": {"test.txt": "Another file in here."},
+ }
+ venv.run(["python", "-m", "pip", "install", "pbr"])
+
+ with contexts.environment(PBR_VERSION="0.42"):
+ install_project("mypkg", venv, tmp_path, files, *editable_opts)
+
+ out = venv.run(["python", "-c", "import mypkg.hello"])
+ assert "Hello world!" in out
+
+
+class TestCustomBuildPy:
+ """
+ Issue #3501 indicates that some plugins/customizations might rely on:
+
+ 1. ``build_py`` not running
+ 2. ``build_py`` always copying files to ``build_lib``
+
+ During the transition period setuptools should prevent potential errors from
+ happening due to those assumptions.
+ """
+
+ # TODO: Remove tests after _run_build_steps is removed.
+
+ FILES = {
+ **TestOverallBehaviour.EXAMPLES["flat-layout"],
+ "setup.py": dedent(
+ """\
+ import pathlib
+ from setuptools import setup
+ from setuptools.command.build_py import build_py as orig
+
+ class my_build_py(orig):
+ def run(self):
+ super().run()
+ raise ValueError("TEST_RAISE")
+
+ setup(cmdclass={"build_py": my_build_py})
+ """
+ ),
+ }
+
+ def test_safeguarded_from_errors(self, tmp_path, venv):
+ """Ensure that errors in custom build_py are reported as warnings"""
+ # Warnings should show up
+ _, out = install_project("mypkg", venv, tmp_path, self.FILES)
+ assert "SetuptoolsDeprecationWarning" in out
+ assert "ValueError: TEST_RAISE" in out
+ # but installation should be successful
+ out = venv.run(["python", "-c", "import mypkg.mod1; print(mypkg.mod1.var)"])
+ assert "42" in out
+
+
+class TestCustomBuildWheel:
+ def install_custom_build_wheel(self, dist):
+ bdist_wheel_cls = dist.get_command_class("bdist_wheel")
+
+ class MyBdistWheel(bdist_wheel_cls):
+ def get_tag(self):
+ # In issue #3513, we can see that some extensions may try to access
+ # the `plat_name` property in bdist_wheel
+ if self.plat_name.startswith("macosx-"):
+ _ = "macOS platform"
+ return super().get_tag()
+
+ dist.cmdclass["bdist_wheel"] = MyBdistWheel
+
+ def test_access_plat_name(self, tmpdir_cwd):
+ # Even when a custom bdist_wheel tries to access plat_name the build should
+ # be successful
+ jaraco.path.build({"module.py": "x = 42"})
+ dist = Distribution()
+ dist.script_name = "setup.py"
+ dist.set_defaults()
+ self.install_custom_build_wheel(dist)
+ cmd = editable_wheel(dist)
+ cmd.ensure_finalized()
+ cmd.run()
+ wheel_file = str(next(Path().glob('dist/*.whl')))
+ assert "editable" in wheel_file
+
+
+class TestCustomBuildExt:
+ def install_custom_build_ext_distutils(self, dist):
+ from distutils.command.build_ext import build_ext as build_ext_cls
+
+ class MyBuildExt(build_ext_cls):
+ pass
+
+ dist.cmdclass["build_ext"] = MyBuildExt
+
+ @pytest.mark.skipif(
+ sys.platform != "linux", reason="compilers may fail without correct setup"
+ )
+ def test_distutils_leave_inplace_files(self, tmpdir_cwd):
+ jaraco.path.build({"module.c": ""})
+ attrs = {
+ "ext_modules": [Extension("module", ["module.c"])],
+ }
+ dist = Distribution(attrs)
+ dist.script_name = "setup.py"
+ dist.set_defaults()
+ self.install_custom_build_ext_distutils(dist)
+ cmd = editable_wheel(dist)
+ cmd.ensure_finalized()
+ cmd.run()
+ wheel_file = str(next(Path().glob('dist/*.whl')))
+ assert "editable" in wheel_file
+ files = [p for p in Path().glob("module.*") if p.suffix != ".c"]
+ assert len(files) == 1
+ name = files[0].name
+ assert any(name.endswith(ext) for ext in EXTENSION_SUFFIXES)
+
+
+def test_debugging_tips(tmpdir_cwd, monkeypatch):
+ """Make sure to display useful debugging tips to the user."""
+ jaraco.path.build({"module.py": "x = 42"})
+ dist = Distribution()
+ dist.script_name = "setup.py"
+ dist.set_defaults()
+ cmd = editable_wheel(dist)
+ cmd.ensure_finalized()
+
+ SimulatedErr = type("SimulatedErr", (Exception,), {})
+ simulated_failure = Mock(side_effect=SimulatedErr())
+ monkeypatch.setattr(cmd, "get_finalized_command", simulated_failure)
+
+ expected_msg = "following steps are recommended to help debug"
+ with pytest.raises(SimulatedErr), pytest.warns(_DebuggingTips, match=expected_msg):
+ cmd.run()
+
+
+@pytest.mark.filterwarnings("error")
+def test_encode_pth():
+ """Ensure _encode_pth function does not produce encoding warnings"""
+ content = _encode_pth("tkmilan_รง_utf8") # no warnings (would be turned into errors)
+ assert isinstance(content, bytes)
+
+
+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
+ out = venv.run(
+ ["python", "-m", "pip", "-v", "install", "-e", str(project), *opts],
+ stderr=subprocess.STDOUT,
+ )
+ return project, out
+
+
+def _addsitedirs(new_dirs):
+ """To use this function, it is necessary to insert new_dir in front of sys.path.
+ The Python process will try to import a ``sitecustomize`` module on startup.
+ If we manipulate sys.path/PYTHONPATH, we can force it to run our code,
+ which invokes ``addsitedir`` and ensure ``.pth`` files are loaded.
+ """
+ content = '\n'.join(
+ ("import site",)
+ + tuple(f"site.addsitedir({os.fspath(new_dir)!r})" for new_dir in new_dirs)
+ )
+ (new_dirs[0] / "sitecustomize.py").write_text(content, encoding="utf-8")
+
+
+# ---- 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) -> None:
+ 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("//", "/")