test_wheel.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690
  1. """wheel tests"""
  2. from __future__ import annotations
  3. import contextlib
  4. import glob
  5. import inspect
  6. import os
  7. import pathlib
  8. import stat
  9. import subprocess
  10. import sys
  11. import sysconfig
  12. import zipfile
  13. from typing import Any
  14. import pytest
  15. from jaraco import path
  16. from packaging.tags import parse_tag
  17. from setuptools._importlib import metadata
  18. from setuptools.wheel import Wheel
  19. from .contexts import tempdir
  20. from .textwrap import DALS
  21. from distutils.sysconfig import get_config_var
  22. from distutils.util import get_platform
  23. WHEEL_INFO_TESTS = (
  24. ('invalid.whl', ValueError),
  25. (
  26. 'simplewheel-2.0-1-py2.py3-none-any.whl',
  27. {
  28. 'project_name': 'simplewheel',
  29. 'version': '2.0',
  30. 'build': '1',
  31. 'py_version': 'py2.py3',
  32. 'abi': 'none',
  33. 'platform': 'any',
  34. },
  35. ),
  36. (
  37. 'simple.dist-0.1-py2.py3-none-any.whl',
  38. {
  39. 'project_name': 'simple.dist',
  40. 'version': '0.1',
  41. 'build': None,
  42. 'py_version': 'py2.py3',
  43. 'abi': 'none',
  44. 'platform': 'any',
  45. },
  46. ),
  47. (
  48. 'example_pkg_a-1-py3-none-any.whl',
  49. {
  50. 'project_name': 'example_pkg_a',
  51. 'version': '1',
  52. 'build': None,
  53. 'py_version': 'py3',
  54. 'abi': 'none',
  55. 'platform': 'any',
  56. },
  57. ),
  58. (
  59. 'PyQt5-5.9-5.9.1-cp35.cp36.cp37-abi3-manylinux1_x86_64.whl',
  60. {
  61. 'project_name': 'PyQt5',
  62. 'version': '5.9',
  63. 'build': '5.9.1',
  64. 'py_version': 'cp35.cp36.cp37',
  65. 'abi': 'abi3',
  66. 'platform': 'manylinux1_x86_64',
  67. },
  68. ),
  69. )
  70. @pytest.mark.parametrize(
  71. ('filename', 'info'), WHEEL_INFO_TESTS, ids=[t[0] for t in WHEEL_INFO_TESTS]
  72. )
  73. def test_wheel_info(filename, info):
  74. if inspect.isclass(info):
  75. with pytest.raises(info):
  76. Wheel(filename)
  77. return
  78. w = Wheel(filename)
  79. assert {k: getattr(w, k) for k in info.keys()} == info
  80. @contextlib.contextmanager
  81. def build_wheel(extra_file_defs=None, **kwargs):
  82. file_defs = {
  83. 'setup.py': (
  84. DALS(
  85. """
  86. # -*- coding: utf-8 -*-
  87. from setuptools import setup
  88. import setuptools
  89. setup(**%r)
  90. """
  91. )
  92. % kwargs
  93. ).encode('utf-8'),
  94. }
  95. if extra_file_defs:
  96. file_defs.update(extra_file_defs)
  97. with tempdir() as source_dir:
  98. path.build(file_defs, source_dir)
  99. subprocess.check_call(
  100. (sys.executable, 'setup.py', '-q', 'bdist_wheel'), cwd=source_dir
  101. )
  102. yield glob.glob(os.path.join(source_dir, 'dist', '*.whl'))[0]
  103. def tree_set(root):
  104. return {
  105. os.path.join(os.path.relpath(dirpath, root), filename)
  106. for dirpath, dirnames, filenames in os.walk(root)
  107. for filename in filenames
  108. }
  109. def flatten_tree(tree):
  110. """Flatten nested dicts and lists into a full list of paths"""
  111. output = set()
  112. for node, contents in tree.items():
  113. if isinstance(contents, dict):
  114. contents = flatten_tree(contents)
  115. for elem in contents:
  116. if isinstance(elem, dict):
  117. output |= {os.path.join(node, val) for val in flatten_tree(elem)}
  118. else:
  119. output.add(os.path.join(node, elem))
  120. return output
  121. def format_install_tree(tree):
  122. return {
  123. x.format(
  124. py_version=sysconfig.get_python_version(),
  125. platform=get_platform(),
  126. shlib_ext=get_config_var('EXT_SUFFIX') or get_config_var('SO'),
  127. )
  128. for x in tree
  129. }
  130. def _check_wheel_install(
  131. filename, install_dir, install_tree_includes, project_name, version, requires_txt
  132. ):
  133. w = Wheel(filename)
  134. egg_path = os.path.join(install_dir, w.egg_name())
  135. w.install_as_egg(egg_path)
  136. if install_tree_includes is not None:
  137. install_tree = format_install_tree(install_tree_includes)
  138. exp = tree_set(install_dir)
  139. assert install_tree.issubset(exp), install_tree - exp
  140. (dist,) = metadata.Distribution.discover(path=[egg_path])
  141. # pyright is nitpicky; fine to assume dist.metadata.__getitem__ will fail or return None
  142. # (https://github.com/pypa/setuptools/pull/5006#issuecomment-2894774288)
  143. assert dist.metadata['Name'] == project_name # pyright: ignore # noqa: PGH003
  144. assert dist.metadata['Version'] == version # pyright: ignore # noqa: PGH003
  145. assert dist.read_text('requires.txt') == requires_txt
  146. class Record:
  147. def __init__(self, id, **kwargs) -> None:
  148. self._id = id
  149. self._fields = kwargs
  150. def __repr__(self) -> str:
  151. return f'{self._id}(**{self._fields!r})'
  152. # Using Any to avoid possible type union issues later in test
  153. # making a TypedDict is not worth in a test and anonymous/inline TypedDict are experimental
  154. # https://github.com/python/mypy/issues/9884
  155. WHEEL_INSTALL_TESTS: tuple[dict[str, Any], ...] = (
  156. dict(
  157. id='basic',
  158. file_defs={'foo': {'__init__.py': ''}},
  159. setup_kwargs=dict(
  160. packages=['foo'],
  161. ),
  162. install_tree=flatten_tree({
  163. 'foo-1.0-py{py_version}.egg': {
  164. 'EGG-INFO': ['PKG-INFO', 'RECORD', 'WHEEL', 'top_level.txt'],
  165. 'foo': ['__init__.py'],
  166. }
  167. }),
  168. ),
  169. dict(
  170. id='utf-8',
  171. setup_kwargs=dict(
  172. description='Description accentuée',
  173. ),
  174. ),
  175. dict(
  176. id='data',
  177. file_defs={
  178. 'data.txt': DALS(
  179. """
  180. Some data...
  181. """
  182. ),
  183. },
  184. setup_kwargs=dict(
  185. data_files=[('data_dir', ['data.txt'])],
  186. ),
  187. install_tree=flatten_tree({
  188. 'foo-1.0-py{py_version}.egg': {
  189. 'EGG-INFO': ['PKG-INFO', 'RECORD', 'WHEEL', 'top_level.txt'],
  190. 'data_dir': ['data.txt'],
  191. }
  192. }),
  193. ),
  194. dict(
  195. id='extension',
  196. file_defs={
  197. 'extension.c': DALS(
  198. """
  199. #include "Python.h"
  200. #if PY_MAJOR_VERSION >= 3
  201. static struct PyModuleDef moduledef = {
  202. PyModuleDef_HEAD_INIT,
  203. "extension",
  204. NULL,
  205. 0,
  206. NULL,
  207. NULL,
  208. NULL,
  209. NULL,
  210. NULL
  211. };
  212. #define INITERROR return NULL
  213. PyMODINIT_FUNC PyInit_extension(void)
  214. #else
  215. #define INITERROR return
  216. void initextension(void)
  217. #endif
  218. {
  219. #if PY_MAJOR_VERSION >= 3
  220. PyObject *module = PyModule_Create(&moduledef);
  221. #else
  222. PyObject *module = Py_InitModule("extension", NULL);
  223. #endif
  224. if (module == NULL)
  225. INITERROR;
  226. #if PY_MAJOR_VERSION >= 3
  227. return module;
  228. #endif
  229. }
  230. """
  231. ),
  232. },
  233. setup_kwargs=dict(
  234. ext_modules=[
  235. Record(
  236. 'setuptools.Extension', name='extension', sources=['extension.c']
  237. )
  238. ],
  239. ),
  240. install_tree=flatten_tree({
  241. 'foo-1.0-py{py_version}-{platform}.egg': [
  242. 'extension{shlib_ext}',
  243. {
  244. 'EGG-INFO': [
  245. 'PKG-INFO',
  246. 'RECORD',
  247. 'WHEEL',
  248. 'top_level.txt',
  249. ]
  250. },
  251. ]
  252. }),
  253. ),
  254. dict(
  255. id='header',
  256. file_defs={
  257. 'header.h': DALS(
  258. """
  259. """
  260. ),
  261. },
  262. setup_kwargs=dict(
  263. headers=['header.h'],
  264. ),
  265. install_tree=flatten_tree({
  266. 'foo-1.0-py{py_version}.egg': [
  267. 'header.h',
  268. {
  269. 'EGG-INFO': [
  270. 'PKG-INFO',
  271. 'RECORD',
  272. 'WHEEL',
  273. 'top_level.txt',
  274. ]
  275. },
  276. ]
  277. }),
  278. ),
  279. dict(
  280. id='script',
  281. file_defs={
  282. 'script.py': DALS(
  283. """
  284. #/usr/bin/python
  285. print('hello world!')
  286. """
  287. ),
  288. 'script.sh': DALS(
  289. """
  290. #/bin/sh
  291. echo 'hello world!'
  292. """
  293. ),
  294. },
  295. setup_kwargs=dict(
  296. scripts=['script.py', 'script.sh'],
  297. ),
  298. install_tree=flatten_tree({
  299. 'foo-1.0-py{py_version}.egg': {
  300. 'EGG-INFO': [
  301. 'PKG-INFO',
  302. 'RECORD',
  303. 'WHEEL',
  304. 'top_level.txt',
  305. {'scripts': ['script.py', 'script.sh']},
  306. ]
  307. }
  308. }),
  309. ),
  310. dict(
  311. id='requires1',
  312. install_requires='foobar==2.0',
  313. install_tree=flatten_tree({
  314. 'foo-1.0-py{py_version}.egg': {
  315. 'EGG-INFO': [
  316. 'PKG-INFO',
  317. 'RECORD',
  318. 'WHEEL',
  319. 'requires.txt',
  320. 'top_level.txt',
  321. ]
  322. }
  323. }),
  324. requires_txt=DALS(
  325. """
  326. foobar==2.0
  327. """
  328. ),
  329. ),
  330. dict(
  331. id='requires2',
  332. install_requires=f"""
  333. bar
  334. foo<=2.0; {sys.platform!r} in sys_platform
  335. """,
  336. requires_txt=DALS(
  337. """
  338. bar
  339. foo<=2.0
  340. """
  341. ),
  342. ),
  343. dict(
  344. id='requires3',
  345. install_requires=f"""
  346. bar; {sys.platform!r} != sys_platform
  347. """,
  348. ),
  349. dict(
  350. id='requires4',
  351. install_requires="""
  352. foo
  353. """,
  354. extras_require={
  355. 'extra': 'foobar>3',
  356. },
  357. requires_txt=DALS(
  358. """
  359. foo
  360. [extra]
  361. foobar>3
  362. """
  363. ),
  364. ),
  365. dict(
  366. id='requires5',
  367. extras_require={
  368. 'extra': f'foobar; {sys.platform!r} != sys_platform',
  369. },
  370. requires_txt='\n'
  371. + DALS(
  372. """
  373. [extra]
  374. """
  375. ),
  376. ),
  377. dict(
  378. id='requires_ensure_order',
  379. install_requires="""
  380. foo
  381. bar
  382. baz
  383. qux
  384. """,
  385. extras_require={
  386. 'extra': """
  387. foobar>3
  388. barbaz>4
  389. bazqux>5
  390. quxzap>6
  391. """,
  392. },
  393. requires_txt=DALS(
  394. """
  395. foo
  396. bar
  397. baz
  398. qux
  399. [extra]
  400. foobar>3
  401. barbaz>4
  402. bazqux>5
  403. quxzap>6
  404. """
  405. ),
  406. ),
  407. dict(
  408. id='namespace_package',
  409. file_defs={
  410. 'foo': {
  411. 'bar': {'__init__.py': ''},
  412. },
  413. },
  414. setup_kwargs=dict(
  415. namespace_packages=['foo'],
  416. packages=['foo.bar'],
  417. ),
  418. install_tree=flatten_tree({
  419. 'foo-1.0-py{py_version}.egg': [
  420. 'foo-1.0-py{py_version}-nspkg.pth',
  421. {
  422. 'EGG-INFO': [
  423. 'PKG-INFO',
  424. 'RECORD',
  425. 'WHEEL',
  426. 'namespace_packages.txt',
  427. 'top_level.txt',
  428. ]
  429. },
  430. {
  431. 'foo': [
  432. '__init__.py',
  433. {'bar': ['__init__.py']},
  434. ]
  435. },
  436. ]
  437. }),
  438. ),
  439. dict(
  440. id='empty_namespace_package',
  441. file_defs={
  442. 'foobar': {
  443. '__init__.py': (
  444. "__import__('pkg_resources').declare_namespace(__name__)"
  445. )
  446. },
  447. },
  448. setup_kwargs=dict(
  449. namespace_packages=['foobar'],
  450. packages=['foobar'],
  451. ),
  452. install_tree=flatten_tree({
  453. 'foo-1.0-py{py_version}.egg': [
  454. 'foo-1.0-py{py_version}-nspkg.pth',
  455. {
  456. 'EGG-INFO': [
  457. 'PKG-INFO',
  458. 'RECORD',
  459. 'WHEEL',
  460. 'namespace_packages.txt',
  461. 'top_level.txt',
  462. ]
  463. },
  464. {
  465. 'foobar': [
  466. '__init__.py',
  467. ]
  468. },
  469. ]
  470. }),
  471. ),
  472. dict(
  473. id='data_in_package',
  474. file_defs={
  475. 'foo': {
  476. '__init__.py': '',
  477. 'data_dir': {
  478. 'data.txt': DALS(
  479. """
  480. Some data...
  481. """
  482. ),
  483. },
  484. }
  485. },
  486. setup_kwargs=dict(
  487. packages=['foo'],
  488. data_files=[('foo/data_dir', ['foo/data_dir/data.txt'])],
  489. ),
  490. install_tree=flatten_tree({
  491. 'foo-1.0-py{py_version}.egg': {
  492. 'EGG-INFO': [
  493. 'PKG-INFO',
  494. 'RECORD',
  495. 'WHEEL',
  496. 'top_level.txt',
  497. ],
  498. 'foo': [
  499. '__init__.py',
  500. {
  501. 'data_dir': [
  502. 'data.txt',
  503. ]
  504. },
  505. ],
  506. }
  507. }),
  508. ),
  509. )
  510. @pytest.mark.parametrize(
  511. 'params',
  512. WHEEL_INSTALL_TESTS,
  513. ids=[params['id'] for params in WHEEL_INSTALL_TESTS],
  514. )
  515. def test_wheel_install(params):
  516. project_name = params.get('name', 'foo')
  517. version = params.get('version', '1.0')
  518. install_requires = params.get('install_requires', [])
  519. extras_require = params.get('extras_require', {})
  520. requires_txt = params.get('requires_txt', None)
  521. install_tree = params.get('install_tree')
  522. file_defs = params.get('file_defs', {})
  523. setup_kwargs = params.get('setup_kwargs', {})
  524. with (
  525. build_wheel(
  526. name=project_name,
  527. version=version,
  528. install_requires=install_requires,
  529. extras_require=extras_require,
  530. extra_file_defs=file_defs,
  531. **setup_kwargs,
  532. ) as filename,
  533. tempdir() as install_dir,
  534. ):
  535. _check_wheel_install(
  536. filename, install_dir, install_tree, project_name, version, requires_txt
  537. )
  538. def test_wheel_no_dist_dir():
  539. project_name = 'nodistinfo'
  540. version = '1.0'
  541. wheel_name = f'{project_name}-{version}-py2.py3-none-any.whl'
  542. with tempdir() as source_dir:
  543. wheel_path = os.path.join(source_dir, wheel_name)
  544. # create an empty zip file
  545. zipfile.ZipFile(wheel_path, 'w').close()
  546. with tempdir() as install_dir:
  547. with pytest.raises(ValueError):
  548. _check_wheel_install(
  549. wheel_path, install_dir, None, project_name, version, None
  550. )
  551. def test_wheel_is_compatible(monkeypatch):
  552. def sys_tags():
  553. return {
  554. (t.interpreter, t.abi, t.platform)
  555. for t in parse_tag('cp36-cp36m-manylinux1_x86_64')
  556. }
  557. monkeypatch.setattr('setuptools.wheel._get_supported_tags', sys_tags)
  558. assert Wheel('onnxruntime-0.1.2-cp36-cp36m-manylinux1_x86_64.whl').is_compatible()
  559. def test_wheel_mode():
  560. @contextlib.contextmanager
  561. def build_wheel(extra_file_defs=None, **kwargs):
  562. file_defs = {
  563. 'setup.py': (
  564. DALS(
  565. """
  566. # -*- coding: utf-8 -*-
  567. from setuptools import setup
  568. import setuptools
  569. setup(**%r)
  570. """
  571. )
  572. % kwargs
  573. ).encode('utf-8'),
  574. }
  575. if extra_file_defs:
  576. file_defs.update(extra_file_defs)
  577. with tempdir() as source_dir:
  578. path.build(file_defs, source_dir)
  579. runsh = pathlib.Path(source_dir) / "script.sh"
  580. os.chmod(runsh, 0o777)
  581. subprocess.check_call(
  582. (sys.executable, 'setup.py', '-q', 'bdist_wheel'), cwd=source_dir
  583. )
  584. yield glob.glob(os.path.join(source_dir, 'dist', '*.whl'))[0]
  585. params = dict(
  586. id='script',
  587. file_defs={
  588. 'script.py': DALS(
  589. """
  590. #/usr/bin/python
  591. print('hello world!')
  592. """
  593. ),
  594. 'script.sh': DALS(
  595. """
  596. #/bin/sh
  597. echo 'hello world!'
  598. """
  599. ),
  600. },
  601. setup_kwargs=dict(
  602. scripts=['script.py', 'script.sh'],
  603. ),
  604. install_tree=flatten_tree({
  605. 'foo-1.0-py{py_version}.egg': {
  606. 'EGG-INFO': [
  607. 'PKG-INFO',
  608. 'RECORD',
  609. 'WHEEL',
  610. 'top_level.txt',
  611. {'scripts': ['script.py', 'script.sh']},
  612. ]
  613. }
  614. }),
  615. )
  616. project_name = params.get('name', 'foo')
  617. version = params.get('version', '1.0')
  618. install_tree = params.get('install_tree')
  619. file_defs = params.get('file_defs', {})
  620. setup_kwargs = params.get('setup_kwargs', {})
  621. with (
  622. build_wheel(
  623. name=project_name,
  624. version=version,
  625. install_requires=[],
  626. extras_require={},
  627. extra_file_defs=file_defs,
  628. **setup_kwargs,
  629. ) as filename,
  630. tempdir() as install_dir,
  631. ):
  632. _check_wheel_install(
  633. filename, install_dir, install_tree, project_name, version, None
  634. )
  635. w = Wheel(filename)
  636. base = pathlib.Path(install_dir) / w.egg_name()
  637. script_sh = base / "EGG-INFO" / "scripts" / "script.sh"
  638. assert script_sh.exists()
  639. if sys.platform != 'win32':
  640. # Editable file mode has no effect on Windows
  641. assert oct(stat.S_IMODE(script_sh.stat().st_mode)) == "0o777"