Skip to content

Commit 7659959

Browse files
committed
Fix pkg_resources-style legacy namespaces in editable installs (#4041)
2 parents 1ef36f2 + 8c740e5 commit 7659959

File tree

6 files changed

+116
-33
lines changed

6 files changed

+116
-33
lines changed

newsfragments/4041.bugfix.1.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix the name given to the ``*-nspkg.pth`` files in editable installs,
2+
ensuring they are unique per distribution.

newsfragments/4041.bugfix.2.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Workaround some limitations on ``pkg_resources``-style legacy namespaces in
2+
the meta path finder for editable installations.

setuptools/command/editable_wheel.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,7 @@ def _create_wheel_file(self, bdist_wheel):
341341
with unpacked_wheel as unpacked, build_lib as lib, build_tmp as tmp:
342342
unpacked_dist_info = Path(unpacked, Path(self.dist_info_dir).name)
343343
shutil.copytree(self.dist_info_dir, unpacked_dist_info)
344-
self._install_namespaces(unpacked, dist_info.name)
344+
self._install_namespaces(unpacked, dist_name)
345345
files, mapping = self._run_build_commands(dist_name, unpacked, lib, tmp)
346346
strategy = self._select_strategy(dist_name, tag, lib)
347347
with strategy, WheelFile(wheel_path, "w") as wheel_obj:
@@ -505,9 +505,19 @@ def __call__(self, wheel: "WheelFile", files: List[str], mapping: Dict[str, str]
505505
)
506506
)
507507

508+
legacy_namespaces = {
509+
pkg: find_package_path(pkg, roots, self.dist.src_root or "")
510+
for pkg in self.dist.namespace_packages or []
511+
}
512+
513+
mapping = {**roots, **legacy_namespaces}
514+
# ^-- We need to explicitly add the legacy_namespaces to the mapping to be
515+
# able to import their modules even if another package sharing the same
516+
# namespace is installed in a conventional (non-editable) way.
517+
508518
name = f"__editable__.{self.name}.finder"
509519
finder = _normalization.safe_identifier(name)
510-
content = bytes(_finder_template(name, roots, namespaces_), "utf-8")
520+
content = bytes(_finder_template(name, mapping, namespaces_), "utf-8")
511521
wheel.writestr(f"{finder}.py", content)
512522

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

755-
def _get_target(self):
765+
def _get_nspkg_file(self):
756766
"""Installation target."""
757-
return os.path.join(self.installation_dir, self.editable_name)
767+
return os.path.join(self.installation_dir, self.editable_name + self.nspkg_ext)
758768

759769
def _get_root(self):
760770
"""Where the modules/packages should be loaded from."""
@@ -777,6 +787,8 @@ def _get_root(self):
777787
class _EditableFinder: # MetaPathFinder
778788
@classmethod
779789
def find_spec(cls, fullname, path=None, target=None):
790+
extra_path = []
791+
780792
# Top-level packages and modules (we know these exist in the FS)
781793
if fullname in MAPPING:
782794
pkg_path = MAPPING[fullname]
@@ -787,7 +799,7 @@ def find_spec(cls, fullname, path=None, target=None):
787799
# to the importlib.machinery implementation.
788800
parent, _, child = fullname.rpartition(".")
789801
if parent and parent in MAPPING:
790-
return PathFinder.find_spec(fullname, path=[MAPPING[parent]])
802+
return PathFinder.find_spec(fullname, path=[MAPPING[parent], *extra_path])
791803
792804
# Other levels of nesting should be handled automatically by importlib
793805
# using the parent path.

setuptools/namespaces.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@ def install_namespaces(self):
1313
nsp = self._get_all_ns_packages()
1414
if not nsp:
1515
return
16-
filename, ext = os.path.splitext(self._get_target())
17-
filename += self.nspkg_ext
16+
filename = self._get_nspkg_file()
1817
self.outputs.append(filename)
1918
log.info("Installing %s", filename)
2019
lines = map(self._gen_nspkg_line, nsp)
@@ -28,13 +27,16 @@ def install_namespaces(self):
2827
f.writelines(lines)
2928

3029
def uninstall_namespaces(self):
31-
filename, ext = os.path.splitext(self._get_target())
32-
filename += self.nspkg_ext
30+
filename = self._get_nspkg_file()
3331
if not os.path.exists(filename):
3432
return
3533
log.info("Removing %s", filename)
3634
os.remove(filename)
3735

36+
def _get_nspkg_file(self):
37+
filename, _ = os.path.splitext(self._get_target())
38+
return filename + self.nspkg_ext
39+
3840
def _get_target(self):
3941
return self.target
4042

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

8082
@staticmethod
8183
def _pkg_names(pkg):

setuptools/tests/namespaces.py

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,54 @@
1+
import ast
2+
import json
13
import textwrap
4+
from pathlib import Path
25

36

4-
def build_namespace_package(tmpdir, name):
7+
def iter_namespace_pkgs(namespace):
8+
parts = namespace.split(".")
9+
for i in range(len(parts)):
10+
yield ".".join(parts[: i + 1])
11+
12+
13+
def build_namespace_package(tmpdir, name, version="1.0", impl="pkg_resources"):
514
src_dir = tmpdir / name
615
src_dir.mkdir()
716
setup_py = src_dir / 'setup.py'
8-
namespace, sep, rest = name.partition('.')
17+
namespace, _, rest = name.rpartition('.')
18+
namespaces = list(iter_namespace_pkgs(namespace))
19+
setup_args = {
20+
"name": name,
21+
"version": version,
22+
"packages": namespaces,
23+
}
24+
25+
if impl == "pkg_resources":
26+
tmpl = '__import__("pkg_resources").declare_namespace(__name__)'
27+
setup_args["namespace_packages"] = namespaces
28+
elif impl == "pkgutil":
29+
tmpl = '__path__ = __import__("pkgutil").extend_path(__path__, __name__)'
30+
else:
31+
raise ValueError(f"Cannot recognise {impl=} when creating namespaces")
32+
33+
args = json.dumps(setup_args, indent=4)
34+
assert ast.literal_eval(args) # ensure it is valid Python
35+
936
script = textwrap.dedent(
10-
"""
37+
"""\
1138
import setuptools
12-
setuptools.setup(
13-
name={name!r},
14-
version="1.0",
15-
namespace_packages=[{namespace!r}],
16-
packages=[{namespace!r}],
17-
)
39+
args = {args}
40+
setuptools.setup(**args)
1841
"""
19-
).format(**locals())
42+
).format(args=args)
2043
setup_py.write_text(script, encoding='utf-8')
21-
ns_pkg_dir = src_dir / namespace
22-
ns_pkg_dir.mkdir()
23-
pkg_init = ns_pkg_dir / '__init__.py'
24-
tmpl = '__import__("pkg_resources").declare_namespace({namespace!r})'
25-
decl = tmpl.format(**locals())
26-
pkg_init.write_text(decl, encoding='utf-8')
44+
45+
ns_pkg_dir = Path(src_dir, namespace.replace(".", "/"))
46+
ns_pkg_dir.mkdir(parents=True)
47+
48+
for ns in namespaces:
49+
pkg_init = src_dir / ns.replace(".", "/") / '__init__.py'
50+
pkg_init.write_text(tmpl, encoding='utf-8')
51+
2752
pkg_mod = ns_pkg_dir / (rest + '.py')
2853
some_functionality = 'name = {rest!r}'.format(**locals())
2954
pkg_mod.write_text(some_functionality, encoding='utf-8')
@@ -34,7 +59,7 @@ def build_pep420_namespace_package(tmpdir, name):
3459
src_dir = tmpdir / name
3560
src_dir.mkdir()
3661
pyproject = src_dir / "pyproject.toml"
37-
namespace, sep, rest = name.rpartition(".")
62+
namespace, _, rest = name.rpartition(".")
3863
script = f"""\
3964
[build-system]
4065
requires = ["setuptools"]
@@ -45,7 +70,7 @@ def build_pep420_namespace_package(tmpdir, name):
4570
version = "3.14159"
4671
"""
4772
pyproject.write_text(textwrap.dedent(script), encoding='utf-8')
48-
ns_pkg_dir = src_dir / namespace.replace(".", "/")
73+
ns_pkg_dir = Path(src_dir, namespace.replace(".", "/"))
4974
ns_pkg_dir.mkdir(parents=True)
5075
pkg_mod = ns_pkg_dir / (rest + ".py")
5176
some_functionality = f"name = {rest!r}"

setuptools/tests/test_editable_install.py

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from unittest.mock import Mock
1212
from uuid import uuid4
1313

14+
from distutils.core import run_setup
15+
1416
import jaraco.envs
1517
import jaraco.path
1618
import pytest
@@ -31,6 +33,7 @@
3133
)
3234
from setuptools.dist import Distribution
3335
from setuptools.extension import Extension
36+
from setuptools.warnings import SetuptoolsDeprecationWarning
3437

3538

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

231234

232235
class TestLegacyNamespaces:
233-
"""Ported from test_develop"""
236+
# legacy => pkg_resources.declare_namespace(...) + setup(namespace_packages=...)
234237

235-
def test_namespace_package_importable(self, venv, tmp_path, editable_opts):
238+
def test_nspkg_file_is_unique(self, tmp_path, monkeypatch):
239+
deprecation = pytest.warns(
240+
SetuptoolsDeprecationWarning, match=".*namespace_packages parameter.*"
241+
)
242+
installation_dir = tmp_path / ".installation_dir"
243+
installation_dir.mkdir()
244+
examples = (
245+
"myns.pkgA",
246+
"myns.pkgB",
247+
"myns.n.pkgA",
248+
"myns.n.pkgB",
249+
)
250+
251+
for name in examples:
252+
pkg = namespaces.build_namespace_package(tmp_path, name, version="42")
253+
with deprecation, monkeypatch.context() as ctx:
254+
ctx.chdir(pkg)
255+
dist = run_setup("setup.py", stop_after="config")
256+
cmd = editable_wheel(dist)
257+
cmd.finalize_options()
258+
editable_name = cmd.get_finalized_command("dist_info").name
259+
cmd._install_namespaces(installation_dir, editable_name)
260+
261+
files = list(installation_dir.glob("*-nspkg.pth"))
262+
assert len(files) == len(examples)
263+
264+
@pytest.mark.parametrize(
265+
"impl",
266+
(
267+
"pkg_resources",
268+
# "pkgutil", => does not work
269+
),
270+
)
271+
@pytest.mark.parametrize("ns", ("myns.n",))
272+
def test_namespace_package_importable(
273+
self, venv, tmp_path, ns, impl, editable_opts
274+
):
236275
"""
237276
Installing two packages sharing the same namespace, one installed
238277
naturally using pip or `--single-version-externally-managed`
239278
and the other installed in editable mode should leave the namespace
240279
intact and both packages reachable by import.
280+
(Ported from test_develop).
241281
"""
242282
build_system = """\
243283
[build-system]
244284
requires = ["setuptools"]
245285
build-backend = "setuptools.build_meta"
246286
"""
247-
pkg_A = namespaces.build_namespace_package(tmp_path, 'myns.pkgA')
248-
pkg_B = namespaces.build_namespace_package(tmp_path, 'myns.pkgB')
287+
pkg_A = namespaces.build_namespace_package(tmp_path, f"{ns}.pkgA", impl=impl)
288+
pkg_B = namespaces.build_namespace_package(tmp_path, f"{ns}.pkgB", impl=impl)
249289
(pkg_A / "pyproject.toml").write_text(build_system, encoding="utf-8")
250290
(pkg_B / "pyproject.toml").write_text(build_system, encoding="utf-8")
251291
# use pip to install to the target directory
252292
opts = editable_opts[:]
253293
opts.append("--no-build-isolation") # force current version of setuptools
254294
venv.run(["python", "-m", "pip", "install", str(pkg_A), *opts])
255295
venv.run(["python", "-m", "pip", "install", "-e", str(pkg_B), *opts])
256-
venv.run(["python", "-c", "import myns.pkgA; import myns.pkgB"])
296+
venv.run(["python", "-c", f"import {ns}.pkgA; import {ns}.pkgB"])
257297
# additionally ensure that pkg_resources import works
258298
venv.run(["python", "-c", "import pkg_resources"])
259299

0 commit comments

Comments
 (0)