test_build_ext.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. from __future__ import annotations
  2. import os
  3. import sys
  4. from importlib.util import cache_from_source as _compiled_file_name
  5. import pytest
  6. from jaraco import path
  7. from setuptools.command.build_ext import build_ext, get_abi3_suffix
  8. from setuptools.dist import Distribution
  9. from setuptools.errors import CompileError
  10. from setuptools.extension import Extension
  11. from . import environment
  12. from .textwrap import DALS
  13. import distutils.command.build_ext as orig
  14. from distutils.sysconfig import get_config_var
  15. IS_PYPY = '__pypy__' in sys.builtin_module_names
  16. class TestBuildExt:
  17. def test_get_ext_filename(self):
  18. """
  19. Setuptools needs to give back the same
  20. result as distutils, even if the fullname
  21. is not in ext_map.
  22. """
  23. dist = Distribution()
  24. cmd = build_ext(dist)
  25. cmd.ext_map['foo/bar'] = ''
  26. res = cmd.get_ext_filename('foo')
  27. wanted = orig.build_ext.get_ext_filename(cmd, 'foo')
  28. assert res == wanted
  29. def test_abi3_filename(self):
  30. """
  31. Filename needs to be loadable by several versions
  32. of Python 3 if 'is_abi3' is truthy on Extension()
  33. """
  34. print(get_abi3_suffix())
  35. extension = Extension('spam.eggs', ['eggs.c'], py_limited_api=True)
  36. dist = Distribution(dict(ext_modules=[extension]))
  37. cmd = build_ext(dist)
  38. cmd.finalize_options()
  39. assert 'spam.eggs' in cmd.ext_map
  40. res = cmd.get_ext_filename('spam.eggs')
  41. if not get_abi3_suffix():
  42. assert res.endswith(get_config_var('EXT_SUFFIX'))
  43. elif sys.platform == 'win32':
  44. assert res.endswith('eggs.pyd')
  45. else:
  46. assert 'abi3' in res
  47. def test_ext_suffix_override(self):
  48. """
  49. SETUPTOOLS_EXT_SUFFIX variable always overrides
  50. default extension options.
  51. """
  52. dist = Distribution()
  53. cmd = build_ext(dist)
  54. cmd.ext_map['for_abi3'] = ext = Extension(
  55. 'for_abi3',
  56. ['s.c'],
  57. # Override shouldn't affect abi3 modules
  58. py_limited_api=True,
  59. )
  60. # Mock value needed to pass tests
  61. ext._links_to_dynamic = False
  62. if not IS_PYPY:
  63. expect = cmd.get_ext_filename('for_abi3')
  64. else:
  65. # PyPy builds do not use ABI3 tag, so they will
  66. # also get the overridden suffix.
  67. expect = 'for_abi3.test-suffix'
  68. try:
  69. os.environ['SETUPTOOLS_EXT_SUFFIX'] = '.test-suffix'
  70. res = cmd.get_ext_filename('normal')
  71. assert 'normal.test-suffix' == res
  72. res = cmd.get_ext_filename('for_abi3')
  73. assert expect == res
  74. finally:
  75. del os.environ['SETUPTOOLS_EXT_SUFFIX']
  76. def dist_with_example(self):
  77. files = {
  78. "src": {"mypkg": {"subpkg": {"ext2.c": ""}}},
  79. "c-extensions": {"ext1": {"main.c": ""}},
  80. }
  81. ext1 = Extension("mypkg.ext1", ["c-extensions/ext1/main.c"])
  82. ext2 = Extension("mypkg.subpkg.ext2", ["src/mypkg/subpkg/ext2.c"])
  83. ext3 = Extension("ext3", ["c-extension/ext3.c"])
  84. path.build(files)
  85. return Distribution({
  86. "script_name": "%test%",
  87. "ext_modules": [ext1, ext2, ext3],
  88. "package_dir": {"": "src"},
  89. })
  90. def test_get_outputs(self, tmpdir_cwd, monkeypatch):
  91. monkeypatch.setenv('SETUPTOOLS_EXT_SUFFIX', '.mp3') # make test OS-independent
  92. monkeypatch.setattr('setuptools.command.build_ext.use_stubs', False)
  93. dist = self.dist_with_example()
  94. # Regular build: get_outputs not empty, but get_output_mappings is empty
  95. build_ext = dist.get_command_obj("build_ext")
  96. build_ext.editable_mode = False
  97. build_ext.ensure_finalized()
  98. build_lib = build_ext.build_lib.replace(os.sep, "/")
  99. outputs = [x.replace(os.sep, "/") for x in build_ext.get_outputs()]
  100. assert outputs == [
  101. f"{build_lib}/ext3.mp3",
  102. f"{build_lib}/mypkg/ext1.mp3",
  103. f"{build_lib}/mypkg/subpkg/ext2.mp3",
  104. ]
  105. assert build_ext.get_output_mapping() == {}
  106. # Editable build: get_output_mappings should contain everything in get_outputs
  107. dist.reinitialize_command("build_ext")
  108. build_ext.editable_mode = True
  109. build_ext.ensure_finalized()
  110. mapping = {
  111. k.replace(os.sep, "/"): v.replace(os.sep, "/")
  112. for k, v in build_ext.get_output_mapping().items()
  113. }
  114. assert mapping == {
  115. f"{build_lib}/ext3.mp3": "src/ext3.mp3",
  116. f"{build_lib}/mypkg/ext1.mp3": "src/mypkg/ext1.mp3",
  117. f"{build_lib}/mypkg/subpkg/ext2.mp3": "src/mypkg/subpkg/ext2.mp3",
  118. }
  119. def test_get_output_mapping_with_stub(self, tmpdir_cwd, monkeypatch):
  120. monkeypatch.setenv('SETUPTOOLS_EXT_SUFFIX', '.mp3') # make test OS-independent
  121. monkeypatch.setattr('setuptools.command.build_ext.use_stubs', True)
  122. dist = self.dist_with_example()
  123. # Editable build should create compiled stubs (.pyc files only, no .py)
  124. build_ext = dist.get_command_obj("build_ext")
  125. build_ext.editable_mode = True
  126. build_ext.ensure_finalized()
  127. for ext in build_ext.extensions:
  128. monkeypatch.setattr(ext, "_needs_stub", True)
  129. build_lib = build_ext.build_lib.replace(os.sep, "/")
  130. mapping = {
  131. k.replace(os.sep, "/"): v.replace(os.sep, "/")
  132. for k, v in build_ext.get_output_mapping().items()
  133. }
  134. def C(file):
  135. """Make it possible to do comparisons and tests in a OS-independent way"""
  136. return _compiled_file_name(file).replace(os.sep, "/")
  137. assert mapping == {
  138. C(f"{build_lib}/ext3.py"): C("src/ext3.py"),
  139. f"{build_lib}/ext3.mp3": "src/ext3.mp3",
  140. C(f"{build_lib}/mypkg/ext1.py"): C("src/mypkg/ext1.py"),
  141. f"{build_lib}/mypkg/ext1.mp3": "src/mypkg/ext1.mp3",
  142. C(f"{build_lib}/mypkg/subpkg/ext2.py"): C("src/mypkg/subpkg/ext2.py"),
  143. f"{build_lib}/mypkg/subpkg/ext2.mp3": "src/mypkg/subpkg/ext2.mp3",
  144. }
  145. # Ensure only the compiled stubs are present not the raw .py stub
  146. assert f"{build_lib}/mypkg/ext1.py" not in mapping
  147. assert f"{build_lib}/mypkg/subpkg/ext2.py" not in mapping
  148. # Visualize what the cached stub files look like
  149. example_stub = C(f"{build_lib}/mypkg/ext1.py")
  150. assert example_stub in mapping
  151. assert example_stub.startswith(f"{build_lib}/mypkg/__pycache__/ext1")
  152. assert example_stub.endswith(".pyc")
  153. class TestBuildExtInplace:
  154. def get_build_ext_cmd(self, optional: bool, **opts) -> build_ext:
  155. files: dict[str, str | dict[str, dict[str, str]]] = {
  156. "eggs.c": "#include missingheader.h\n",
  157. ".build": {"lib": {}, "tmp": {}},
  158. }
  159. path.build(files)
  160. extension = Extension('spam.eggs', ['eggs.c'], optional=optional)
  161. dist = Distribution(dict(ext_modules=[extension]))
  162. dist.script_name = 'setup.py'
  163. cmd = build_ext(dist)
  164. vars(cmd).update(build_lib=".build/lib", build_temp=".build/tmp", **opts)
  165. cmd.ensure_finalized()
  166. return cmd
  167. def get_log_messages(self, caplog, capsys):
  168. """
  169. Historically, distutils "logged" by printing to sys.std*.
  170. Later versions adopted the logging framework. Grab
  171. messages regardless of how they were captured.
  172. """
  173. std = capsys.readouterr()
  174. return std.out.splitlines() + std.err.splitlines() + caplog.messages
  175. def test_optional(self, tmpdir_cwd, caplog, capsys):
  176. """
  177. If optional extensions fail to build, setuptools should show the error
  178. in the logs but not fail to build
  179. """
  180. cmd = self.get_build_ext_cmd(optional=True, inplace=True)
  181. cmd.run()
  182. assert any(
  183. 'build_ext: building extension "spam.eggs" failed'
  184. for msg in self.get_log_messages(caplog, capsys)
  185. )
  186. # No compile error exception should be raised
  187. def test_non_optional(self, tmpdir_cwd):
  188. # Non-optional extensions should raise an exception
  189. cmd = self.get_build_ext_cmd(optional=False, inplace=True)
  190. with pytest.raises(CompileError):
  191. cmd.run()
  192. def test_build_ext_config_handling(tmpdir_cwd):
  193. files = {
  194. 'setup.py': DALS(
  195. """
  196. from setuptools import Extension, setup
  197. setup(
  198. name='foo',
  199. version='0.0.0',
  200. ext_modules=[Extension('foo', ['foo.c'])],
  201. )
  202. """
  203. ),
  204. 'foo.c': DALS(
  205. """
  206. #include "Python.h"
  207. #if PY_MAJOR_VERSION >= 3
  208. static struct PyModuleDef moduledef = {
  209. PyModuleDef_HEAD_INIT,
  210. "foo",
  211. NULL,
  212. 0,
  213. NULL,
  214. NULL,
  215. NULL,
  216. NULL,
  217. NULL
  218. };
  219. #define INITERROR return NULL
  220. PyMODINIT_FUNC PyInit_foo(void)
  221. #else
  222. #define INITERROR return
  223. void initfoo(void)
  224. #endif
  225. {
  226. #if PY_MAJOR_VERSION >= 3
  227. PyObject *module = PyModule_Create(&moduledef);
  228. #else
  229. PyObject *module = Py_InitModule("extension", NULL);
  230. #endif
  231. if (module == NULL)
  232. INITERROR;
  233. #if PY_MAJOR_VERSION >= 3
  234. return module;
  235. #endif
  236. }
  237. """
  238. ),
  239. 'setup.cfg': DALS(
  240. """
  241. [build]
  242. build_base = foo_build
  243. """
  244. ),
  245. }
  246. path.build(files)
  247. code, (stdout, stderr) = environment.run_setup_py(
  248. cmd=['build'],
  249. data_stream=(0, 2),
  250. )
  251. assert code == 0, f'\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}'