conftest.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580
  1. #-----------------------------------------------------------------------------
  2. # Copyright (c) 2005-2023, 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. import contextlib
  12. import copy
  13. import glob
  14. import logging
  15. import os
  16. import re
  17. import shutil
  18. import subprocess
  19. import sys
  20. import time
  21. # Set a handler for the root-logger to inhibit 'basicConfig()' (called in PyInstaller.log) is setting up a stream
  22. # handler writing to stderr. This avoids log messages to be written (and captured) twice: once on stderr and
  23. # once by pytests's caplog.
  24. logging.getLogger().addHandler(logging.NullHandler())
  25. # psutil is used for process tree clean-up on time-out when running the test frozen application. If unavailable
  26. # (for example, on cygwin), we fall back to trying to terminate only the main application process.
  27. try:
  28. import psutil # noqa: E402
  29. except ModuleNotFoundError:
  30. psutil = None
  31. import pytest # noqa: E402
  32. from PyInstaller import __main__ as pyi_main # noqa: E402
  33. from PyInstaller import configure # noqa: E402
  34. from PyInstaller.compat import is_cygwin, is_darwin, is_win # noqa: E402
  35. from PyInstaller.depend.analysis import initialize_modgraph # noqa: E402
  36. from PyInstaller.archive.readers import pkg_archive_contents # noqa: E402
  37. from PyInstaller.utils.tests import gen_sourcefile # noqa: E402
  38. from PyInstaller.utils.win32 import winutils # noqa: E402
  39. # Timeout for running the executable. If executable does not exit in this time, it is interpreted as a test failure.
  40. _EXE_TIMEOUT = 3 * 60 # In sec.
  41. # All currently supported platforms
  42. SUPPORTED_OSES = {"darwin", "linux", "win32"}
  43. # Have pyi_builder fixure clean-up the temporary directories of successful tests. Controlled by environment variable.
  44. _PYI_BUILDER_CLEANUP = os.environ.get("PYI_BUILDER_CLEANUP", "1") == "1"
  45. # Fixtures
  46. # --------
  47. def pytest_runtest_setup(item):
  48. """
  49. Markers to skip tests based on the current platform.
  50. https://pytest.org/en/stable/example/markers.html#marking-platform-specific-tests-with-pytest
  51. Available markers: see pytest.ini markers
  52. - @pytest.mark.darwin (macOS)
  53. - @pytest.mark.linux (GNU/Linux)
  54. - @pytest.mark.win32 (Windows)
  55. """
  56. supported_platforms = SUPPORTED_OSES.intersection(mark.name for mark in item.iter_markers())
  57. plat = sys.platform
  58. if plat == 'android':
  59. plat = 'linux'
  60. if supported_platforms and plat not in supported_platforms:
  61. pytest.skip(f"does not run on {plat}")
  62. @pytest.hookimpl(tryfirst=True, hookwrapper=True)
  63. def pytest_runtest_makereport(item, call):
  64. # Execute all other hooks to obtain the report object.
  65. outcome = yield
  66. rep = outcome.get_result()
  67. # Set a report attribute for each phase of a call, which can be "setup", "call", "teardown".
  68. setattr(item, f"rep_{rep.when}", rep)
  69. # Return the base directory which contains the current test module.
  70. def _get_base_dir(request):
  71. return request.path.resolve().parent # pathlib.Path instance
  72. # Directory with Python scripts for functional tests.
  73. def _get_script_dir(request):
  74. return _get_base_dir(request) / 'scripts'
  75. # Directory with testing modules used in some tests.
  76. def _get_modules_dir(request):
  77. return _get_base_dir(request) / 'modules'
  78. # Directory with .toc log files.
  79. def _get_logs_dir(request):
  80. return _get_base_dir(request) / 'logs'
  81. # Return the directory where data for tests is located.
  82. def _get_data_dir(request):
  83. return _get_base_dir(request) / 'data'
  84. # Directory with .spec files used in some tests.
  85. def _get_spec_dir(request):
  86. return _get_base_dir(request) / 'specs'
  87. @pytest.fixture
  88. def spec_dir(request):
  89. """
  90. Return the directory where the test spec-files reside.
  91. """
  92. return _get_spec_dir(request)
  93. @pytest.fixture
  94. def script_dir(request):
  95. """
  96. Return the directory where the test scripts reside.
  97. """
  98. return _get_script_dir(request)
  99. # A fixture that copies test's data directory into test's temporary directory. The data directory is assumed to be
  100. # `data/{test-name}` found next to the .py file that contains test.
  101. @pytest.fixture
  102. def data_dir(
  103. # The request object for this test. Used to infer name of the test and location of the source .py file.
  104. # See
  105. # https://pytest.org/latest/builtin.html#_pytest.python.FixtureRequest
  106. # and
  107. # https://pytest.org/latest/fixture.html#fixtures-can-introspect-the-requesting-test-context.
  108. request,
  109. # The tmp_path object for this test. See: https://pytest.org/latest/tmp_path.html.
  110. tmp_path
  111. ):
  112. # Strip the leading 'test_' from the test's name.
  113. test_name = request.function.__name__[5:]
  114. # Copy to data dir and return the path.
  115. source_data_dir = _get_data_dir(request) / test_name
  116. tmp_data_dir = tmp_path / 'data'
  117. # Copy the data.
  118. shutil.copytree(source_data_dir, tmp_data_dir)
  119. # Return the temporary data directory, so that the copied data can now be used.
  120. return tmp_data_dir
  121. class AppBuilder:
  122. def __init__(self, tmp_path, request, bundle_mode):
  123. self._tmp_path = tmp_path
  124. self._request = request
  125. self._mode = bundle_mode
  126. self._spec_dir = tmp_path
  127. self._dist_dir = tmp_path / 'dist'
  128. self._build_dir = tmp_path / 'build'
  129. self._is_spec = False
  130. def test_spec(self, specfile, *args, **kwargs):
  131. """
  132. Test a Python script that is referenced in the supplied .spec file.
  133. """
  134. __tracebackhide__ = True
  135. specfile = _get_spec_dir(self._request) / specfile
  136. # 'test_script' should handle .spec properly as script.
  137. self._is_spec = True
  138. return self.test_script(specfile, *args, **kwargs)
  139. def test_source(self, source, *args, **kwargs):
  140. """
  141. Test a Python script given as source code.
  142. The source will be written into a file named like the test-function. This file will then be passed to
  143. `test_script`. If you need other related file, e.g., as `.toc`-file for testing the content, put it at at the
  144. normal place. Just mind to take the basnename from the test-function's name.
  145. :param script: Source code to create executable from. This will be saved into a temporary file which is then
  146. passed on to `test_script`.
  147. :param test_id: Test-id for parametrized tests. If given, it will be appended to the script filename, separated
  148. by two underscores.
  149. All other arguments are passed straight on to `test_script`.
  150. """
  151. __tracebackhide__ = True
  152. # For parametrized test append the test-id.
  153. scriptfile = gen_sourcefile(self._tmp_path, source, kwargs.setdefault('test_id'))
  154. del kwargs['test_id']
  155. return self.test_script(scriptfile, *args, **kwargs)
  156. def _display_message(self, step_name, message):
  157. # Print the given message to both stderr and stdout, and it with APP-BUILDER to make it clear where it
  158. # originates from.
  159. print(f'[APP-BUILDER:{step_name}] {message}', file=sys.stdout)
  160. print(f'[APP-BUILDER:{step_name}] {message}', file=sys.stderr)
  161. def test_script(
  162. self, script, pyi_args=None, app_name=None, app_args=None, runtime=None, run_from_path=False, **kwargs
  163. ):
  164. """
  165. Main method to wrap all phases of testing a Python script.
  166. :param script: Name of script to create executable from.
  167. :param pyi_args: Additional arguments to pass to PyInstaller when creating executable.
  168. :param app_name: Name of the executable. This is equivalent to argument --name=APPNAME.
  169. :param app_args: Additional arguments to pass to
  170. :param runtime: Time in seconds how long to keep executable running.
  171. :param toc_log: List of modules that are expected to be bundled with the executable.
  172. """
  173. __tracebackhide__ = True
  174. # Skip interactive tests (the ones with `runtime` set) if `psutil` is unavailable, as we need it to properly
  175. # clean up the process tree.
  176. if runtime and psutil is None:
  177. pytest.skip('Interactive tests require psutil for proper cleanup.')
  178. if pyi_args is None:
  179. pyi_args = []
  180. if app_args is None:
  181. app_args = []
  182. if app_name:
  183. if not self._is_spec:
  184. pyi_args.extend(['--name', app_name])
  185. else:
  186. # Derive name from script name.
  187. app_name = os.path.splitext(os.path.basename(script))[0]
  188. # Relative path means that a script from _script_dir is referenced.
  189. if not os.path.isabs(script):
  190. script = _get_script_dir(self._request) / script
  191. self.script = str(script) # might be a pathlib.Path at this point!
  192. assert os.path.exists(self.script), f'Script {self.script!r} not found.'
  193. self._display_message('TEST-SCRIPT', 'Starting build...')
  194. if not self._test_building(args=pyi_args):
  195. pytest.fail(f'Building of {script} failed.')
  196. self._display_message('TEST-SCRIPT', 'Build finished, now running executable...')
  197. self._test_executables(app_name, args=app_args, runtime=runtime, run_from_path=run_from_path, **kwargs)
  198. self._display_message('TEST-SCRIPT', 'Running executable finished.')
  199. def _test_executables(self, name, args, runtime, run_from_path, **kwargs):
  200. """
  201. Run created executable to make sure it works.
  202. Multipackage-tests generate more than one exe-file and all of them have to be run.
  203. :param args: CLI options to pass to the created executable.
  204. :param runtime: Time in seconds how long to keep the executable running.
  205. :return: Exit code of the executable.
  206. """
  207. __tracebackhide__ = True
  208. exes = self._find_executables(name)
  209. # Empty list means that PyInstaller probably failed to create any executable.
  210. assert exes != [], 'No executable file was found.'
  211. for exe in exes:
  212. # Try to find .toc log file. .toc log file has the same basename as exe file.
  213. toc_log = os.path.splitext(os.path.basename(exe))[0] + '.toc'
  214. toc_log = _get_logs_dir(self._request) / toc_log
  215. if toc_log.exists():
  216. if not self._examine_executable(exe, toc_log):
  217. pytest.fail(f'Matching .toc of {exe} failed.')
  218. retcode = self._run_executable(exe, args, run_from_path, runtime)
  219. if retcode != kwargs.get('retcode', 0):
  220. pytest.fail(f'Running exe {exe} failed with return-code {retcode}.')
  221. def _find_executables(self, name):
  222. """
  223. Search for all executables generated by the testcase.
  224. If the test-case is called e.g. 'test_multipackage1', this is searching for each of 'test_multipackage1.exe'
  225. and 'multipackage1_?.exe' in both one-file- and one-dir-mode.
  226. :param name: Name of the executable to look for.
  227. :return: List of executables
  228. """
  229. exes = []
  230. onedir_pt = str(self._dist_dir / name / name)
  231. onefile_pt = str(self._dist_dir / name)
  232. patterns = [
  233. onedir_pt,
  234. onefile_pt,
  235. # Multipackage one-dir
  236. onedir_pt + '_?',
  237. # Multipackage one-file
  238. onefile_pt + '_?'
  239. ]
  240. # For Windows append .exe extension to patterns.
  241. if is_win:
  242. patterns = [pt + '.exe' for pt in patterns]
  243. # For macOS append pattern for .app bundles.
  244. if is_darwin:
  245. # e.g: ./dist/name.app/Contents/MacOS/name
  246. app_bundle_pt = str(self._dist_dir / f'{name}.app' / 'Contents' / 'MacOS' / name)
  247. patterns.append(app_bundle_pt)
  248. # Apply file patterns.
  249. for pattern in patterns:
  250. for prog in glob.glob(pattern):
  251. if os.path.isfile(prog):
  252. exes.append(prog)
  253. return exes
  254. def _run_executable(self, prog, args, run_from_path, runtime):
  255. """
  256. Run executable created by PyInstaller.
  257. :param args: CLI options to pass to the created executable.
  258. """
  259. # Run the test in a clean environment to make sure they're really self-contained.
  260. prog_env = copy.deepcopy(os.environ)
  261. prog_env['PATH'] = ''
  262. del prog_env['PATH']
  263. # For Windows we need to keep minimal PATH for successful running of some tests.
  264. if is_win:
  265. # Minimum Windows PATH is in most cases: C:\Windows\system32;C:\Windows
  266. prog_env['PATH'] = os.pathsep.join(winutils.get_system_path())
  267. # Same for Cygwin - if /usr/bin is not in PATH, cygwin1.dll cannot be discovered.
  268. if is_cygwin:
  269. prog_env['PATH'] = os.pathsep.join(['/usr/local/bin', '/usr/bin'])
  270. # On macOS, we similarly set up minimal PATH with system directories, in case utilities from there are used by
  271. # tested python code (for example, matplotlib >= 3.9.0 uses `system_profiler` that is found in /usr/sbin).
  272. if is_darwin:
  273. # The following paths are registered when application is launched via Finder, and are a subset of what is
  274. # typically available in the shell.
  275. prog_env['PATH'] = os.pathsep.join(['/usr/bin', '/bin', '/usr/sbin', '/sbin'])
  276. exe_path = prog
  277. if run_from_path:
  278. # Run executable in the temp directory. Add the directory containing the executable to $PATH. Basically,
  279. # pretend we are a shell executing the program from $PATH.
  280. prog_cwd = str(self._tmp_path)
  281. prog_name = os.path.basename(prog)
  282. prog_env['PATH'] = os.pathsep.join([prog_env.get('PATH', ''), os.path.dirname(prog)])
  283. else:
  284. # Run executable in the directory where it is.
  285. prog_cwd = os.path.dirname(prog)
  286. # The executable will be called with argv[0] as relative not absolute path.
  287. prog_name = os.path.join(os.curdir, os.path.basename(prog))
  288. args = [prog_name] + args
  289. # Using sys.stdout/sys.stderr for subprocess fixes printing messages in Windows command prompt. Py.test is then
  290. # able to collect stdout/sterr messages and display them if a test fails.
  291. return self._run_executable_(args, exe_path, prog_env, prog_cwd, runtime)
  292. def _run_executable_(self, args, exe_path, prog_env, prog_cwd, runtime):
  293. # Use psutil.Popen, if available; otherwise, fall back to subprocess.Popen
  294. popen_implementation = subprocess.Popen if psutil is None else psutil.Popen
  295. # Run the executable
  296. self._display_message('RUN-EXE', f'Running {exe_path!r}, args: {args!r}')
  297. start_time = time.time()
  298. process = popen_implementation(args, executable=exe_path, env=prog_env, cwd=prog_cwd)
  299. # Wait for the process to finish. If no run-time (= timeout) is specified, we expect the process to exit on
  300. # its own, and use global _EXE_TIMEOUT. If run-time is specified, we expect the application to be running
  301. # for at least the specified amount of time, which is useful in "interactive" test applications that are not
  302. # expected exit on their own.
  303. stdout = stderr = None
  304. try:
  305. timeout = runtime if runtime else _EXE_TIMEOUT
  306. stdout, stderr = process.communicate(timeout=timeout)
  307. elapsed = time.time() - start_time
  308. retcode = process.returncode
  309. self._display_message(
  310. 'RUN-EXE', f'Process exited on its own after {elapsed:.1f} seconds with return code {retcode}.'
  311. )
  312. except (subprocess.TimeoutExpired) if psutil is None else (psutil.TimeoutExpired, subprocess.TimeoutExpired):
  313. if runtime:
  314. # When 'runtime' is set, the expired timeout is a good sign that the executable was running successfully
  315. # for the specified time.
  316. self._display_message('RUN-EXE', f'Process reached expected run-time of {runtime} seconds.')
  317. retcode = 0
  318. else:
  319. # Executable is still running and it is not interactive. Clean up the process tree, and fail the test.
  320. self._display_message('RUN-EXE', f'Timeout while running executable (timeout: {timeout} seconds)!')
  321. retcode = 1
  322. if psutil is None:
  323. # We are using subprocess.Popen(). Without psutil, we have no access to process tree; this poses a
  324. # problem for onefile builds, where we would need to first kill the child (main application) process,
  325. # and let the onefile parent perform its cleanup. As a best-effort approach, we can first call
  326. # process.terminate(); on POSIX systems, this sends SIGTERM to the parent process, and in most
  327. # situations, the bootloader will forward it to the child process. Then wait 5 seconds, and call
  328. # process.kill() if necessary. On Windows, however, both process.terminate() and process.kill() do
  329. # the same. Therefore, we should avoid running "interactive" tests with expected run-time if we do
  330. # not have psutil available.
  331. try:
  332. self._display_message('RUN-EXE', 'Stopping the process using Popen.terminate()...')
  333. process.terminate()
  334. stdout, stderr = process.communicate(timeout=5)
  335. self._display_message('RUN-EXE', 'Process stopped.')
  336. except subprocess.TimeoutExpired:
  337. # Kill the process.
  338. try:
  339. self._display_message('RUN-EXE', 'Stopping the process using Popen.kill()...')
  340. process.kill()
  341. # process.communicate() waits for end-of-file, which may never arrive if there is a child
  342. # process still alive. Nothing we can really do about it here, so add a short timeout and
  343. # display a warning.
  344. stdout, stderr = process.communicate(timeout=1)
  345. self._display_message('RUN-EXE', 'Process stopped.')
  346. except subprocess.TimeoutExpired:
  347. self._display_message('RUN-EXE', 'Failed to stop the process (or its child process(es))!')
  348. else:
  349. # We are using psutil.Popen(). First, force-kill all child processes; in onefile mode, this includes
  350. # the application process, whose termination should trigger cleanup and exit of the parent onefile
  351. # process.
  352. self._display_message('RUN-EXE', 'Stopping child processes...')
  353. for child_process in list(process.children(recursive=True)):
  354. with contextlib.suppress(psutil.NoSuchProcess):
  355. self._display_message('RUN-EXE', f'Stopping child process {child_process.pid}...')
  356. child_process.kill()
  357. # Give the main process 5 seconds to exit on its own (to accommodate cleanup in onefile mode).
  358. try:
  359. self._display_message('RUN-EXE', f'Waiting for main process ({process.pid}) to stop...')
  360. stdout, stderr = process.communicate(timeout=5)
  361. self._display_message('RUN-EXE', 'Process stopped on its own.')
  362. except (psutil.TimeoutExpired, subprocess.TimeoutExpired):
  363. # End of the line - kill the main process.
  364. self._display_message('RUN-EXE', 'Stopping the process using Popen.kill()...')
  365. with contextlib.suppress(psutil.NoSuchProcess):
  366. process.kill()
  367. # Try to retrieve stdout/stderr - but keep a short timeout, just in case...
  368. try:
  369. stdout, stderr = process.communicate(timeout=1)
  370. self._display_message('RUN-EXE', 'Process stopped.')
  371. except (psutil.TimeoutExpired, subprocess.TimeoutExpire):
  372. self._display_message('RUN-EXE', 'Failed to stop the process (or its child process(es))!')
  373. self._display_message('RUN-EXE', f'Done! Return code: {retcode}')
  374. return retcode
  375. def _test_building(self, args):
  376. """
  377. Run building of test script.
  378. :param args: additional CLI options for PyInstaller.
  379. Return True if build succeeded False otherwise.
  380. """
  381. if self._is_spec:
  382. default_args = [
  383. '--distpath', str(self._dist_dir),
  384. '--workpath', str(self._build_dir),
  385. '--log-level', 'INFO',
  386. ] # yapf: disable
  387. else:
  388. default_args = [
  389. '--debug=bootloader',
  390. '--noupx',
  391. '--specpath', str(self._spec_dir),
  392. '--distpath', str(self._dist_dir),
  393. '--workpath', str(self._build_dir),
  394. '--path', str(_get_modules_dir(self._request)),
  395. '--log-level', 'INFO',
  396. ] # yapf: disable
  397. # Choose bundle mode.
  398. if self._mode == 'onedir':
  399. default_args.append('--onedir')
  400. elif self._mode == 'onefile':
  401. default_args.append('--onefile')
  402. # if self._mode is None then just the spec file was supplied.
  403. pyi_args = [self.script, *default_args, *args]
  404. # TODO: fix return code in running PyInstaller programmatically.
  405. PYI_CONFIG = configure.get_config()
  406. # Override CACHEDIR for PyInstaller; relocate cache into `self._tmp_path`.
  407. PYI_CONFIG['cachedir'] = str(self._tmp_path)
  408. pyi_main.run(pyi_args, PYI_CONFIG)
  409. retcode = 0
  410. return retcode == 0
  411. def _examine_executable(self, exe, toc_log):
  412. """
  413. Compare log files (now used mostly by multipackage test_name).
  414. :return: True if .toc files match
  415. """
  416. self._display_message('EXAMINE-EXE', f'Matching against TOC log: {str(toc_log)!r}')
  417. fname_list = pkg_archive_contents(exe)
  418. with open(toc_log, 'r', encoding='utf-8') as f:
  419. pattern_list = eval(f.read())
  420. # Alphabetical order of patterns.
  421. pattern_list.sort()
  422. missing = []
  423. for pattern in pattern_list:
  424. for fname in fname_list:
  425. if re.match(pattern, fname):
  426. self._display_message('EXAMINE-EXE', f'Entry found: {pattern!r} --> {fname!r}')
  427. break
  428. else:
  429. # No matching entry found
  430. missing.append(pattern)
  431. self._display_message('EXAMINE-EXE', f'Entry MISSING: {pattern!r}')
  432. # We expect the missing list to be empty
  433. return not missing
  434. # Scope 'session' should keep the object unchanged for whole tests. This fixture caches basic module graph dependencies
  435. # that are same for every executable.
  436. @pytest.fixture(scope='session')
  437. def pyi_modgraph():
  438. # Explicitly set the log level since the plugin `pytest-catchlog` (un-) sets the root logger's level to NOTSET for
  439. # the setup phase, which will lead to TRACE messages been written out.
  440. import PyInstaller.log as logging
  441. logging.logger.setLevel(logging.DEBUG)
  442. initialize_modgraph()
  443. # Run by default test as onedir and onefile.
  444. @pytest.fixture(params=['onedir', 'onefile'])
  445. def pyi_builder(tmp_path, monkeypatch, request, pyi_modgraph):
  446. # Save/restore environment variable PATH.
  447. monkeypatch.setenv('PATH', os.environ['PATH'])
  448. # PyInstaller or a test case might manipulate 'sys.path'. Reset it for every test.
  449. monkeypatch.syspath_prepend(None)
  450. # Set current working directory to
  451. monkeypatch.chdir(tmp_path)
  452. # Clean up configuration and force PyInstaller to do a clean configuration for another app/test. The value is same
  453. # as the original value.
  454. monkeypatch.setattr('PyInstaller.config.CONF', {'pathex': []})
  455. yield AppBuilder(tmp_path, request, request.param)
  456. # Clean up the temporary directory of a successful test
  457. if _PYI_BUILDER_CLEANUP and request.node.rep_setup.passed and request.node.rep_call.passed:
  458. if tmp_path.exists():
  459. shutil.rmtree(tmp_path, ignore_errors=True)
  460. # Fixture for .spec based tests. With .spec it does not make sense to differentiate onefile/onedir mode.
  461. @pytest.fixture
  462. def pyi_builder_spec(tmp_path, request, monkeypatch, pyi_modgraph):
  463. # Save/restore environment variable PATH.
  464. monkeypatch.setenv('PATH', os.environ['PATH'])
  465. # Set current working directory to
  466. monkeypatch.chdir(tmp_path)
  467. # PyInstaller or a test case might manipulate 'sys.path'. Reset it for every test.
  468. monkeypatch.syspath_prepend(None)
  469. # Clean up configuration and force PyInstaller to do a clean configuration for another app/test. The value is same
  470. # as the original value.
  471. monkeypatch.setattr('PyInstaller.config.CONF', {'pathex': []})
  472. yield AppBuilder(tmp_path, request, None)
  473. # Clean up the temporary directory of a successful test
  474. if _PYI_BUILDER_CLEANUP and request.node.rep_setup.passed and request.node.rep_call.passed:
  475. if tmp_path.exists():
  476. shutil.rmtree(tmp_path, ignore_errors=True)
  477. @pytest.fixture
  478. def pyi_windowed_builder(pyi_builder: AppBuilder):
  479. """A pyi_builder equivalent for testing --windowed applications."""
  480. # psutil.Popen() somehow bypasses an application's windowed/console mode so that any application built in
  481. # --windowed mode but invoked with psutil still receives valid std{in,out,err} handles and behaves exactly like
  482. # a console application. In short, testing windowed mode with psutil is a null test. We must instead use subprocess.
  483. def _run_executable_(args, exe_path, prog_env, prog_cwd, runtime):
  484. return subprocess.run([exe_path, *args], env=prog_env, cwd=prog_cwd, timeout=runtime).returncode
  485. pyi_builder._run_executable_ = _run_executable_
  486. yield pyi_builder