test_editable_install.py 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263
  1. from __future__ import annotations
  2. import os
  3. import platform
  4. import stat
  5. import subprocess
  6. import sys
  7. from copy import deepcopy
  8. from importlib import import_module
  9. from importlib.machinery import EXTENSION_SUFFIXES
  10. from pathlib import Path
  11. from textwrap import dedent
  12. from typing import Any
  13. from unittest.mock import Mock
  14. from uuid import uuid4
  15. import jaraco.envs
  16. import jaraco.path
  17. import pytest
  18. from path import Path as _Path
  19. from setuptools._importlib import resources as importlib_resources
  20. from setuptools.command.editable_wheel import (
  21. _encode_pth,
  22. _find_namespaces,
  23. _find_package_roots,
  24. _find_virtual_namespaces,
  25. _finder_template,
  26. _LinkTree,
  27. _TopLevelFinder,
  28. editable_wheel,
  29. )
  30. from setuptools.dist import Distribution
  31. from setuptools.extension import Extension
  32. from setuptools.warnings import SetuptoolsDeprecationWarning
  33. from . import contexts, namespaces
  34. from distutils.core import run_setup
  35. @pytest.fixture(params=["strict", "lenient"])
  36. def editable_opts(request):
  37. if request.param == "strict":
  38. return ["--config-settings", "editable-mode=strict"]
  39. return []
  40. EXAMPLE = {
  41. 'pyproject.toml': dedent(
  42. """\
  43. [build-system]
  44. requires = ["setuptools"]
  45. build-backend = "setuptools.build_meta"
  46. [project]
  47. name = "mypkg"
  48. version = "3.14159"
  49. license = {text = "MIT"}
  50. description = "This is a Python package"
  51. dynamic = ["readme"]
  52. classifiers = [
  53. "Development Status :: 5 - Production/Stable",
  54. "Intended Audience :: Developers"
  55. ]
  56. urls = {Homepage = "https://github.com"}
  57. [tool.setuptools]
  58. package-dir = {"" = "src"}
  59. packages = {find = {where = ["src"]}}
  60. license-files = ["LICENSE*"]
  61. [tool.setuptools.dynamic]
  62. readme = {file = "README.rst"}
  63. [tool.distutils.egg_info]
  64. tag-build = ".post0"
  65. """
  66. ),
  67. "MANIFEST.in": dedent(
  68. """\
  69. global-include *.py *.txt
  70. global-exclude *.py[cod]
  71. prune dist
  72. prune build
  73. """
  74. ).strip(),
  75. "README.rst": "This is a ``README``",
  76. "LICENSE.txt": "---- placeholder MIT license ----",
  77. "src": {
  78. "mypkg": {
  79. "__init__.py": dedent(
  80. """\
  81. import sys
  82. from importlib.metadata import PackageNotFoundError, version
  83. try:
  84. __version__ = version(__name__)
  85. except PackageNotFoundError:
  86. __version__ = "unknown"
  87. """
  88. ),
  89. "__main__.py": dedent(
  90. """\
  91. from importlib.resources import read_text
  92. from . import __version__, __name__ as parent
  93. from .mod import x
  94. data = read_text(parent, "data.txt")
  95. print(__version__, data, x)
  96. """
  97. ),
  98. "mod.py": "x = ''",
  99. "data.txt": "Hello World",
  100. }
  101. },
  102. }
  103. SETUP_SCRIPT_STUB = "__import__('setuptools').setup()"
  104. @pytest.mark.xfail(sys.platform == "darwin", reason="pypa/setuptools#4328")
  105. @pytest.mark.parametrize(
  106. "files",
  107. [
  108. {**EXAMPLE, "setup.py": SETUP_SCRIPT_STUB},
  109. EXAMPLE, # No setup.py script
  110. ],
  111. )
  112. def test_editable_with_pyproject(tmp_path, venv, files, editable_opts):
  113. project = tmp_path / "mypkg"
  114. project.mkdir()
  115. jaraco.path.build(files, prefix=project)
  116. cmd = [
  117. "python",
  118. "-m",
  119. "pip",
  120. "install",
  121. "--no-build-isolation", # required to force current version of setuptools
  122. "-e",
  123. str(project),
  124. *editable_opts,
  125. ]
  126. print(venv.run(cmd))
  127. cmd = ["python", "-m", "mypkg"]
  128. assert venv.run(cmd).strip() == "3.14159.post0 Hello World"
  129. (project / "src/mypkg/data.txt").write_text("foobar", encoding="utf-8")
  130. (project / "src/mypkg/mod.py").write_text("x = 42", encoding="utf-8")
  131. assert venv.run(cmd).strip() == "3.14159.post0 foobar 42"
  132. def test_editable_with_flat_layout(tmp_path, venv, editable_opts):
  133. files = {
  134. "mypkg": {
  135. "pyproject.toml": dedent(
  136. """\
  137. [build-system]
  138. requires = ["setuptools", "wheel"]
  139. build-backend = "setuptools.build_meta"
  140. [project]
  141. name = "mypkg"
  142. version = "3.14159"
  143. [tool.setuptools]
  144. packages = ["pkg"]
  145. py-modules = ["mod"]
  146. """
  147. ),
  148. "pkg": {"__init__.py": "a = 4"},
  149. "mod.py": "b = 2",
  150. },
  151. }
  152. jaraco.path.build(files, prefix=tmp_path)
  153. project = tmp_path / "mypkg"
  154. cmd = [
  155. "python",
  156. "-m",
  157. "pip",
  158. "install",
  159. "--no-build-isolation", # required to force current version of setuptools
  160. "-e",
  161. str(project),
  162. *editable_opts,
  163. ]
  164. print(venv.run(cmd))
  165. cmd = ["python", "-c", "import pkg, mod; print(pkg.a, mod.b)"]
  166. assert venv.run(cmd).strip() == "4 2"
  167. def test_editable_with_single_module(tmp_path, venv, editable_opts):
  168. files = {
  169. "mypkg": {
  170. "pyproject.toml": dedent(
  171. """\
  172. [build-system]
  173. requires = ["setuptools", "wheel"]
  174. build-backend = "setuptools.build_meta"
  175. [project]
  176. name = "mod"
  177. version = "3.14159"
  178. [tool.setuptools]
  179. py-modules = ["mod"]
  180. """
  181. ),
  182. "mod.py": "b = 2",
  183. },
  184. }
  185. jaraco.path.build(files, prefix=tmp_path)
  186. project = tmp_path / "mypkg"
  187. cmd = [
  188. "python",
  189. "-m",
  190. "pip",
  191. "install",
  192. "--no-build-isolation", # required to force current version of setuptools
  193. "-e",
  194. str(project),
  195. *editable_opts,
  196. ]
  197. print(venv.run(cmd))
  198. cmd = ["python", "-c", "import mod; print(mod.b)"]
  199. assert venv.run(cmd).strip() == "2"
  200. class TestLegacyNamespaces:
  201. # legacy => pkg_resources.declare_namespace(...) + setup(namespace_packages=...)
  202. def test_nspkg_file_is_unique(self, tmp_path, monkeypatch):
  203. deprecation = pytest.warns(
  204. SetuptoolsDeprecationWarning, match=".*namespace_packages parameter.*"
  205. )
  206. installation_dir = tmp_path / ".installation_dir"
  207. installation_dir.mkdir()
  208. examples = (
  209. "myns.pkgA",
  210. "myns.pkgB",
  211. "myns.n.pkgA",
  212. "myns.n.pkgB",
  213. )
  214. for name in examples:
  215. pkg = namespaces.build_namespace_package(tmp_path, name, version="42")
  216. with deprecation, monkeypatch.context() as ctx:
  217. ctx.chdir(pkg)
  218. dist = run_setup("setup.py", stop_after="config")
  219. cmd = editable_wheel(dist)
  220. cmd.finalize_options()
  221. editable_name = cmd.get_finalized_command("dist_info").name
  222. cmd._install_namespaces(installation_dir, editable_name)
  223. files = list(installation_dir.glob("*-nspkg.pth"))
  224. assert len(files) == len(examples)
  225. @pytest.mark.parametrize(
  226. "impl",
  227. (
  228. "pkg_resources",
  229. # "pkgutil", => does not work
  230. ),
  231. )
  232. @pytest.mark.parametrize("ns", ("myns.n",))
  233. def test_namespace_package_importable(
  234. self, venv, tmp_path, ns, impl, editable_opts
  235. ):
  236. """
  237. Installing two packages sharing the same namespace, one installed
  238. naturally using pip or `--single-version-externally-managed`
  239. and the other installed in editable mode should leave the namespace
  240. intact and both packages reachable by import.
  241. (Ported from test_develop).
  242. """
  243. build_system = """\
  244. [build-system]
  245. requires = ["setuptools"]
  246. build-backend = "setuptools.build_meta"
  247. """
  248. pkg_A = namespaces.build_namespace_package(tmp_path, f"{ns}.pkgA", impl=impl)
  249. pkg_B = namespaces.build_namespace_package(tmp_path, f"{ns}.pkgB", impl=impl)
  250. (pkg_A / "pyproject.toml").write_text(build_system, encoding="utf-8")
  251. (pkg_B / "pyproject.toml").write_text(build_system, encoding="utf-8")
  252. # use pip to install to the target directory
  253. opts = editable_opts[:]
  254. opts.append("--no-build-isolation") # force current version of setuptools
  255. venv.run(["python", "-m", "pip", "install", str(pkg_A), *opts])
  256. venv.run(["python", "-m", "pip", "install", "-e", str(pkg_B), *opts])
  257. venv.run(["python", "-c", f"import {ns}.pkgA; import {ns}.pkgB"])
  258. # additionally ensure that pkg_resources import works
  259. venv.run(["python", "-c", "import pkg_resources"])
  260. class TestPep420Namespaces:
  261. def test_namespace_package_importable(self, venv, tmp_path, editable_opts):
  262. """
  263. Installing two packages sharing the same namespace, one installed
  264. normally using pip and the other installed in editable mode
  265. should allow importing both packages.
  266. """
  267. pkg_A = namespaces.build_pep420_namespace_package(tmp_path, 'myns.n.pkgA')
  268. pkg_B = namespaces.build_pep420_namespace_package(tmp_path, 'myns.n.pkgB')
  269. # use pip to install to the target directory
  270. opts = editable_opts[:]
  271. opts.append("--no-build-isolation") # force current version of setuptools
  272. venv.run(["python", "-m", "pip", "install", str(pkg_A), *opts])
  273. venv.run(["python", "-m", "pip", "install", "-e", str(pkg_B), *opts])
  274. venv.run(["python", "-c", "import myns.n.pkgA; import myns.n.pkgB"])
  275. def test_namespace_created_via_package_dir(self, venv, tmp_path, editable_opts):
  276. """Currently users can create a namespace by tweaking `package_dir`"""
  277. files = {
  278. "pkgA": {
  279. "pyproject.toml": dedent(
  280. """\
  281. [build-system]
  282. requires = ["setuptools", "wheel"]
  283. build-backend = "setuptools.build_meta"
  284. [project]
  285. name = "pkgA"
  286. version = "3.14159"
  287. [tool.setuptools]
  288. package-dir = {"myns.n.pkgA" = "src"}
  289. """
  290. ),
  291. "src": {"__init__.py": "a = 1"},
  292. },
  293. }
  294. jaraco.path.build(files, prefix=tmp_path)
  295. pkg_A = tmp_path / "pkgA"
  296. pkg_B = namespaces.build_pep420_namespace_package(tmp_path, 'myns.n.pkgB')
  297. pkg_C = namespaces.build_pep420_namespace_package(tmp_path, 'myns.n.pkgC')
  298. # use pip to install to the target directory
  299. opts = editable_opts[:]
  300. opts.append("--no-build-isolation") # force current version of setuptools
  301. venv.run(["python", "-m", "pip", "install", str(pkg_A), *opts])
  302. venv.run(["python", "-m", "pip", "install", "-e", str(pkg_B), *opts])
  303. venv.run(["python", "-m", "pip", "install", "-e", str(pkg_C), *opts])
  304. venv.run(["python", "-c", "from myns.n import pkgA, pkgB, pkgC"])
  305. def test_namespace_accidental_config_in_lenient_mode(self, venv, tmp_path):
  306. """Sometimes users might specify an ``include`` pattern that ignores parent
  307. packages. In a normal installation this would ignore all modules inside the
  308. parent packages, and make them namespaces (reported in issue #3504),
  309. so the editable mode should preserve this behaviour.
  310. """
  311. files = {
  312. "pkgA": {
  313. "pyproject.toml": dedent(
  314. """\
  315. [build-system]
  316. requires = ["setuptools", "wheel"]
  317. build-backend = "setuptools.build_meta"
  318. [project]
  319. name = "pkgA"
  320. version = "3.14159"
  321. [tool.setuptools]
  322. packages.find.include = ["mypkg.*"]
  323. """
  324. ),
  325. "mypkg": {
  326. "__init__.py": "",
  327. "other.py": "b = 1",
  328. "n": {
  329. "__init__.py": "",
  330. "pkgA.py": "a = 1",
  331. },
  332. },
  333. "MANIFEST.in": EXAMPLE["MANIFEST.in"],
  334. },
  335. }
  336. jaraco.path.build(files, prefix=tmp_path)
  337. pkg_A = tmp_path / "pkgA"
  338. # use pip to install to the target directory
  339. opts = ["--no-build-isolation"] # force current version of setuptools
  340. venv.run(["python", "-m", "pip", "-v", "install", "-e", str(pkg_A), *opts])
  341. out = venv.run(["python", "-c", "from mypkg.n import pkgA; print(pkgA.a)"])
  342. assert out.strip() == "1"
  343. cmd = """\
  344. try:
  345. import mypkg.other
  346. except ImportError:
  347. print("mypkg.other not defined")
  348. """
  349. out = venv.run(["python", "-c", dedent(cmd)])
  350. assert "mypkg.other not defined" in out
  351. def test_editable_with_prefix(tmp_path, sample_project, editable_opts):
  352. """
  353. Editable install to a prefix should be discoverable.
  354. """
  355. prefix = tmp_path / 'prefix'
  356. # figure out where pip will likely install the package
  357. site_packages_all = [
  358. prefix / Path(path).relative_to(sys.prefix)
  359. for path in sys.path
  360. if 'site-packages' in path and path.startswith(sys.prefix)
  361. ]
  362. for sp in site_packages_all:
  363. sp.mkdir(parents=True)
  364. # install workaround
  365. _addsitedirs(site_packages_all)
  366. env = dict(os.environ, PYTHONPATH=os.pathsep.join(map(str, site_packages_all)))
  367. cmd = [
  368. sys.executable,
  369. '-m',
  370. 'pip',
  371. 'install',
  372. '--editable',
  373. str(sample_project),
  374. '--prefix',
  375. str(prefix),
  376. '--no-build-isolation',
  377. *editable_opts,
  378. ]
  379. subprocess.check_call(cmd, env=env)
  380. # now run 'sample' with the prefix on the PYTHONPATH
  381. bin = 'Scripts' if platform.system() == 'Windows' else 'bin'
  382. exe = prefix / bin / 'sample'
  383. subprocess.check_call([exe], env=env)
  384. class TestFinderTemplate:
  385. """This test focus in getting a particular implementation detail right.
  386. If at some point in time the implementation is changed for something different,
  387. this test can be modified or even excluded.
  388. """
  389. def install_finder(self, finder):
  390. loc = {}
  391. exec(finder, loc, loc)
  392. loc["install"]()
  393. def test_packages(self, tmp_path):
  394. files = {
  395. "src1": {
  396. "pkg1": {
  397. "__init__.py": "",
  398. "subpkg": {"mod1.py": "a = 42"},
  399. },
  400. },
  401. "src2": {"mod2.py": "a = 43"},
  402. }
  403. jaraco.path.build(files, prefix=tmp_path)
  404. mapping = {
  405. "pkg1": str(tmp_path / "src1/pkg1"),
  406. "mod2": str(tmp_path / "src2/mod2"),
  407. }
  408. template = _finder_template(str(uuid4()), mapping, {})
  409. with contexts.save_paths(), contexts.save_sys_modules():
  410. for mod in ("pkg1", "pkg1.subpkg", "pkg1.subpkg.mod1", "mod2"):
  411. sys.modules.pop(mod, None)
  412. self.install_finder(template)
  413. mod1 = import_module("pkg1.subpkg.mod1")
  414. mod2 = import_module("mod2")
  415. subpkg = import_module("pkg1.subpkg")
  416. assert mod1.a == 42
  417. assert mod2.a == 43
  418. expected = str((tmp_path / "src1/pkg1/subpkg").resolve())
  419. assert_path(subpkg, expected)
  420. def test_namespace(self, tmp_path):
  421. files = {"pkg": {"__init__.py": "a = 13", "text.txt": "abc"}}
  422. jaraco.path.build(files, prefix=tmp_path)
  423. mapping = {"ns.othername": str(tmp_path / "pkg")}
  424. namespaces = {"ns": []}
  425. template = _finder_template(str(uuid4()), mapping, namespaces)
  426. with contexts.save_paths(), contexts.save_sys_modules():
  427. for mod in ("ns", "ns.othername"):
  428. sys.modules.pop(mod, None)
  429. self.install_finder(template)
  430. pkg = import_module("ns.othername")
  431. text = importlib_resources.files(pkg) / "text.txt"
  432. expected = str((tmp_path / "pkg").resolve())
  433. assert_path(pkg, expected)
  434. assert pkg.a == 13
  435. # Make sure resources can also be found
  436. assert text.read_text(encoding="utf-8") == "abc"
  437. def test_combine_namespaces(self, tmp_path):
  438. files = {
  439. "src1": {"ns": {"pkg1": {"__init__.py": "a = 13"}}},
  440. "src2": {"ns": {"mod2.py": "b = 37"}},
  441. }
  442. jaraco.path.build(files, prefix=tmp_path)
  443. mapping = {
  444. "ns.pkgA": str(tmp_path / "src1/ns/pkg1"),
  445. "ns": str(tmp_path / "src2/ns"),
  446. }
  447. namespaces_ = {"ns": [str(tmp_path / "src1"), str(tmp_path / "src2")]}
  448. template = _finder_template(str(uuid4()), mapping, namespaces_)
  449. with contexts.save_paths(), contexts.save_sys_modules():
  450. for mod in ("ns", "ns.pkgA", "ns.mod2"):
  451. sys.modules.pop(mod, None)
  452. self.install_finder(template)
  453. pkgA = import_module("ns.pkgA")
  454. mod2 = import_module("ns.mod2")
  455. expected = str((tmp_path / "src1/ns/pkg1").resolve())
  456. assert_path(pkgA, expected)
  457. assert pkgA.a == 13
  458. assert mod2.b == 37
  459. def test_combine_namespaces_nested(self, tmp_path):
  460. """
  461. Users may attempt to combine namespace packages in a nested way via
  462. ``package_dir`` as shown in pypa/setuptools#4248.
  463. """
  464. files = {
  465. "src": {"my_package": {"my_module.py": "a = 13"}},
  466. "src2": {"my_package2": {"my_module2.py": "b = 37"}},
  467. }
  468. stack = jaraco.path.DirectoryStack()
  469. with stack.context(tmp_path):
  470. jaraco.path.build(files)
  471. attrs = {
  472. "script_name": "%PEP 517%",
  473. "package_dir": {
  474. "different_name": "src/my_package",
  475. "different_name.subpkg": "src2/my_package2",
  476. },
  477. "packages": ["different_name", "different_name.subpkg"],
  478. }
  479. dist = Distribution(attrs)
  480. finder = _TopLevelFinder(dist, str(uuid4()))
  481. code = next(v for k, v in finder.get_implementation() if k.endswith(".py"))
  482. with contexts.save_paths(), contexts.save_sys_modules():
  483. for mod in attrs["packages"]:
  484. sys.modules.pop(mod, None)
  485. self.install_finder(code)
  486. mod1 = import_module("different_name.my_module")
  487. mod2 = import_module("different_name.subpkg.my_module2")
  488. expected = str((tmp_path / "src/my_package/my_module.py").resolve())
  489. assert str(Path(mod1.__file__).resolve()) == expected
  490. expected = str((tmp_path / "src2/my_package2/my_module2.py").resolve())
  491. assert str(Path(mod2.__file__).resolve()) == expected
  492. assert mod1.a == 13
  493. assert mod2.b == 37
  494. def test_dynamic_path_computation(self, tmp_path):
  495. # Follows the example in PEP 420
  496. files = {
  497. "project1": {"parent": {"child": {"one.py": "x = 1"}}},
  498. "project2": {"parent": {"child": {"two.py": "x = 2"}}},
  499. "project3": {"parent": {"child": {"three.py": "x = 3"}}},
  500. }
  501. jaraco.path.build(files, prefix=tmp_path)
  502. mapping = {}
  503. namespaces_ = {"parent": [str(tmp_path / "project1/parent")]}
  504. template = _finder_template(str(uuid4()), mapping, namespaces_)
  505. mods = (f"parent.child.{name}" for name in ("one", "two", "three"))
  506. with contexts.save_paths(), contexts.save_sys_modules():
  507. for mod in ("parent", "parent.child", "parent.child", *mods):
  508. sys.modules.pop(mod, None)
  509. self.install_finder(template)
  510. one = import_module("parent.child.one")
  511. assert one.x == 1
  512. with pytest.raises(ImportError):
  513. import_module("parent.child.two")
  514. sys.path.append(str(tmp_path / "project2"))
  515. two = import_module("parent.child.two")
  516. assert two.x == 2
  517. with pytest.raises(ImportError):
  518. import_module("parent.child.three")
  519. sys.path.append(str(tmp_path / "project3"))
  520. three = import_module("parent.child.three")
  521. assert three.x == 3
  522. def test_no_recursion(self, tmp_path):
  523. # See issue #3550
  524. files = {
  525. "pkg": {
  526. "__init__.py": "from . import pkg",
  527. },
  528. }
  529. jaraco.path.build(files, prefix=tmp_path)
  530. mapping = {
  531. "pkg": str(tmp_path / "pkg"),
  532. }
  533. template = _finder_template(str(uuid4()), mapping, {})
  534. with contexts.save_paths(), contexts.save_sys_modules():
  535. sys.modules.pop("pkg", None)
  536. self.install_finder(template)
  537. with pytest.raises(ImportError, match="pkg"):
  538. import_module("pkg")
  539. def test_similar_name(self, tmp_path):
  540. files = {
  541. "foo": {
  542. "__init__.py": "",
  543. "bar": {
  544. "__init__.py": "",
  545. },
  546. },
  547. }
  548. jaraco.path.build(files, prefix=tmp_path)
  549. mapping = {
  550. "foo": str(tmp_path / "foo"),
  551. }
  552. template = _finder_template(str(uuid4()), mapping, {})
  553. with contexts.save_paths(), contexts.save_sys_modules():
  554. sys.modules.pop("foo", None)
  555. sys.modules.pop("foo.bar", None)
  556. self.install_finder(template)
  557. with pytest.raises(ImportError, match="foobar"):
  558. import_module("foobar")
  559. def test_case_sensitivity(self, tmp_path):
  560. files = {
  561. "foo": {
  562. "__init__.py": "",
  563. "lowercase.py": "x = 1",
  564. "bar": {
  565. "__init__.py": "",
  566. "lowercase.py": "x = 2",
  567. },
  568. },
  569. }
  570. jaraco.path.build(files, prefix=tmp_path)
  571. mapping = {
  572. "foo": str(tmp_path / "foo"),
  573. }
  574. template = _finder_template(str(uuid4()), mapping, {})
  575. with contexts.save_paths(), contexts.save_sys_modules():
  576. sys.modules.pop("foo", None)
  577. self.install_finder(template)
  578. with pytest.raises(ImportError, match="'FOO'"):
  579. import_module("FOO")
  580. with pytest.raises(ImportError, match="'foo\\.LOWERCASE'"):
  581. import_module("foo.LOWERCASE")
  582. with pytest.raises(ImportError, match="'foo\\.bar\\.Lowercase'"):
  583. import_module("foo.bar.Lowercase")
  584. with pytest.raises(ImportError, match="'foo\\.BAR'"):
  585. import_module("foo.BAR.lowercase")
  586. with pytest.raises(ImportError, match="'FOO'"):
  587. import_module("FOO.bar.lowercase")
  588. mod = import_module("foo.lowercase")
  589. assert mod.x == 1
  590. mod = import_module("foo.bar.lowercase")
  591. assert mod.x == 2
  592. def test_namespace_case_sensitivity(self, tmp_path):
  593. files = {
  594. "pkg": {
  595. "__init__.py": "a = 13",
  596. "foo": {
  597. "__init__.py": "b = 37",
  598. "bar.py": "c = 42",
  599. },
  600. },
  601. }
  602. jaraco.path.build(files, prefix=tmp_path)
  603. mapping = {"ns.othername": str(tmp_path / "pkg")}
  604. namespaces = {"ns": []}
  605. template = _finder_template(str(uuid4()), mapping, namespaces)
  606. with contexts.save_paths(), contexts.save_sys_modules():
  607. for mod in ("ns", "ns.othername"):
  608. sys.modules.pop(mod, None)
  609. self.install_finder(template)
  610. pkg = import_module("ns.othername")
  611. expected = str((tmp_path / "pkg").resolve())
  612. assert_path(pkg, expected)
  613. assert pkg.a == 13
  614. foo = import_module("ns.othername.foo")
  615. assert foo.b == 37
  616. bar = import_module("ns.othername.foo.bar")
  617. assert bar.c == 42
  618. with pytest.raises(ImportError, match="'NS'"):
  619. import_module("NS.othername.foo")
  620. with pytest.raises(ImportError, match="'ns\\.othername\\.FOO\\'"):
  621. import_module("ns.othername.FOO")
  622. with pytest.raises(ImportError, match="'ns\\.othername\\.foo\\.BAR\\'"):
  623. import_module("ns.othername.foo.BAR")
  624. def test_intermediate_packages(self, tmp_path):
  625. """
  626. The finder should not import ``fullname`` if the intermediate segments
  627. don't exist (see pypa/setuptools#4019).
  628. """
  629. files = {
  630. "src": {
  631. "mypkg": {
  632. "__init__.py": "",
  633. "config.py": "a = 13",
  634. "helloworld.py": "b = 13",
  635. "components": {
  636. "config.py": "a = 37",
  637. },
  638. },
  639. }
  640. }
  641. jaraco.path.build(files, prefix=tmp_path)
  642. mapping = {"mypkg": str(tmp_path / "src/mypkg")}
  643. template = _finder_template(str(uuid4()), mapping, {})
  644. with contexts.save_paths(), contexts.save_sys_modules():
  645. for mod in (
  646. "mypkg",
  647. "mypkg.config",
  648. "mypkg.helloworld",
  649. "mypkg.components",
  650. "mypkg.components.config",
  651. "mypkg.components.helloworld",
  652. ):
  653. sys.modules.pop(mod, None)
  654. self.install_finder(template)
  655. config = import_module("mypkg.components.config")
  656. assert config.a == 37
  657. helloworld = import_module("mypkg.helloworld")
  658. assert helloworld.b == 13
  659. with pytest.raises(ImportError):
  660. import_module("mypkg.components.helloworld")
  661. def test_pkg_roots(tmp_path):
  662. """This test focus in getting a particular implementation detail right.
  663. If at some point in time the implementation is changed for something different,
  664. this test can be modified or even excluded.
  665. """
  666. files = {
  667. "a": {"b": {"__init__.py": "ab = 1"}, "__init__.py": "a = 1"},
  668. "d": {"__init__.py": "d = 1", "e": {"__init__.py": "de = 1"}},
  669. "f": {"g": {"h": {"__init__.py": "fgh = 1"}}},
  670. "other": {"__init__.py": "abc = 1"},
  671. "another": {"__init__.py": "abcxyz = 1"},
  672. "yet_another": {"__init__.py": "mnopq = 1"},
  673. }
  674. jaraco.path.build(files, prefix=tmp_path)
  675. package_dir = {
  676. "a.b.c": "other",
  677. "a.b.c.x.y.z": "another",
  678. "m.n.o.p.q": "yet_another",
  679. }
  680. packages = [
  681. "a",
  682. "a.b",
  683. "a.b.c",
  684. "a.b.c.x.y",
  685. "a.b.c.x.y.z",
  686. "d",
  687. "d.e",
  688. "f",
  689. "f.g",
  690. "f.g.h",
  691. "m.n.o.p.q",
  692. ]
  693. roots = _find_package_roots(packages, package_dir, tmp_path)
  694. assert roots == {
  695. "a": str(tmp_path / "a"),
  696. "a.b.c": str(tmp_path / "other"),
  697. "a.b.c.x.y.z": str(tmp_path / "another"),
  698. "d": str(tmp_path / "d"),
  699. "f": str(tmp_path / "f"),
  700. "m.n.o.p.q": str(tmp_path / "yet_another"),
  701. }
  702. ns = set(dict(_find_namespaces(packages, roots)))
  703. assert ns == {"f", "f.g"}
  704. ns = set(_find_virtual_namespaces(roots))
  705. assert ns == {"a.b", "a.b.c.x", "a.b.c.x.y", "m", "m.n", "m.n.o", "m.n.o.p"}
  706. class TestOverallBehaviour:
  707. PYPROJECT = """\
  708. [build-system]
  709. requires = ["setuptools"]
  710. build-backend = "setuptools.build_meta"
  711. [project]
  712. name = "mypkg"
  713. version = "3.14159"
  714. """
  715. # Any: Would need a TypedDict. Keep it simple for tests
  716. FLAT_LAYOUT: dict[str, Any] = {
  717. "pyproject.toml": dedent(PYPROJECT),
  718. "MANIFEST.in": EXAMPLE["MANIFEST.in"],
  719. "otherfile.py": "",
  720. "mypkg": {
  721. "__init__.py": "",
  722. "mod1.py": "var = 42",
  723. "subpackage": {
  724. "__init__.py": "",
  725. "mod2.py": "var = 13",
  726. "resource_file.txt": "resource 39",
  727. },
  728. },
  729. }
  730. EXAMPLES = {
  731. "flat-layout": FLAT_LAYOUT,
  732. "src-layout": {
  733. "pyproject.toml": dedent(PYPROJECT),
  734. "MANIFEST.in": EXAMPLE["MANIFEST.in"],
  735. "otherfile.py": "",
  736. "src": {"mypkg": FLAT_LAYOUT["mypkg"]},
  737. },
  738. "custom-layout": {
  739. "pyproject.toml": dedent(PYPROJECT)
  740. + dedent(
  741. """\
  742. [tool.setuptools]
  743. packages = ["mypkg", "mypkg.subpackage"]
  744. [tool.setuptools.package-dir]
  745. "mypkg.subpackage" = "other"
  746. """
  747. ),
  748. "MANIFEST.in": EXAMPLE["MANIFEST.in"],
  749. "otherfile.py": "",
  750. "mypkg": {
  751. "__init__.py": "",
  752. "mod1.py": FLAT_LAYOUT["mypkg"]["mod1.py"],
  753. },
  754. "other": FLAT_LAYOUT["mypkg"]["subpackage"],
  755. },
  756. "namespace": {
  757. "pyproject.toml": dedent(PYPROJECT),
  758. "MANIFEST.in": EXAMPLE["MANIFEST.in"],
  759. "otherfile.py": "",
  760. "src": {
  761. "mypkg": {
  762. "mod1.py": FLAT_LAYOUT["mypkg"]["mod1.py"],
  763. "subpackage": FLAT_LAYOUT["mypkg"]["subpackage"],
  764. },
  765. },
  766. },
  767. }
  768. @pytest.mark.xfail(sys.platform == "darwin", reason="pypa/setuptools#4328")
  769. @pytest.mark.parametrize("layout", EXAMPLES.keys())
  770. def test_editable_install(self, tmp_path, venv, layout, editable_opts):
  771. project, _ = install_project(
  772. "mypkg", venv, tmp_path, self.EXAMPLES[layout], *editable_opts
  773. )
  774. # Ensure stray files are not importable
  775. cmd_import_error = """\
  776. try:
  777. import otherfile
  778. except ImportError as ex:
  779. print(ex)
  780. """
  781. out = venv.run(["python", "-c", dedent(cmd_import_error)])
  782. assert "No module named 'otherfile'" in out
  783. # Ensure the modules are importable
  784. cmd_get_vars = """\
  785. import mypkg, mypkg.mod1, mypkg.subpackage.mod2
  786. print(mypkg.mod1.var, mypkg.subpackage.mod2.var)
  787. """
  788. out = venv.run(["python", "-c", dedent(cmd_get_vars)])
  789. assert "42 13" in out
  790. # Ensure resources are reachable
  791. cmd_get_resource = """\
  792. import mypkg.subpackage
  793. from setuptools._importlib import resources as importlib_resources
  794. text = importlib_resources.files(mypkg.subpackage) / "resource_file.txt"
  795. print(text.read_text(encoding="utf-8"))
  796. """
  797. out = venv.run(["python", "-c", dedent(cmd_get_resource)])
  798. assert "resource 39" in out
  799. # Ensure files are editable
  800. mod1 = next(project.glob("**/mod1.py"))
  801. mod2 = next(project.glob("**/mod2.py"))
  802. resource_file = next(project.glob("**/resource_file.txt"))
  803. mod1.write_text("var = 17", encoding="utf-8")
  804. mod2.write_text("var = 781", encoding="utf-8")
  805. resource_file.write_text("resource 374", encoding="utf-8")
  806. out = venv.run(["python", "-c", dedent(cmd_get_vars)])
  807. assert "42 13" not in out
  808. assert "17 781" in out
  809. out = venv.run(["python", "-c", dedent(cmd_get_resource)])
  810. assert "resource 39" not in out
  811. assert "resource 374" in out
  812. class TestLinkTree:
  813. FILES = deepcopy(TestOverallBehaviour.EXAMPLES["src-layout"])
  814. FILES["pyproject.toml"] += dedent(
  815. """\
  816. [tool.setuptools]
  817. # Temporary workaround: both `include-package-data` and `package-data` configs
  818. # can be removed after #3260 is fixed.
  819. include-package-data = false
  820. package-data = {"*" = ["*.txt"]}
  821. [tool.setuptools.packages.find]
  822. where = ["src"]
  823. exclude = ["*.subpackage*"]
  824. """
  825. )
  826. FILES["src"]["mypkg"]["resource.not_in_manifest"] = "abc"
  827. def test_generated_tree(self, tmp_path):
  828. jaraco.path.build(self.FILES, prefix=tmp_path)
  829. with _Path(tmp_path):
  830. name = "mypkg-3.14159"
  831. dist = Distribution({"script_name": "%PEP 517%"})
  832. dist.parse_config_files()
  833. wheel = Mock()
  834. aux = tmp_path / ".aux"
  835. build = tmp_path / ".build"
  836. aux.mkdir()
  837. build.mkdir()
  838. build_py = dist.get_command_obj("build_py")
  839. build_py.editable_mode = True
  840. build_py.build_lib = str(build)
  841. build_py.ensure_finalized()
  842. outputs = build_py.get_outputs()
  843. output_mapping = build_py.get_output_mapping()
  844. make_tree = _LinkTree(dist, name, aux, build)
  845. make_tree(wheel, outputs, output_mapping)
  846. mod1 = next(aux.glob("**/mod1.py"))
  847. expected = tmp_path / "src/mypkg/mod1.py"
  848. assert_link_to(mod1, expected)
  849. assert next(aux.glob("**/subpackage"), None) is None
  850. assert next(aux.glob("**/mod2.py"), None) is None
  851. assert next(aux.glob("**/resource_file.txt"), None) is None
  852. assert next(aux.glob("**/resource.not_in_manifest"), None) is None
  853. def test_strict_install(self, tmp_path, venv):
  854. opts = ["--config-settings", "editable-mode=strict"]
  855. install_project("mypkg", venv, tmp_path, self.FILES, *opts)
  856. out = venv.run(["python", "-c", "import mypkg.mod1; print(mypkg.mod1.var)"])
  857. assert "42" in out
  858. # Ensure packages excluded from distribution are not importable
  859. cmd_import_error = """\
  860. try:
  861. from mypkg import subpackage
  862. except ImportError as ex:
  863. print(ex)
  864. """
  865. out = venv.run(["python", "-c", dedent(cmd_import_error)])
  866. assert "cannot import name 'subpackage'" in out
  867. # Ensure resource files excluded from distribution are not reachable
  868. cmd_get_resource = """\
  869. import mypkg
  870. from setuptools._importlib import resources as importlib_resources
  871. try:
  872. text = importlib_resources.files(mypkg) / "resource.not_in_manifest"
  873. print(text.read_text(encoding="utf-8"))
  874. except FileNotFoundError as ex:
  875. print(ex)
  876. """
  877. out = venv.run(["python", "-c", dedent(cmd_get_resource)])
  878. assert "No such file or directory" in out
  879. assert "resource.not_in_manifest" in out
  880. @pytest.mark.filterwarnings("ignore:.*compat.*:setuptools.SetuptoolsDeprecationWarning")
  881. def test_compat_install(tmp_path, venv):
  882. # TODO: Remove `compat` after Dec/2022.
  883. opts = ["--config-settings", "editable-mode=compat"]
  884. files = TestOverallBehaviour.EXAMPLES["custom-layout"]
  885. install_project("mypkg", venv, tmp_path, files, *opts)
  886. out = venv.run(["python", "-c", "import mypkg.mod1; print(mypkg.mod1.var)"])
  887. assert "42" in out
  888. expected_path = comparable_path(str(tmp_path))
  889. # Compatible behaviour will make spurious modules and excluded
  890. # files importable directly from the original path
  891. for cmd in (
  892. "import otherfile; print(otherfile)",
  893. "import other; print(other)",
  894. "import mypkg; print(mypkg)",
  895. ):
  896. out = comparable_path(venv.run(["python", "-c", cmd]))
  897. assert expected_path in out
  898. # Compatible behaviour will not consider custom mappings
  899. cmd = """\
  900. try:
  901. from mypkg import subpackage;
  902. except ImportError as ex:
  903. print(ex)
  904. """
  905. out = venv.run(["python", "-c", dedent(cmd)])
  906. assert "cannot import name 'subpackage'" in out
  907. @pytest.mark.uses_network
  908. def test_pbr_integration(pbr_package, venv, editable_opts):
  909. """Ensure editable installs work with pbr, issue #3500"""
  910. cmd = [
  911. 'python',
  912. '-m',
  913. 'pip',
  914. '-v',
  915. 'install',
  916. '--editable',
  917. pbr_package,
  918. *editable_opts,
  919. ]
  920. venv.run(cmd, stderr=subprocess.STDOUT)
  921. out = venv.run(["python", "-c", "import mypkg.hello"])
  922. assert "Hello world!" in out
  923. class TestCustomBuildPy:
  924. """
  925. Issue #3501 indicates that some plugins/customizations might rely on:
  926. 1. ``build_py`` not running
  927. 2. ``build_py`` always copying files to ``build_lib``
  928. During the transition period setuptools should prevent potential errors from
  929. happening due to those assumptions.
  930. """
  931. # TODO: Remove tests after _run_build_steps is removed.
  932. FILES = {
  933. **TestOverallBehaviour.EXAMPLES["flat-layout"],
  934. "setup.py": dedent(
  935. """\
  936. import pathlib
  937. from setuptools import setup
  938. from setuptools.command.build_py import build_py as orig
  939. class my_build_py(orig):
  940. def run(self):
  941. super().run()
  942. raise ValueError("TEST_RAISE")
  943. setup(cmdclass={"build_py": my_build_py})
  944. """
  945. ),
  946. }
  947. def test_safeguarded_from_errors(self, tmp_path, venv):
  948. """Ensure that errors in custom build_py are reported as warnings"""
  949. # Warnings should show up
  950. _, out = install_project("mypkg", venv, tmp_path, self.FILES)
  951. assert "SetuptoolsDeprecationWarning" in out
  952. assert "ValueError: TEST_RAISE" in out
  953. # but installation should be successful
  954. out = venv.run(["python", "-c", "import mypkg.mod1; print(mypkg.mod1.var)"])
  955. assert "42" in out
  956. class TestCustomBuildWheel:
  957. def install_custom_build_wheel(self, dist):
  958. bdist_wheel_cls = dist.get_command_class("bdist_wheel")
  959. class MyBdistWheel(bdist_wheel_cls):
  960. def get_tag(self):
  961. # In issue #3513, we can see that some extensions may try to access
  962. # the `plat_name` property in bdist_wheel
  963. if self.plat_name.startswith("macosx-"):
  964. _ = "macOS platform"
  965. return super().get_tag()
  966. dist.cmdclass["bdist_wheel"] = MyBdistWheel
  967. def test_access_plat_name(self, tmpdir_cwd):
  968. # Even when a custom bdist_wheel tries to access plat_name the build should
  969. # be successful
  970. jaraco.path.build({"module.py": "x = 42"})
  971. dist = Distribution()
  972. dist.script_name = "setup.py"
  973. dist.set_defaults()
  974. self.install_custom_build_wheel(dist)
  975. cmd = editable_wheel(dist)
  976. cmd.ensure_finalized()
  977. cmd.run()
  978. wheel_file = str(next(Path().glob('dist/*.whl')))
  979. assert "editable" in wheel_file
  980. class TestCustomBuildExt:
  981. def install_custom_build_ext_distutils(self, dist):
  982. from distutils.command.build_ext import build_ext as build_ext_cls
  983. class MyBuildExt(build_ext_cls):
  984. pass
  985. dist.cmdclass["build_ext"] = MyBuildExt
  986. @pytest.mark.skipif(
  987. sys.platform != "linux", reason="compilers may fail without correct setup"
  988. )
  989. def test_distutils_leave_inplace_files(self, tmpdir_cwd):
  990. jaraco.path.build({"module.c": ""})
  991. attrs = {
  992. "ext_modules": [Extension("module", ["module.c"])],
  993. }
  994. dist = Distribution(attrs)
  995. dist.script_name = "setup.py"
  996. dist.set_defaults()
  997. self.install_custom_build_ext_distutils(dist)
  998. cmd = editable_wheel(dist)
  999. cmd.ensure_finalized()
  1000. cmd.run()
  1001. wheel_file = str(next(Path().glob('dist/*.whl')))
  1002. assert "editable" in wheel_file
  1003. files = [p for p in Path().glob("module.*") if p.suffix != ".c"]
  1004. assert len(files) == 1
  1005. name = files[0].name
  1006. assert any(name.endswith(ext) for ext in EXTENSION_SUFFIXES)
  1007. def test_debugging_tips(tmpdir_cwd, monkeypatch):
  1008. """Make sure to display useful debugging tips to the user."""
  1009. jaraco.path.build({"module.py": "x = 42"})
  1010. dist = Distribution()
  1011. dist.script_name = "setup.py"
  1012. dist.set_defaults()
  1013. cmd = editable_wheel(dist)
  1014. cmd.ensure_finalized()
  1015. SimulatedErr = type("SimulatedErr", (Exception,), {})
  1016. simulated_failure = Mock(side_effect=SimulatedErr())
  1017. monkeypatch.setattr(cmd, "get_finalized_command", simulated_failure)
  1018. with pytest.raises(SimulatedErr) as ctx:
  1019. cmd.run()
  1020. assert any('debugging-tips' in note for note in ctx.value.__notes__)
  1021. @pytest.mark.filterwarnings("error")
  1022. def test_encode_pth():
  1023. """Ensure _encode_pth function does not produce encoding warnings"""
  1024. content = _encode_pth("tkmilan_ç_utf8") # no warnings (would be turned into errors)
  1025. assert isinstance(content, bytes)
  1026. def install_project(name, venv, tmp_path, files, *opts):
  1027. project = tmp_path / name
  1028. project.mkdir()
  1029. jaraco.path.build(files, prefix=project)
  1030. opts = [*opts, "--no-build-isolation"] # force current version of setuptools
  1031. out = venv.run(
  1032. ["python", "-m", "pip", "-v", "install", "-e", str(project), *opts],
  1033. stderr=subprocess.STDOUT,
  1034. )
  1035. return project, out
  1036. def _addsitedirs(new_dirs):
  1037. """To use this function, it is necessary to insert new_dir in front of sys.path.
  1038. The Python process will try to import a ``sitecustomize`` module on startup.
  1039. If we manipulate sys.path/PYTHONPATH, we can force it to run our code,
  1040. which invokes ``addsitedir`` and ensure ``.pth`` files are loaded.
  1041. """
  1042. content = '\n'.join(
  1043. ("import site",)
  1044. + tuple(f"site.addsitedir({os.fspath(new_dir)!r})" for new_dir in new_dirs)
  1045. )
  1046. (new_dirs[0] / "sitecustomize.py").write_text(content, encoding="utf-8")
  1047. # ---- Assertion Helpers ----
  1048. def assert_path(pkg, expected):
  1049. # __path__ is not guaranteed to exist, so we have to account for that
  1050. if pkg.__path__:
  1051. path = next(iter(pkg.__path__), None)
  1052. if path:
  1053. assert str(Path(path).resolve()) == expected
  1054. def assert_link_to(file: Path, other: Path) -> None:
  1055. if file.is_symlink():
  1056. assert str(file.resolve()) == str(other.resolve())
  1057. else:
  1058. file_stat = file.stat()
  1059. other_stat = other.stat()
  1060. assert file_stat[stat.ST_INO] == other_stat[stat.ST_INO]
  1061. assert file_stat[stat.ST_DEV] == other_stat[stat.ST_DEV]
  1062. def comparable_path(str_with_path: str) -> str:
  1063. return str_with_path.lower().replace(os.sep, "/").replace("//", "/")