test_build_py.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. import os
  2. import shutil
  3. import stat
  4. import warnings
  5. from pathlib import Path
  6. from unittest.mock import Mock
  7. import jaraco.path
  8. import pytest
  9. from setuptools import SetuptoolsDeprecationWarning
  10. from setuptools.dist import Distribution
  11. from .textwrap import DALS
  12. def test_directories_in_package_data_glob(tmpdir_cwd):
  13. """
  14. Directories matching the glob in package_data should
  15. not be included in the package data.
  16. Regression test for #261.
  17. """
  18. dist = Distribution(
  19. dict(
  20. script_name='setup.py',
  21. script_args=['build_py'],
  22. packages=[''],
  23. package_data={'': ['path/*']},
  24. )
  25. )
  26. os.makedirs('path/subpath')
  27. dist.parse_command_line()
  28. dist.run_commands()
  29. def test_recursive_in_package_data_glob(tmpdir_cwd):
  30. """
  31. Files matching recursive globs (**) in package_data should
  32. be included in the package data.
  33. #1806
  34. """
  35. dist = Distribution(
  36. dict(
  37. script_name='setup.py',
  38. script_args=['build_py'],
  39. packages=[''],
  40. package_data={'': ['path/**/data']},
  41. )
  42. )
  43. os.makedirs('path/subpath/subsubpath')
  44. open('path/subpath/subsubpath/data', 'wb').close()
  45. dist.parse_command_line()
  46. dist.run_commands()
  47. assert stat.S_ISREG(os.stat('build/lib/path/subpath/subsubpath/data').st_mode), (
  48. "File is not included"
  49. )
  50. def test_read_only(tmpdir_cwd):
  51. """
  52. Ensure read-only flag is not preserved in copy
  53. for package modules and package data, as that
  54. causes problems with deleting read-only files on
  55. Windows.
  56. #1451
  57. """
  58. dist = Distribution(
  59. dict(
  60. script_name='setup.py',
  61. script_args=['build_py'],
  62. packages=['pkg'],
  63. package_data={'pkg': ['data.dat']},
  64. )
  65. )
  66. os.makedirs('pkg')
  67. open('pkg/__init__.py', 'wb').close()
  68. open('pkg/data.dat', 'wb').close()
  69. os.chmod('pkg/__init__.py', stat.S_IREAD)
  70. os.chmod('pkg/data.dat', stat.S_IREAD)
  71. dist.parse_command_line()
  72. dist.run_commands()
  73. shutil.rmtree('build')
  74. @pytest.mark.xfail(
  75. 'platform.system() == "Windows"',
  76. reason="On Windows, files do not have executable bits",
  77. raises=AssertionError,
  78. strict=True,
  79. )
  80. def test_executable_data(tmpdir_cwd):
  81. """
  82. Ensure executable bit is preserved in copy for
  83. package data, as users rely on it for scripts.
  84. #2041
  85. """
  86. dist = Distribution(
  87. dict(
  88. script_name='setup.py',
  89. script_args=['build_py'],
  90. packages=['pkg'],
  91. package_data={'pkg': ['run-me']},
  92. )
  93. )
  94. os.makedirs('pkg')
  95. open('pkg/__init__.py', 'wb').close()
  96. open('pkg/run-me', 'wb').close()
  97. os.chmod('pkg/run-me', 0o700)
  98. dist.parse_command_line()
  99. dist.run_commands()
  100. assert os.stat('build/lib/pkg/run-me').st_mode & stat.S_IEXEC, (
  101. "Script is not executable"
  102. )
  103. EXAMPLE_WITH_MANIFEST = {
  104. "setup.cfg": DALS(
  105. """
  106. [metadata]
  107. name = mypkg
  108. version = 42
  109. [options]
  110. include_package_data = True
  111. packages = find:
  112. [options.packages.find]
  113. exclude = *.tests*
  114. """
  115. ),
  116. "mypkg": {
  117. "__init__.py": "",
  118. "resource_file.txt": "",
  119. "tests": {
  120. "__init__.py": "",
  121. "test_mypkg.py": "",
  122. "test_file.txt": "",
  123. },
  124. },
  125. "MANIFEST.in": DALS(
  126. """
  127. global-include *.py *.txt
  128. global-exclude *.py[cod]
  129. prune dist
  130. prune build
  131. prune *.egg-info
  132. """
  133. ),
  134. }
  135. def test_excluded_subpackages(tmpdir_cwd):
  136. jaraco.path.build(EXAMPLE_WITH_MANIFEST)
  137. dist = Distribution({"script_name": "%PEP 517%"})
  138. dist.parse_config_files()
  139. build_py = dist.get_command_obj("build_py")
  140. msg = r"Python recognizes 'mypkg\.tests' as an importable package"
  141. with pytest.warns(SetuptoolsDeprecationWarning, match=msg): # noqa: PT031
  142. # TODO: To fix #3260 we need some transition period to deprecate the
  143. # existing behavior of `include_package_data`. After the transition, we
  144. # should remove the warning and fix the behavior.
  145. if os.getenv("SETUPTOOLS_USE_DISTUTILS") == "stdlib":
  146. # pytest.warns reset the warning filter temporarily
  147. # https://github.com/pytest-dev/pytest/issues/4011#issuecomment-423494810
  148. warnings.filterwarnings(
  149. "ignore",
  150. "'encoding' argument not specified",
  151. module="distutils.text_file",
  152. # This warning is already fixed in pypa/distutils but not in stdlib
  153. )
  154. build_py.finalize_options()
  155. build_py.run()
  156. build_dir = Path(dist.get_command_obj("build_py").build_lib)
  157. assert (build_dir / "mypkg/__init__.py").exists()
  158. assert (build_dir / "mypkg/resource_file.txt").exists()
  159. # Setuptools is configured to ignore `mypkg.tests`, therefore the following
  160. # files/dirs should not be included in the distribution.
  161. for f in [
  162. "mypkg/tests/__init__.py",
  163. "mypkg/tests/test_mypkg.py",
  164. "mypkg/tests/test_file.txt",
  165. "mypkg/tests",
  166. ]:
  167. with pytest.raises(AssertionError):
  168. # TODO: Enforce the following assertion once #3260 is fixed
  169. # (remove context manager and the following xfail).
  170. assert not (build_dir / f).exists()
  171. pytest.xfail("#3260")
  172. @pytest.mark.filterwarnings("ignore::setuptools.SetuptoolsDeprecationWarning")
  173. def test_existing_egg_info(tmpdir_cwd, monkeypatch):
  174. """When provided with the ``existing_egg_info_dir`` attribute, build_py should not
  175. attempt to run egg_info again.
  176. """
  177. # == Pre-condition ==
  178. # Generate an egg-info dir
  179. jaraco.path.build(EXAMPLE_WITH_MANIFEST)
  180. dist = Distribution({"script_name": "%PEP 517%"})
  181. dist.parse_config_files()
  182. assert dist.include_package_data
  183. egg_info = dist.get_command_obj("egg_info")
  184. dist.run_command("egg_info")
  185. egg_info_dir = next(Path(egg_info.egg_base).glob("*.egg-info"))
  186. assert egg_info_dir.is_dir()
  187. # == Setup ==
  188. build_py = dist.get_command_obj("build_py")
  189. build_py.finalize_options()
  190. egg_info = dist.get_command_obj("egg_info")
  191. egg_info_run = Mock(side_effect=egg_info.run)
  192. monkeypatch.setattr(egg_info, "run", egg_info_run)
  193. # == Remove caches ==
  194. # egg_info is called when build_py looks for data_files, which gets cached.
  195. # We need to ensure it is not cached yet, otherwise it may impact on the tests
  196. build_py.__dict__.pop('data_files', None)
  197. dist.reinitialize_command(egg_info)
  198. # == Sanity check ==
  199. # Ensure that if existing_egg_info is not given, build_py attempts to run egg_info
  200. build_py.existing_egg_info_dir = None
  201. build_py.run()
  202. egg_info_run.assert_called()
  203. # == Remove caches ==
  204. egg_info_run.reset_mock()
  205. build_py.__dict__.pop('data_files', None)
  206. dist.reinitialize_command(egg_info)
  207. # == Actual test ==
  208. # Ensure that if existing_egg_info_dir is given, egg_info doesn't run
  209. build_py.existing_egg_info_dir = egg_info_dir
  210. build_py.run()
  211. egg_info_run.assert_not_called()
  212. assert build_py.data_files
  213. # Make sure the list of outputs is actually OK
  214. outputs = map(lambda x: x.replace(os.sep, "/"), build_py.get_outputs())
  215. assert outputs
  216. example = str(Path(build_py.build_lib, "mypkg/__init__.py")).replace(os.sep, "/")
  217. assert example in outputs
  218. EXAMPLE_ARBITRARY_MAPPING = {
  219. "pyproject.toml": DALS(
  220. """
  221. [project]
  222. name = "mypkg"
  223. version = "42"
  224. [tool.setuptools]
  225. packages = ["mypkg", "mypkg.sub1", "mypkg.sub2", "mypkg.sub2.nested"]
  226. [tool.setuptools.package-dir]
  227. "" = "src"
  228. "mypkg.sub2" = "src/mypkg/_sub2"
  229. "mypkg.sub2.nested" = "other"
  230. """
  231. ),
  232. "src": {
  233. "mypkg": {
  234. "__init__.py": "",
  235. "resource_file.txt": "",
  236. "sub1": {
  237. "__init__.py": "",
  238. "mod1.py": "",
  239. },
  240. "_sub2": {
  241. "mod2.py": "",
  242. },
  243. },
  244. },
  245. "other": {
  246. "__init__.py": "",
  247. "mod3.py": "",
  248. },
  249. "MANIFEST.in": DALS(
  250. """
  251. global-include *.py *.txt
  252. global-exclude *.py[cod]
  253. """
  254. ),
  255. }
  256. def test_get_outputs(tmpdir_cwd):
  257. jaraco.path.build(EXAMPLE_ARBITRARY_MAPPING)
  258. dist = Distribution({"script_name": "%test%"})
  259. dist.parse_config_files()
  260. build_py = dist.get_command_obj("build_py")
  261. build_py.editable_mode = True
  262. build_py.ensure_finalized()
  263. build_lib = build_py.build_lib.replace(os.sep, "/")
  264. outputs = {x.replace(os.sep, "/") for x in build_py.get_outputs()}
  265. assert outputs == {
  266. f"{build_lib}/mypkg/__init__.py",
  267. f"{build_lib}/mypkg/resource_file.txt",
  268. f"{build_lib}/mypkg/sub1/__init__.py",
  269. f"{build_lib}/mypkg/sub1/mod1.py",
  270. f"{build_lib}/mypkg/sub2/mod2.py",
  271. f"{build_lib}/mypkg/sub2/nested/__init__.py",
  272. f"{build_lib}/mypkg/sub2/nested/mod3.py",
  273. }
  274. mapping = {
  275. k.replace(os.sep, "/"): v.replace(os.sep, "/")
  276. for k, v in build_py.get_output_mapping().items()
  277. }
  278. assert mapping == {
  279. f"{build_lib}/mypkg/__init__.py": "src/mypkg/__init__.py",
  280. f"{build_lib}/mypkg/resource_file.txt": "src/mypkg/resource_file.txt",
  281. f"{build_lib}/mypkg/sub1/__init__.py": "src/mypkg/sub1/__init__.py",
  282. f"{build_lib}/mypkg/sub1/mod1.py": "src/mypkg/sub1/mod1.py",
  283. f"{build_lib}/mypkg/sub2/mod2.py": "src/mypkg/_sub2/mod2.py",
  284. f"{build_lib}/mypkg/sub2/nested/__init__.py": "other/__init__.py",
  285. f"{build_lib}/mypkg/sub2/nested/mod3.py": "other/mod3.py",
  286. }
  287. class TestTypeInfoFiles:
  288. PYPROJECTS = {
  289. "default_pyproject": DALS(
  290. """
  291. [project]
  292. name = "foo"
  293. version = "1"
  294. """
  295. ),
  296. "dont_include_package_data": DALS(
  297. """
  298. [project]
  299. name = "foo"
  300. version = "1"
  301. [tool.setuptools]
  302. include-package-data = false
  303. """
  304. ),
  305. "exclude_type_info": DALS(
  306. """
  307. [project]
  308. name = "foo"
  309. version = "1"
  310. [tool.setuptools]
  311. include-package-data = false
  312. [tool.setuptools.exclude-package-data]
  313. "*" = ["py.typed", "*.pyi"]
  314. """
  315. ),
  316. }
  317. EXAMPLES = {
  318. "simple_namespace": {
  319. "directory_structure": {
  320. "foo": {
  321. "bar.pyi": "",
  322. "py.typed": "",
  323. "__init__.py": "",
  324. }
  325. },
  326. "expected_type_files": {"foo/bar.pyi", "foo/py.typed"},
  327. },
  328. "nested_inside_namespace": {
  329. "directory_structure": {
  330. "foo": {
  331. "bar": {
  332. "py.typed": "",
  333. "mod.pyi": "",
  334. }
  335. }
  336. },
  337. "expected_type_files": {"foo/bar/mod.pyi", "foo/bar/py.typed"},
  338. },
  339. "namespace_nested_inside_regular": {
  340. "directory_structure": {
  341. "foo": {
  342. "namespace": {
  343. "foo.pyi": "",
  344. },
  345. "__init__.pyi": "",
  346. "py.typed": "",
  347. }
  348. },
  349. "expected_type_files": {
  350. "foo/namespace/foo.pyi",
  351. "foo/__init__.pyi",
  352. "foo/py.typed",
  353. },
  354. },
  355. }
  356. @pytest.mark.parametrize(
  357. "pyproject",
  358. [
  359. "default_pyproject",
  360. pytest.param(
  361. "dont_include_package_data",
  362. marks=pytest.mark.xfail(reason="pypa/setuptools#4350"),
  363. ),
  364. ],
  365. )
  366. @pytest.mark.parametrize("example", EXAMPLES.keys())
  367. def test_type_files_included_by_default(self, tmpdir_cwd, pyproject, example):
  368. structure = {
  369. **self.EXAMPLES[example]["directory_structure"],
  370. "pyproject.toml": self.PYPROJECTS[pyproject],
  371. }
  372. expected_type_files = self.EXAMPLES[example]["expected_type_files"]
  373. jaraco.path.build(structure)
  374. build_py = get_finalized_build_py()
  375. outputs = get_outputs(build_py)
  376. assert expected_type_files <= outputs
  377. @pytest.mark.parametrize("pyproject", ["exclude_type_info"])
  378. @pytest.mark.parametrize("example", EXAMPLES.keys())
  379. def test_type_files_can_be_excluded(self, tmpdir_cwd, pyproject, example):
  380. structure = {
  381. **self.EXAMPLES[example]["directory_structure"],
  382. "pyproject.toml": self.PYPROJECTS[pyproject],
  383. }
  384. expected_type_files = self.EXAMPLES[example]["expected_type_files"]
  385. jaraco.path.build(structure)
  386. build_py = get_finalized_build_py()
  387. outputs = get_outputs(build_py)
  388. assert expected_type_files.isdisjoint(outputs)
  389. def test_stub_only_package(self, tmpdir_cwd):
  390. structure = {
  391. "pyproject.toml": DALS(
  392. """
  393. [project]
  394. name = "foo-stubs"
  395. version = "1"
  396. """
  397. ),
  398. "foo-stubs": {"__init__.pyi": "", "bar.pyi": ""},
  399. }
  400. expected_type_files = {"foo-stubs/__init__.pyi", "foo-stubs/bar.pyi"}
  401. jaraco.path.build(structure)
  402. build_py = get_finalized_build_py()
  403. outputs = get_outputs(build_py)
  404. assert expected_type_files <= outputs
  405. def get_finalized_build_py(script_name="%build_py-test%"):
  406. dist = Distribution({"script_name": script_name})
  407. dist.parse_config_files()
  408. build_py = dist.get_command_obj("build_py")
  409. build_py.finalize_options()
  410. return build_py
  411. def get_outputs(build_py):
  412. build_dir = Path(build_py.build_lib)
  413. return {
  414. os.path.relpath(x, build_dir).replace(os.sep, "/")
  415. for x in build_py.get_outputs()
  416. }