From d60073b0c13ff33322b2762bbd065acebc88a58f Mon Sep 17 00:00:00 2001 From: Vasily Kuznetsov Date: Sat, 29 Sep 2018 00:14:00 +0200 Subject: [PATCH 1/2] Make tests use a separate virtualenv for installing scripts --- tests/test_console_scripts.py | 201 +----------------------------- tests/test_run_scripts.py | 225 ++++++++++++++++++++++++++++++++++ tox.ini | 4 +- 3 files changed, 230 insertions(+), 200 deletions(-) create mode 100644 tests/test_run_scripts.py diff --git a/tests/test_console_scripts.py b/tests/test_console_scripts.py index 8a05fbe..384ac9e 100644 --- a/tests/test_console_scripts.py +++ b/tests/test_console_scripts.py @@ -1,8 +1,5 @@ from __future__ import print_function, unicode_literals -import subprocess - -import mock import pytest @@ -31,7 +28,7 @@ def launch_modes(launch_mode_conf): @pytest.fixture def run_test(testdir): - def runner(script, passed=1, skipped=0, failed=0, launch_mode_conf=None): + def run(script, passed=1, skipped=0, failed=0, launch_mode_conf=None): testdir.makepyfile(script) args = [] if launch_mode_conf is not None: @@ -41,7 +38,7 @@ def runner(script, passed=1, skipped=0, failed=0, launch_mode_conf=None): ['pytest stderr:'] + result.errlines)) result.assert_outcomes(passed=passed, skipped=skipped, failed=failed) return result - return runner + return run CHECK_LAUNCH_MODE = """ @@ -105,197 +102,3 @@ def test_help_message(testdir): 'console-scripts:', '*--script-launch-mode=*', ]) - - -def run_setup_py(cmd, script_path, uninstall=False): - """Run setup.py to install or uninstall the script command line wrapper.""" - script_dir = script_path.join('..') - script_name = script_path.purebasename - setup_py = script_dir.join('setup.py') - setup_py.write( - """ -import setuptools - -setuptools.setup( - name='{script_name}', - version='0.1', - py_modules=['{script_name}'], - zip_safe=False, - entry_points={{ - 'console_scripts': ['{cmd}={script_name}:main'] - }} -) - """.format(cmd=cmd, script_name=script_name)) - args = ['setup.py', 'develop'] - if uninstall: - args.append('--uninstall') - try: - with script_dir.as_cwd(), mock.patch('sys.argv', args): - exec(setup_py.read()) - except TypeError: - # In-process call fails on some Python 2 installations - # but it's faster, especially on PyPy. - subprocess.check_call(['python'] + args, cwd=str(script_dir)) - - -@pytest.yield_fixture -def console_script(request, testdir): - """Console script exposed as a wrapper in python `bin` directory. - - Returned value is a `py.path.local` object that corresponds to a python - file whose `main` function is exposed via console script wrapper. The - name of the command is available via it `command_name` attribute. - """ - script = testdir.makepyfile(console_script_module='def main(): pass') - cmd = 'console-script-module-cmd' - run_setup_py(cmd, script) - script.command_name = cmd - yield script - run_setup_py(cmd, script, uninstall=True) - - -@pytest.fixture(params=['inprocess', 'subprocess']) -def launch_mode(request): - """Launch mode: inprocess|subprocess.""" - return request.param - - -def test_run_script(console_script, run_test, launch_mode): - console_script.write( - """ -from __future__ import print_function - -def main(): - print(u'hello world') - print('hello world') - """ - ) - run_test( - r""" -def test_hello_world(script_runner): - ret = script_runner.run('{}') - print(ret.stderr) - assert ret.success - assert ret.stdout == 'hello world\nhello world\n' - """.format(console_script.command_name), - launch_mode_conf=launch_mode, - passed=1 - ) - - -def test_run_failing_script(console_script, run_test, launch_mode): - console_script.write( - """ -import sys - -def main(): - sys.exit('boom') - """ - ) - run_test( - r""" -def test_exit_boom(script_runner): - ret = script_runner.run('{}') - assert not ret.success - assert ret.stdout == '' - assert ret.stderr == 'boom\n' - """.format(console_script.command_name), - launch_mode_conf=launch_mode, - passed=1 - ) - - -def test_run_script_with_exception(console_script, run_test, launch_mode): - console_script.write( - """ -import sys - -def main(): - raise TypeError('boom') - """ - ) - run_test( - r""" -def test_throw_exception(script_runner): - ret = script_runner.run('{}') - assert not ret.success - assert ret.returncode == 1 - assert ret.stdout == '' - assert 'TypeError: boom' in ret.stderr - """.format(console_script.command_name), - launch_mode_conf=launch_mode, - passed=1 - ) - - -def test_run_script_with_cwd(console_script, run_test, launch_mode, tmpdir): - console_script.write( - """ -from __future__ import print_function - -import os - -def main(): - print(os.getcwd()) - """ - ) - run_test( - r""" -def test_cwd(script_runner): - ret = script_runner.run('{script_name}', cwd='{cwd}') - assert ret.success - assert ret.stdout == '{cwd}\n' - """.format(script_name=console_script.command_name, cwd=tmpdir), - launch_mode_conf=launch_mode - ) - - -def test_preserve_cwd(console_script, run_test, launch_mode): - console_script.write( - """ -from __future__ import print_function - -import os -import sys - -def main(): - os.chdir(sys.argv[1]) - print(os.getcwd()) - """ - ) - run_test( - r""" -import os - -def test_preserve_cwd(script_runner, tmpdir): - dir1 = tmpdir.mkdir('dir1') - dir2 = tmpdir.mkdir('dir2') - os.chdir(str(dir1)) - ret = script_runner.run('{script_name}', str(dir2)) - assert ret.stdout == str(dir2) + '\n' - assert os.getcwd() == str(dir1) - """.format(script_name=console_script.command_name) - ) - - -def test_run_script_with_stdin(console_script, run_test, launch_mode): - console_script.write( - """ -import sys - -def main(): - for line in sys.stdin: - sys.stdout.write('simon says ' + line) - """ - ) - run_test( - r""" -import io - -def test_stdin(script_runner): - ret = script_runner.run('{script_name}', stdin=io.StringIO(u'foo\nbar')) - assert ret.success - assert ret.stdout == 'simon says foo\nsimon says bar' - """.format(script_name=console_script.command_name), - launch_mode_conf=launch_mode - ) diff --git a/tests/test_run_scripts.py b/tests/test_run_scripts.py new file mode 100644 index 0000000..76933b6 --- /dev/null +++ b/tests/test_run_scripts.py @@ -0,0 +1,225 @@ +import os +import subprocess +import sys + +import mock +import py +import pytest +import virtualenv + + +# Template for creating setup.py for installing console scripts. +SETUP_TEMPLATE = """ +import setuptools + +setuptools.setup( + name='{script_name}', + version='1.0', + py_modules=['{script_name}'], + zip_safe=False, + entry_points={{ + 'console_scripts': ['{cmd}={script_name}:main'] + }} +) +""" + + +class VEnvWrapper: + """Wrapper for virtualenv that can execute code inside of it.""" + + def __init__(self, path): + self.path = path + + def _update_env(self, env): + bin_dir = self.path.join('bin').strpath + env['PATH'] = bin_dir + ':' + env.get('PATH', '') + env['VIRTUAL_ENV'] = self.path.strpath + env['PYTHONPATH'] = ':'.join(sys.path) + + def run(self, cmd, *args, **kw): + """Run a command in the virtualenv.""" + self._update_env(kw.setdefault('env', os.environ)) + print(kw['env']['PATH'], kw['env']['PYTHONPATH']) + subprocess.check_call(cmd, *args, **kw) + + def install_console_script(self, cmd, script_path): + """Run setup.py to install console script into this virtualenv.""" + script_dir = script_path.dirpath() + script_name = script_path.purebasename + setup_py = script_dir.join('setup.py') + setup_py.write(SETUP_TEMPLATE.format(cmd=cmd, script_name=script_name)) + self.run(['python', 'setup.py', 'develop'], cwd=str(script_dir)) + + +@pytest.fixture(scope='session') +def pcs_venv(tmpdir_factory): + """Virtualenv for testing console scripts.""" + venv = tmpdir_factory.mktemp('venv') + virtualenv.create_environment(venv.strpath) + yield VEnvWrapper(venv) + + +@pytest.fixture(scope='session') +def console_script(pcs_venv, tmpdir_factory): + """Console script exposed as a wrapper in python `bin` directory. + + Returned value is a `py.path.local` object that corresponds to a python + file whose `main` function is exposed via console script wrapper. The + name of the command is available via it `command_name` attribute. + + The fixture is made session scoped for speed. The idea is that every test + will overwrite the content of the script exposed by this fixture to get + the behavior that it needs. + """ + script = tmpdir_factory.mktemp('script').join('console_script.py') + script.write('def main(): pass') + pcs_venv.install_console_script('console-script', script) + return script + + +@pytest.fixture(params=['inprocess', 'subprocess']) +def launch_mode(request): + """Launch mode: inprocess|subprocess.""" + return request.param + + +@pytest.fixture +def test_script_in_venv(pcs_venv, console_script, tmpdir, launch_mode): + """A fixture that tests provided script with provided test.""" + pytest_path = py.path.local(sys.executable).dirpath('pytest').strpath + + def run(script_src, test_src, **kw): + """Test provided script with a provided test.""" + console_script.write(script_src) + test = tmpdir.join('test.py') + test.write(test_src) + # Execute pytest with the python of the virtualenv we created, + # otherwise it would be executed with the python that runs this test, + # which is wrong. + test_cmd = [ + 'python', + pytest_path, + '--script-launch-mode=' + launch_mode, + test.strpath, + ] + pcs_venv.run(test_cmd, **kw) + + return run + + +@pytest.mark.parametrize('script,test', [ + ( + """ +from __future__ import print_function + +def main(): + print(u'hello world') + print('hello world') + """, + r""" +def test_hello_world(script_runner): + ret = script_runner.run('console-script') + print(ret.stderr) + assert ret.success + assert ret.stdout == 'hello world\nhello world\n' + """, + ), + # Script that exits abnormally. + ( + """ +import sys + +def main(): + sys.exit('boom') + """, + r""" +def test_exit_boom(script_runner): + ret = script_runner.run('console-script') + assert not ret.success + assert ret.stdout == '' + assert ret.stderr == 'boom\n' + """, + ), + # Script that has an uncaught exception. + ( + """ +import sys + +def main(): + raise TypeError('boom') + """, + r""" +def test_throw_exception(script_runner): + ret = script_runner.run('console-script') + assert not ret.success + assert ret.returncode == 1 + assert ret.stdout == '' + assert 'TypeError: boom' in ret.stderr + """, + ), + # Script that changes to another directory. The test process should remain + # in the directory where it was (this is particularly relevant if we run + # the script inprocess). + ( + """ +from __future__ import print_function + +import os +import sys + +def main(): + os.chdir(sys.argv[1]) + print(os.getcwd()) + """, + r""" +import os + +def test_preserve_cwd(script_runner, tmpdir): + dir1 = tmpdir.mkdir('dir1') + dir2 = tmpdir.mkdir('dir2') + os.chdir(str(dir1)) + ret = script_runner.run('console-script', str(dir2)) + assert ret.stdout == str(dir2) + '\n' + assert os.getcwd() == str(dir1) + """, + ), + # Send input to tested script's stdin. + ( + """ +import sys + +def main(): + for line in sys.stdin: + sys.stdout.write('simon says ' + line) + """, + r""" +import io + +def test_stdin(script_runner): + ret = script_runner.run('console-script', stdin=io.StringIO(u'foo\nbar')) + assert ret.success + assert ret.stdout == 'simon says foo\nsimon says bar' + """, + ), +]) +def test_run_script(test_script_in_venv, script, test): + test_script_in_venv(script, test) + + +def test_run_script_with_cwd(test_script_in_venv, tmpdir): + test_script_in_venv( + """ +from __future__ import print_function + +import os + +def main(): + print(os.getcwd()) + """, + r""" +def test_cwd(script_runner): + ret = script_runner.run('console-script', cwd='{cwd}') + assert ret.success + assert ret.stdout == '{cwd}\n' + """.format(cwd=tmpdir), + ) diff --git a/tox.ini b/tox.ini index 43f852b..504a20e 100644 --- a/tox.ini +++ b/tox.ini @@ -3,5 +3,7 @@ envlist = py27,py34,py35,py36,py37,pypy [testenv] -deps = pytest +deps = + pytest + virtualenv commands = pytest tests From affbb6ffd0f7b2ad3f29461e9ddb34488beac7d9 Mon Sep 17 00:00:00 2001 From: Vasily Kuznetsov Date: Sat, 29 Sep 2018 12:53:20 +0200 Subject: [PATCH 2/2] Work around py27 heisenbug caused by stale pyc files --- tests/test_run_scripts.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/test_run_scripts.py b/tests/test_run_scripts.py index 76933b6..0d6702e 100644 --- a/tests/test_run_scripts.py +++ b/tests/test_run_scripts.py @@ -74,6 +74,16 @@ def console_script(pcs_venv, tmpdir_factory): script = tmpdir_factory.mktemp('script').join('console_script.py') script.write('def main(): pass') pcs_venv.install_console_script('console-script', script) + + def replace(new_source): + """Replace script source.""" + script.write(new_source) + pyc = script.strpath + 'c' + if os.path.exists(pyc): + # Remove stale bytecode that causes heisenbugs on py27. + os.remove(pyc) + + script.replace = replace return script @@ -86,11 +96,10 @@ def launch_mode(request): @pytest.fixture def test_script_in_venv(pcs_venv, console_script, tmpdir, launch_mode): """A fixture that tests provided script with provided test.""" - pytest_path = py.path.local(sys.executable).dirpath('pytest').strpath def run(script_src, test_src, **kw): """Test provided script with a provided test.""" - console_script.write(script_src) + console_script.replace(script_src) test = tmpdir.join('test.py') test.write(test_src) # Execute pytest with the python of the virtualenv we created, @@ -98,7 +107,7 @@ def run(script_src, test_src, **kw): # which is wrong. test_cmd = [ 'python', - pytest_path, + '-m', 'pytest', '--script-launch-mode=' + launch_mode, test.strpath, ]