Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Fix *-nspkg.pth files and imports for pkg_resources-style legacy namespaces in editable installs. #4041

Merged
merged 11 commits into from
Sep 6, 2023
Merged
22 changes: 17 additions & 5 deletions setuptools/command/editable_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ def _create_wheel_file(self, bdist_wheel):
with unpacked_wheel as unpacked, build_lib as lib, build_tmp as tmp:
unpacked_dist_info = Path(unpacked, Path(self.dist_info_dir).name)
shutil.copytree(self.dist_info_dir, unpacked_dist_info)
self._install_namespaces(unpacked, dist_info.name)
self._install_namespaces(unpacked, dist_name)
files, mapping = self._run_build_commands(dist_name, unpacked, lib, tmp)
strategy = self._select_strategy(dist_name, tag, lib)
with strategy, WheelFile(wheel_path, "w") as wheel_obj:
Expand Down Expand Up @@ -505,9 +505,19 @@ def __call__(self, wheel: "WheelFile", files: List[str], mapping: Dict[str, str]
)
)

legacy_namespaces = {
pkg: find_package_path(pkg, roots, self.dist.src_root or "")
for pkg in self.dist.namespace_packages or []
}

mapping = {**roots, **legacy_namespaces}
# ^-- We need to explicitly add the legacy_namespaces to the mapping to be
# able to import their modules even if another package sharing the same
# namespace is installed in a conventional (non-editable) way.

name = f"__editable__.{self.name}.finder"
finder = _normalization.safe_identifier(name)
content = bytes(_finder_template(name, roots, namespaces_), "utf-8")
content = bytes(_finder_template(name, mapping, namespaces_), "utf-8")
wheel.writestr(f"{finder}.py", content)

content = _encode_pth(f"import {finder}; {finder}.install()")
Expand Down Expand Up @@ -752,9 +762,9 @@ def __init__(self, distribution, installation_dir, editable_name, src_root):
self.outputs = []
self.dry_run = False

def _get_target(self):
def _get_nspkg_file(self):
"""Installation target."""
return os.path.join(self.installation_dir, self.editable_name)
return os.path.join(self.installation_dir, self.editable_name + self.nspkg_ext)

def _get_root(self):
"""Where the modules/packages should be loaded from."""
Expand All @@ -777,6 +787,8 @@ def _get_root(self):
class _EditableFinder: # MetaPathFinder
@classmethod
def find_spec(cls, fullname, path=None, target=None):
extra_path = []

# Top-level packages and modules (we know these exist in the FS)
if fullname in MAPPING:
pkg_path = MAPPING[fullname]
Expand All @@ -787,7 +799,7 @@ def find_spec(cls, fullname, path=None, target=None):
# to the importlib.machinery implementation.
parent, _, child = fullname.rpartition(".")
if parent and parent in MAPPING:
return PathFinder.find_spec(fullname, path=[MAPPING[parent]])
return PathFinder.find_spec(fullname, path=[MAPPING[parent], *extra_path])

# Other levels of nesting should be handled automatically by importlib
# using the parent path.
Expand Down
12 changes: 7 additions & 5 deletions setuptools/namespaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ def install_namespaces(self):
nsp = self._get_all_ns_packages()
if not nsp:
return
filename, ext = os.path.splitext(self._get_target())
filename += self.nspkg_ext
filename = self._get_nspkg_file()
self.outputs.append(filename)
log.info("Installing %s", filename)
lines = map(self._gen_nspkg_line, nsp)
Expand All @@ -28,13 +27,16 @@ def install_namespaces(self):
f.writelines(lines)

def uninstall_namespaces(self):
filename, ext = os.path.splitext(self._get_target())
filename += self.nspkg_ext
filename = self._get_nspkg_file()
if not os.path.exists(filename):
return
log.info("Removing %s", filename)
os.remove(filename)

def _get_nspkg_file(self):
filename, _ = os.path.splitext(self._get_target())
return filename + self.nspkg_ext

def _get_target(self):
return self.target

Expand Down Expand Up @@ -75,7 +77,7 @@ def _gen_nspkg_line(self, pkg):
def _get_all_ns_packages(self):
"""Return sorted list of all package namespaces"""
pkgs = self.distribution.namespace_packages or []
return sorted(flatten(map(self._pkg_names, pkgs)))
return sorted(set(flatten(map(self._pkg_names, pkgs))))

@staticmethod
def _pkg_names(pkg):
Expand Down
61 changes: 43 additions & 18 deletions setuptools/tests/namespaces.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,54 @@
import ast
import json
import textwrap
from pathlib import Path


def build_namespace_package(tmpdir, name):
def iter_namespace_pkgs(namespace):
parts = namespace.split(".")
for i in range(len(parts)):
yield ".".join(parts[: i + 1])


def build_namespace_package(tmpdir, name, version="1.0", impl="pkg_resources"):
src_dir = tmpdir / name
src_dir.mkdir()
setup_py = src_dir / 'setup.py'
namespace, sep, rest = name.partition('.')
namespace, _, rest = name.rpartition('.')
namespaces = list(iter_namespace_pkgs(namespace))
setup_args = {
"name": name,
"version": version,
"packages": namespaces,
}

if impl == "pkg_resources":
tmpl = '__import__("pkg_resources").declare_namespace(__name__)'
setup_args["namespace_packages"] = namespaces
elif impl == "pkgutil":
tmpl = '__path__ = __import__("pkgutil").extend_path(__path__, __name__)'
else:
raise ValueError(f"Cannot recognise {impl=} when creating namespaces")

args = json.dumps(setup_args, indent=4)
assert ast.literal_eval(args) # ensure it is valid Python

script = textwrap.dedent(
"""
"""\
import setuptools
setuptools.setup(
name={name!r},
version="1.0",
namespace_packages=[{namespace!r}],
packages=[{namespace!r}],
)
args = {args}
setuptools.setup(**args)
"""
).format(**locals())
).format(args=args)
setup_py.write_text(script, encoding='utf-8')
ns_pkg_dir = src_dir / namespace
ns_pkg_dir.mkdir()
pkg_init = ns_pkg_dir / '__init__.py'
tmpl = '__import__("pkg_resources").declare_namespace({namespace!r})'
decl = tmpl.format(**locals())
pkg_init.write_text(decl, encoding='utf-8')

ns_pkg_dir = Path(src_dir, namespace.replace(".", "/"))
ns_pkg_dir.mkdir(parents=True)

for ns in namespaces:
pkg_init = src_dir / ns.replace(".", "/") / '__init__.py'
pkg_init.write_text(tmpl, encoding='utf-8')

pkg_mod = ns_pkg_dir / (rest + '.py')
some_functionality = 'name = {rest!r}'.format(**locals())
pkg_mod.write_text(some_functionality, encoding='utf-8')
Expand All @@ -34,7 +59,7 @@ def build_pep420_namespace_package(tmpdir, name):
src_dir = tmpdir / name
src_dir.mkdir()
pyproject = src_dir / "pyproject.toml"
namespace, sep, rest = name.rpartition(".")
namespace, _, rest = name.rpartition(".")
script = f"""\
[build-system]
requires = ["setuptools"]
Expand All @@ -45,7 +70,7 @@ def build_pep420_namespace_package(tmpdir, name):
version = "3.14159"
"""
pyproject.write_text(textwrap.dedent(script), encoding='utf-8')
ns_pkg_dir = src_dir / namespace.replace(".", "/")
ns_pkg_dir = Path(src_dir, namespace.replace(".", "/"))
ns_pkg_dir.mkdir(parents=True)
pkg_mod = ns_pkg_dir / (rest + ".py")
some_functionality = f"name = {rest!r}"
Expand Down
50 changes: 45 additions & 5 deletions setuptools/tests/test_editable_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from unittest.mock import Mock
from uuid import uuid4

from distutils.core import run_setup

import jaraco.envs
import jaraco.path
import pytest
Expand All @@ -31,6 +33,7 @@
)
from setuptools.dist import Distribution
from setuptools.extension import Extension
from setuptools.warnings import SetuptoolsDeprecationWarning


@pytest.fixture(params=["strict", "lenient"])
Expand Down Expand Up @@ -230,30 +233,67 @@ def test_editable_with_single_module(tmp_path, venv, editable_opts):


class TestLegacyNamespaces:
"""Ported from test_develop"""
# legacy => pkg_resources.declare_namespace(...) + setup(namespace_packages=...)

def test_namespace_package_importable(self, venv, tmp_path, editable_opts):
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, 'myns.pkgA')
pkg_B = namespaces.build_namespace_package(tmp_path, 'myns.pkgB')
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", "import myns.pkgA; import myns.pkgB"])
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"])

Expand Down
Loading