conftest.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599
  1. #-----------------------------------------------------------------------------
  2. # Copyright (c) 2005-2021, PyInstaller Development Team.
  3. #
  4. # Distributed under the terms of the GNU General Public License (version 2
  5. # or later) with exception for distributing the bootloader.
  6. #
  7. # The full license is in the file COPYING.txt, distributed with this software.
  8. #
  9. # SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
  10. #-----------------------------------------------------------------------------
  11. # Imports
  12. # =======
  13. # Library imports
  14. # ---------------
  15. import copy
  16. import glob
  17. import os
  18. import pytest
  19. import re
  20. import subprocess
  21. import sys
  22. import inspect
  23. import textwrap
  24. import io
  25. import shutil
  26. from contextlib import suppress
  27. # Third-party imports
  28. # -------------------
  29. import py
  30. import psutil # Manages subprocess timeout.
  31. # Set a handler for the root-logger to inhibit 'basicConfig()' (called in
  32. # PyInstaller.log) is setting up a stream handler writing to stderr. This
  33. # avoids log messages to be written (and captured) twice: once on stderr and
  34. # once by pytests's caplog.
  35. import logging
  36. logging.getLogger().addHandler(logging.NullHandler())
  37. # Local imports
  38. # -------------
  39. # Expand sys.path with PyInstaller source.
  40. _ROOT_DIR = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..'))
  41. sys.path.append(_ROOT_DIR)
  42. from PyInstaller import configure, config
  43. from PyInstaller import __main__ as pyi_main
  44. from PyInstaller.utils.tests import gen_sourcefile
  45. from PyInstaller.utils.cliutils import archive_viewer
  46. from PyInstaller.compat import is_darwin, is_win, safe_repr, \
  47. architecture, is_linux
  48. from PyInstaller.depend.analysis import initialize_modgraph
  49. from PyInstaller.utils.win32 import winutils
  50. # Globals
  51. # =======
  52. # Timeout for running the executable. If executable does not exit in this time
  53. # then it is interpreted as test failure.
  54. _EXE_TIMEOUT = 60 # In sec.
  55. # Number of retries we should attempt if the executable times out.
  56. _MAX_RETRIES = 2
  57. # All currently supported platforms
  58. SUPPORTED_OSES = {"darwin", "linux", "win32"}
  59. # Code
  60. # ====
  61. # Fixtures
  62. # --------
  63. @pytest.fixture
  64. def SPEC_DIR(request):
  65. """Return the directory where the test spec-files reside"""
  66. return py.path.local(_get_spec_dir(request))
  67. @pytest.fixture
  68. def SCRIPT_DIR(request):
  69. """Return the directory where the test scripts reside"""
  70. return py.path.local(_get_script_dir(request))
  71. def pytest_runtest_setup(item):
  72. """Markers to skip tests based on the current platform.
  73. https://pytest.org/en/stable/example/markers.html#marking-platform-specific-tests-with-pytest
  74. Available markers: see setup.cfg [tool:pytest] markers
  75. - @pytest.mark.darwin (macOS)
  76. - @pytest.mark.linux (GNU/Linux)
  77. - @pytest.mark.win32 (Windows)
  78. """
  79. supported_platforms = SUPPORTED_OSES.intersection(
  80. mark.name for mark in item.iter_markers())
  81. plat = sys.platform
  82. if supported_platforms and plat not in supported_platforms:
  83. pytest.skip("does not run on %s" % plat)
  84. @pytest.hookimpl(tryfirst=True, hookwrapper=True)
  85. def pytest_runtest_makereport(item, call):
  86. # execute all other hooks to obtain the report object
  87. outcome = yield
  88. rep = outcome.get_result()
  89. # set an report attribute for each phase of a call, which can
  90. # be "setup", "call", "teardown"
  91. setattr(item, "rep_" + rep.when, rep)
  92. # Return the base directory which contains the current test module.
  93. def _get_base_dir(request):
  94. return os.path.dirname(os.path.abspath(request.fspath.strpath))
  95. # Directory with Python scripts for functional tests. E.g. main scripts, etc.
  96. def _get_script_dir(request):
  97. return os.path.join(_get_base_dir(request), 'scripts')
  98. # Directory with testing modules used in some tests.
  99. def _get_modules_dir(request):
  100. return os.path.join(_get_base_dir(request), 'modules')
  101. # Directory with .toc log files.
  102. def _get_logs_dir(request):
  103. return os.path.join(_get_base_dir(request), 'logs')
  104. # Return the directory where data for tests is located.
  105. def _get_data_dir(request):
  106. return os.path.join(_get_base_dir(request), 'data')
  107. # Directory with .spec files used in some tests.
  108. def _get_spec_dir(request):
  109. return os.path.join(_get_base_dir(request), 'specs')
  110. @pytest.fixture
  111. def script_dir(request):
  112. return py.path.local(_get_script_dir(request))
  113. # A helper function to copy from data/dir to tmpdir/data.
  114. def _data_dir_copy(
  115. # The pytest request object.
  116. request,
  117. # The name of the subdirectory located in data/name to copy.
  118. subdir_name,
  119. # The tmpdir object for this test. See
  120. # https://pytest.org/latest/tmpdir.html.
  121. tmpdir
  122. ):
  123. # Form the source and tmp paths.
  124. source_data_dir = py.path.local(_get_data_dir(request)).join(subdir_name)
  125. tmp_data_dir = tmpdir.join('data', subdir_name)
  126. # Copy the data.
  127. shutil.copytree(source_data_dir.strpath, tmp_data_dir.strpath)
  128. # Return the temporary data directory, so that the copied data can now be
  129. # used.
  130. return tmp_data_dir
  131. # Define a fixure for the DataDir object.
  132. @pytest.fixture
  133. def data_dir(
  134. # The request object for this test. See
  135. # https://pytest.org/latest/builtin.html#_pytest.python.FixtureRequest
  136. # and
  137. # https://pytest.org/latest/fixture.html#fixtures-can-introspect-the-requesting-test-context.
  138. request,
  139. # The tmpdir object for this test. See
  140. # https://pytest.org/latest/tmpdir.html.
  141. tmpdir):
  142. # Strip the leading 'test_' from the test's name.
  143. name = request.function.__name__[5:]
  144. # Copy to tmpdir and return the path.
  145. return _data_dir_copy(request, name, tmpdir)
  146. class AppBuilder(object):
  147. def __init__(self, tmpdir, request, bundle_mode):
  148. self._tmpdir = tmpdir
  149. self._request = request
  150. self._mode = bundle_mode
  151. self._specdir = str(tmpdir)
  152. self._distdir = str(tmpdir / 'dist')
  153. self._builddir = str(tmpdir /'build')
  154. def test_spec(self, specfile, *args, **kwargs):
  155. """
  156. Test a Python script that is referenced in the supplied .spec file.
  157. """
  158. __tracebackhide__ = True
  159. specfile = os.path.join(_get_spec_dir(self._request), specfile)
  160. # 'test_script' should handle .spec properly as script.
  161. return self.test_script(specfile, *args, **kwargs)
  162. def test_source(self, source, *args, **kwargs):
  163. """
  164. Test a Python script given as source code.
  165. The source will be written into a file named like the
  166. test-function. This file will then be passed to `test_script`.
  167. If you need other related file, e.g. as `.toc`-file for
  168. testing the content, put it at at the normal place. Just mind
  169. to take the basnename from the test-function's name.
  170. :param script: Source code to create executable from. This
  171. will be saved into a temporary file which is
  172. then passed on to `test_script`.
  173. :param test_id: Test-id for parametrized tests. If given, it
  174. will be appended to the script filename,
  175. separated by two underscores.
  176. All other arguments are passed straight on to `test_script`.
  177. Ensure that the caller of `test_source` is in a UTF-8
  178. encoded file with the correct '# -*- coding: utf-8 -*-' marker.
  179. """
  180. __tracebackhide__ = True
  181. # For parametrized test append the test-id.
  182. scriptfile = gen_sourcefile(self._tmpdir, source,
  183. kwargs.setdefault('test_id'))
  184. del kwargs['test_id']
  185. return self.test_script(str(scriptfile), *args, **kwargs)
  186. def test_script(self, script, pyi_args=None, app_name=None,
  187. app_args=None, runtime=None, run_from_path=False,
  188. **kwargs):
  189. """
  190. Main method to wrap all phases of testing a Python script.
  191. :param script: Name of script to create executable from.
  192. :param pyi_args: Additional arguments to pass to PyInstaller when creating executable.
  193. :param app_name: Name of the executable. This is equivalent to argument --name=APPNAME.
  194. :param app_args: Additional arguments to pass to
  195. :param runtime: Time in seconds how long to keep executable running.
  196. :param toc_log: List of modules that are expected to be bundled with the executable.
  197. """
  198. __tracebackhide__ = True
  199. def marker(line):
  200. # Print some marker to stdout and stderr to make it easier
  201. # to distinguish the phases in the CI test output.
  202. print('-------', line, '-------')
  203. print('-------', line, '-------', file=sys.stderr)
  204. if pyi_args is None:
  205. pyi_args = []
  206. if app_args is None:
  207. app_args = []
  208. if app_name:
  209. pyi_args.extend(['--name', app_name])
  210. else:
  211. # Derive name from script name.
  212. app_name = os.path.splitext(os.path.basename(script))[0]
  213. # Relative path means that a script from _script_dir is referenced.
  214. if not os.path.isabs(script):
  215. script = os.path.join(_get_script_dir(self._request), script)
  216. self.script = script
  217. assert os.path.exists(self.script), 'Script %s not found.' % script
  218. marker('Starting build.')
  219. if not self._test_building(args=pyi_args):
  220. pytest.fail('Building of %s failed.' % script)
  221. marker('Build finshed, now running executable.')
  222. self._test_executables(app_name, args=app_args,
  223. runtime=runtime, run_from_path=run_from_path,
  224. **kwargs)
  225. marker('Running executable finished.')
  226. def _test_executables(self, name, args, runtime, run_from_path, **kwargs):
  227. """
  228. Run created executable to make sure it works.
  229. Multipackage-tests generate more than one exe-file and all of
  230. them have to be run.
  231. :param args: CLI options to pass to the created executable.
  232. :param runtime: Time in seconds how long to keep the executable running.
  233. :return: Exit code of the executable.
  234. """
  235. __tracebackhide__ = True
  236. exes = self._find_executables(name)
  237. # Empty list means that PyInstaller probably failed to create any executable.
  238. assert exes != [], 'No executable file was found.'
  239. for exe in exes:
  240. # Try to find .toc log file. .toc log file has the same basename as exe file.
  241. toc_log = os.path.join(
  242. _get_logs_dir(self._request),
  243. os.path.splitext(os.path.basename(exe))[0] + '.toc')
  244. if os.path.exists(toc_log):
  245. if not self._examine_executable(exe, toc_log):
  246. pytest.fail('Matching .toc of %s failed.' % exe)
  247. retcode = self._run_executable(exe, args, run_from_path, runtime)
  248. if retcode != kwargs.get('retcode', 0):
  249. pytest.fail('Running exe %s failed with return-code %s.' %
  250. (exe, retcode))
  251. def _find_executables(self, name):
  252. """
  253. Search for all executables generated by the testcase.
  254. If the test-case is called e.g. 'test_multipackage1', this is
  255. searching for each of 'test_multipackage1.exe' and
  256. 'multipackage1_?.exe' in both one-file- and one-dir-mode.
  257. :param name: Name of the executable to look for.
  258. :return: List of executables
  259. """
  260. exes = []
  261. onedir_pt = os.path.join(self._distdir, name, name)
  262. onefile_pt = os.path.join(self._distdir, name)
  263. patterns = [onedir_pt, onefile_pt,
  264. # Multipackage one-dir
  265. onedir_pt + '_?',
  266. # Multipackage one-file
  267. onefile_pt + '_?']
  268. # For Windows append .exe extension to patterns.
  269. if is_win:
  270. patterns = [pt + '.exe' for pt in patterns]
  271. # For Mac OS X append pattern for .app bundles.
  272. if is_darwin:
  273. # e.g: ./dist/name.app/Contents/MacOS/name
  274. pt = os.path.join(self._distdir, name + '.app', 'Contents', 'MacOS', name)
  275. patterns.append(pt)
  276. # Apply file patterns.
  277. for pattern in patterns:
  278. for prog in glob.glob(pattern):
  279. if os.path.isfile(prog):
  280. exes.append(prog)
  281. return exes
  282. def _run_executable(self, prog, args, run_from_path, runtime):
  283. """
  284. Run executable created by PyInstaller.
  285. :param args: CLI options to pass to the created executable.
  286. """
  287. # Run the test in a clean environment to make sure they're really self-contained.
  288. prog_env = copy.deepcopy(os.environ)
  289. prog_env['PATH'] = ''
  290. del prog_env['PATH']
  291. # For Windows we need to keep minimal PATH for successful running of some tests.
  292. if is_win:
  293. # Minimum Windows PATH is in most cases: C:\Windows\system32;C:\Windows
  294. prog_env['PATH'] = os.pathsep.join(winutils.get_system_path())
  295. exe_path = prog
  296. if(run_from_path):
  297. # Run executable in the temp directory
  298. # Add the directory containing the executable to $PATH
  299. # Basically, pretend we are a shell executing the program from $PATH.
  300. prog_cwd = str(self._tmpdir)
  301. prog_name = os.path.basename(prog)
  302. prog_env['PATH'] = os.pathsep.join([prog_env.get('PATH', ''), os.path.dirname(prog)])
  303. else:
  304. # Run executable in the directory where it is.
  305. prog_cwd = os.path.dirname(prog)
  306. # The executable will be called with argv[0] as relative not absolute path.
  307. prog_name = os.path.join(os.curdir, os.path.basename(prog))
  308. args = [prog_name] + args
  309. # Using sys.stdout/sys.stderr for subprocess fixes printing messages in
  310. # Windows command prompt. Py.test is then able to collect stdout/sterr
  311. # messages and display them if a test fails.
  312. for _ in range(_MAX_RETRIES):
  313. retcode = self.__run_executable(args, exe_path, prog_env,
  314. prog_cwd, runtime)
  315. if retcode != 1: # retcode == 1 means a timeout
  316. break
  317. return retcode
  318. def __run_executable(self, args, exe_path, prog_env, prog_cwd, runtime):
  319. process = psutil.Popen(args, executable=exe_path,
  320. stdout=subprocess.PIPE,
  321. stderr=subprocess.PIPE,
  322. env=prog_env, cwd=prog_cwd)
  323. def _msg(*text):
  324. print('[' + str(process.pid) + '] ', *text)
  325. # Run executable. stderr is redirected to stdout.
  326. _msg('RUNNING: ', safe_repr(exe_path), ', args: ', safe_repr(args))
  327. # 'psutil' allows to use timeout in waiting for a subprocess.
  328. # If not timeout was specified then it is 'None' - no timeout, just waiting.
  329. # Runtime is useful mostly for interactive tests.
  330. try:
  331. timeout = runtime if runtime else _EXE_TIMEOUT
  332. stdout, stderr = process.communicate(timeout=timeout)
  333. retcode = process.returncode
  334. except (psutil.TimeoutExpired, subprocess.TimeoutExpired):
  335. if runtime:
  336. # When 'runtime' is set then expired timeout is a good sing
  337. # that the executable was running successfully for a specified time.
  338. # TODO Is there a better way return success than 'retcode = 0'?
  339. retcode = 0
  340. else:
  341. # Exe is running and it is not interactive. Fail the test.
  342. retcode = 1
  343. _msg('TIMED OUT!')
  344. # Kill the subprocess and its child processes.
  345. for p in list(process.children(recursive=True)) + [process]:
  346. with suppress(psutil.NoSuchProcess):
  347. p.kill()
  348. stdout, stderr = process.communicate()
  349. sys.stdout.buffer.write(stdout)
  350. sys.stderr.buffer.write(stderr)
  351. return retcode
  352. def _test_building(self, args):
  353. """
  354. Run building of test script.
  355. :param args: additional CLI options for PyInstaller.
  356. Return True if build succeded False otherwise.
  357. """
  358. default_args = ['--debug=bootloader', '--noupx',
  359. '--specpath', self._specdir,
  360. '--distpath', self._distdir,
  361. '--workpath', self._builddir,
  362. '--path', _get_modules_dir(self._request),
  363. '--log-level=INFO',
  364. ]
  365. # Choose bundle mode.
  366. if self._mode == 'onedir':
  367. default_args.append('--onedir')
  368. elif self._mode == 'onefile':
  369. default_args.append('--onefile')
  370. # if self._mode is None then just the spec file was supplied.
  371. pyi_args = [self.script] + default_args + args
  372. # TODO fix return code in running PyInstaller programatically
  373. PYI_CONFIG = configure.get_config(upx_dir=None)
  374. # Override CACHEDIR for PyInstaller and put it into self.tmpdir
  375. PYI_CONFIG['cachedir'] = str(self._tmpdir)
  376. pyi_main.run(pyi_args, PYI_CONFIG)
  377. retcode = 0
  378. return retcode == 0
  379. def _examine_executable(self, exe, toc_log):
  380. """
  381. Compare log files (now used mostly by multipackage test_name).
  382. :return: True if .toc files match
  383. """
  384. print('EXECUTING MATCHING:', toc_log)
  385. fname_list = archive_viewer.get_archive_content(exe)
  386. fname_list = [fn for fn in fname_list]
  387. with open(toc_log, 'r') as f:
  388. pattern_list = eval(f.read())
  389. # Alphabetical order of patterns.
  390. pattern_list.sort()
  391. missing = []
  392. for pattern in pattern_list:
  393. for fname in fname_list:
  394. if re.match(pattern, fname):
  395. print('MATCH:', pattern, '-->', fname)
  396. break
  397. else:
  398. # No matching entry found
  399. missing.append(pattern)
  400. print('MISSING:', pattern)
  401. # Not all modules matched.
  402. # Stop comparing other .toc files and fail the test.
  403. if missing:
  404. for m in missing:
  405. print('Missing', m, 'in', exe)
  406. return False
  407. # All patterns matched.
  408. return True
  409. # Scope 'session' should keep the object unchanged for whole tests.
  410. # This fixture caches basic module graph dependencies that are same
  411. # for every executable.
  412. @pytest.fixture(scope='session')
  413. def pyi_modgraph():
  414. # Explicitly set the log level since the plugin `pytest-catchlog` (un-)
  415. # sets the root logger's level to NOTSET for the setup phase, which will
  416. # lead to TRACE messages been written out.
  417. import PyInstaller.log as logging
  418. logging.logger.setLevel(logging.DEBUG)
  419. initialize_modgraph()
  420. # Run by default test as onedir and onefile.
  421. @pytest.fixture(params=['onedir', 'onefile'])
  422. def pyi_builder(tmpdir, monkeypatch, request, pyi_modgraph):
  423. # Save/restore environment variable PATH.
  424. monkeypatch.setenv('PATH', os.environ['PATH'], )
  425. # PyInstaller or a test case might manipulate 'sys.path'.
  426. # Reset it for every test.
  427. monkeypatch.syspath_prepend(None)
  428. # Set current working directory to
  429. monkeypatch.chdir(tmpdir)
  430. # Clean up configuration and force PyInstaller to do a clean configuration
  431. # for another app/test.
  432. # The value is same as the original value.
  433. monkeypatch.setattr('PyInstaller.config.CONF', {'pathex': []})
  434. yield AppBuilder(tmpdir, request, request.param)
  435. if is_darwin or is_linux:
  436. if request.node.rep_setup.passed:
  437. if request.node.rep_call.passed:
  438. if tmpdir.exists():
  439. tmpdir.remove(rec=1, ignore_errors=True)
  440. # Fixture for .spec based tests.
  441. # With .spec it does not make sense to differentiate onefile/onedir mode.
  442. @pytest.fixture
  443. def pyi_builder_spec(tmpdir, request, monkeypatch, pyi_modgraph):
  444. # Save/restore environment variable PATH.
  445. monkeypatch.setenv('PATH', os.environ['PATH'], )
  446. # Set current working directory to
  447. monkeypatch.chdir(tmpdir)
  448. # PyInstaller or a test case might manipulate 'sys.path'.
  449. # Reset it for every test.
  450. monkeypatch.syspath_prepend(None)
  451. # Clean up configuration and force PyInstaller to do a clean configuration
  452. # for another app/test.
  453. # The value is same as the original value.
  454. monkeypatch.setattr('PyInstaller.config.CONF', {'pathex': []})
  455. return AppBuilder(tmpdir, request, None)
  456. # Define a fixture which compiles the data/load_dll_using_ctypes/ctypes_dylib.c
  457. # program in the tmpdir, returning the tmpdir object.
  458. @pytest.fixture()
  459. def compiled_dylib(tmpdir, request):
  460. tmp_data_dir = _data_dir_copy(request, 'ctypes_dylib', tmpdir)
  461. # Compile the ctypes_dylib in the tmpdir: Make tmpdir/data the CWD. Don't
  462. # use monkeypatch.chdir to change, then monkeypatch.undo() to restore the
  463. # CWD, since this will undo ALL monkeypatches (such as the pyi_builder's
  464. # additions to sys.path), breaking the test.
  465. old_wd = tmp_data_dir.chdir()
  466. try:
  467. if is_win:
  468. tmp_data_dir = tmp_data_dir.join('ctypes_dylib.dll')
  469. # For Mingw-x64 we must pass '-m32' to build 32-bit binaries
  470. march = '-m32' if architecture == '32bit' else '-m64'
  471. ret = subprocess.call('gcc -shared ' + march + ' ctypes_dylib.c -o ctypes_dylib.dll', shell=True)
  472. if ret != 0:
  473. # Find path to cl.exe file.
  474. from distutils.msvccompiler import MSVCCompiler
  475. comp = MSVCCompiler()
  476. comp.initialize()
  477. cl_path = comp.cc
  478. # Fallback to msvc.
  479. ret = subprocess.call([cl_path, '/LD', 'ctypes_dylib.c'], shell=False)
  480. elif is_darwin:
  481. tmp_data_dir = tmp_data_dir.join('ctypes_dylib.dylib')
  482. # On Mac OS X we need to detect architecture - 32 bit or 64 bit.
  483. arch = 'i386' if architecture == '32bit' else 'x86_64'
  484. cmd = ('gcc -arch ' + arch + ' -Wall -dynamiclib '
  485. 'ctypes_dylib.c -o ctypes_dylib.dylib -headerpad_max_install_names')
  486. ret = subprocess.call(cmd, shell=True)
  487. id_dylib = os.path.abspath('ctypes_dylib.dylib')
  488. ret = subprocess.call('install_name_tool -id %s ctypes_dylib.dylib' % (id_dylib,), shell=True)
  489. else:
  490. tmp_data_dir = tmp_data_dir.join('ctypes_dylib.so')
  491. ret = subprocess.call('gcc -fPIC -shared ctypes_dylib.c -o ctypes_dylib.so', shell=True)
  492. assert ret == 0, 'Compile ctypes_dylib failed.'
  493. finally:
  494. # Reset the CWD directory.
  495. old_wd.chdir()
  496. return tmp_data_dir