"""Implements the wrapper for the Astropy test runner. This is for backward-compatibility for other downstream packages and can be removed once astropy-helpers has reached end-of-life. """ import os import stat import shutil import subprocess import sys import tempfile from contextlib import contextmanager from setuptools import Command from astropy.logger import log @contextmanager def _suppress_stdout(): ''' A context manager to temporarily disable stdout. Used later when installing a temporary copy of astropy to avoid a very verbose output. ''' with open(os.devnull, "w") as devnull: old_stdout = sys.stdout sys.stdout = devnull try: yield finally: sys.stdout = old_stdout class FixRemoteDataOption(type): """ This metaclass is used to catch cases where the user is running the tests with --remote-data. We've now changed the --remote-data option so that it takes arguments, but we still want --remote-data to work as before and to enable all remote tests. With this metaclass, we can modify sys.argv before setuptools try to parse the command-line options. """ def __init__(cls, name, bases, dct): try: idx = sys.argv.index('--remote-data') except ValueError: pass else: sys.argv[idx] = '--remote-data=any' try: idx = sys.argv.index('-R') except ValueError: pass else: sys.argv[idx] = '-R=any' return super().__init__(name, bases, dct) class AstropyTest(Command, metaclass=FixRemoteDataOption): description = 'Run the tests for this package' user_options = [ ('package=', 'P', "The name of a specific package to test, e.g. 'io.fits' or 'utils'. " "Accepts comma separated string to specify multiple packages. " "If nothing is specified, all default tests are run."), ('test-path=', 't', 'Specify a test location by path. If a relative path to a .py file, ' 'it is relative to the built package, so e.g., a leading "astropy/" ' 'is necessary. If a relative path to a .rst file, it is relative to ' 'the directory *below* the --docs-path directory, so a leading ' '"docs/" is usually necessary. May also be an absolute path.'), ('verbose-results', 'V', 'Turn on verbose output from pytest.'), ('plugins=', 'p', 'Plugins to enable when running pytest.'), ('pastebin=', 'b', "Enable pytest pastebin output. Either 'all' or 'failed'."), ('args=', 'a', 'Additional arguments to be passed to pytest.'), ('remote-data=', 'R', 'Run tests that download remote data. Should be ' 'one of none/astropy/any (defaults to none).'), ('pep8', '8', 'Enable PEP8 checking and disable regular tests. ' 'Requires the pytest-pep8 plugin.'), ('pdb', 'd', 'Start the interactive Python debugger on errors.'), ('coverage', 'c', 'Create a coverage report. Requires the coverage package.'), ('open-files', 'o', 'Fail if any tests leave files open. Requires the ' 'psutil package.'), ('parallel=', 'j', 'Run the tests in parallel on the specified number of ' 'CPUs. If "auto", all the cores on the machine will be ' 'used. Requires the pytest-xdist plugin.'), ('docs-path=', None, 'The path to the documentation .rst files. If not provided, and ' 'the current directory contains a directory called "docs", that ' 'will be used.'), ('skip-docs', None, "Don't test the documentation .rst files."), ('repeat=', None, 'How many times to repeat each test (can be used to check for ' 'sporadic failures).'), ('temp-root=', None, 'The root directory in which to create the temporary testing files. ' 'If unspecified the system default is used (e.g. /tmp) as explained ' 'in the documentation for tempfile.mkstemp.'), ('verbose-install', None, 'Turn on terminal output from the installation of astropy in a ' 'temporary folder.'), ('readonly', None, 'Make the temporary installation being tested read-only.') ] package_name = '' def initialize_options(self): self.package = None self.test_path = None self.verbose_results = False self.plugins = None self.pastebin = None self.args = None self.remote_data = 'none' self.pep8 = False self.pdb = False self.coverage = False self.open_files = False self.parallel = 0 self.docs_path = None self.skip_docs = False self.repeat = None self.temp_root = None self.verbose_install = False self.readonly = False def finalize_options(self): # Normally we would validate the options here, but that's handled in # run_tests pass def generate_testing_command(self): """ Build a Python script to run the tests. """ cmd_pre = '' # Commands to run before the test function cmd_post = '' # Commands to run after the test function if self.coverage: pre, post = self._generate_coverage_commands() cmd_pre += pre cmd_post += post set_flag = "import builtins; builtins._ASTROPY_TEST_ = True" cmd = ('{cmd_pre}{0}; import {1.package_name}, sys; result = (' '{1.package_name}.test(' 'package={1.package!r}, ' 'test_path={1.test_path!r}, ' 'args={1.args!r}, ' 'plugins={1.plugins!r}, ' 'verbose={1.verbose_results!r}, ' 'pastebin={1.pastebin!r}, ' 'remote_data={1.remote_data!r}, ' 'pep8={1.pep8!r}, ' 'pdb={1.pdb!r}, ' 'open_files={1.open_files!r}, ' 'parallel={1.parallel!r}, ' 'docs_path={1.docs_path!r}, ' 'skip_docs={1.skip_docs!r}, ' 'add_local_eggs_to_path=True, ' # see _build_temp_install below 'repeat={1.repeat!r})); ' '{cmd_post}' 'sys.exit(result)') return cmd.format(set_flag, self, cmd_pre=cmd_pre, cmd_post=cmd_post) def run(self): """ Run the tests! """ # Install the runtime dependencies. if self.distribution.install_requires: self.distribution.fetch_build_eggs(self.distribution.install_requires) # Ensure there is a doc path if self.docs_path is None: cfg_docs_dir = self.distribution.get_option_dict('build_docs').get('source_dir', None) # Some affiliated packages use this. # See astropy/package-template#157 if cfg_docs_dir is not None and os.path.exists(cfg_docs_dir[1]): self.docs_path = os.path.abspath(cfg_docs_dir[1]) # fall back on a default path of "docs" elif os.path.exists('docs'): # pragma: no cover self.docs_path = os.path.abspath('docs') # Build a testing install of the package self._build_temp_install() # Install the test dependencies # NOTE: we do this here after _build_temp_install because there is # a weird but which occurs if psutil is installed in this way before # astropy is built, Cython can have segmentation fault. Strange, eh? if self.distribution.tests_require: self.distribution.fetch_build_eggs(self.distribution.tests_require) # Copy any additional dependencies that may have been installed via # tests_requires or install_requires. We then pass the # add_local_eggs_to_path=True option to package.test() to make sure the # eggs get included in the path. if os.path.exists('.eggs'): shutil.copytree('.eggs', os.path.join(self.testing_path, '.eggs')) # This option exists so that we can make sure that the tests don't # write to an installed location. if self.readonly: log.info('changing permissions of temporary installation to read-only') self._change_permissions_testing_path(writable=False) # Run everything in a try: finally: so that the tmp dir gets deleted. try: # Construct this modules testing command cmd = self.generate_testing_command() # Run the tests in a subprocess--this is necessary since # new extension modules may have appeared, and this is the # easiest way to set up a new environment testproc = subprocess.Popen( [sys.executable, '-c', cmd], cwd=self.testing_path, close_fds=False) retcode = testproc.wait() except KeyboardInterrupt: import signal # If a keyboard interrupt is handled, pass it to the test # subprocess to prompt pytest to initiate its teardown testproc.send_signal(signal.SIGINT) retcode = testproc.wait() finally: # Remove temporary directory if self.readonly: self._change_permissions_testing_path(writable=True) shutil.rmtree(self.tmp_dir) raise SystemExit(retcode) def _build_temp_install(self): """ Install the package and to a temporary directory for the purposes of testing. This allows us to test the install command, include the entry points, and also avoids creating pyc and __pycache__ directories inside the build directory """ # On OSX the default path for temp files is under /var, but in most # cases on OSX /var is actually a symlink to /private/var; ensure we # dereference that link, because pytest is very sensitive to relative # paths... tmp_dir = tempfile.mkdtemp(prefix=self.package_name + '-test-', dir=self.temp_root) self.tmp_dir = os.path.realpath(tmp_dir) log.info(f'installing to temporary directory: {self.tmp_dir}') # We now install the package to the temporary directory. We do this # rather than build and copy because this will ensure that e.g. entry # points work. self.reinitialize_command('install') install_cmd = self.distribution.get_command_obj('install') install_cmd.prefix = self.tmp_dir if self.verbose_install: self.run_command('install') else: with _suppress_stdout(): self.run_command('install') # We now get the path to the site-packages directory that was created # inside self.tmp_dir install_cmd = self.get_finalized_command('install') self.testing_path = install_cmd.install_lib # Ideally, docs_path is set properly in run(), but if it is still # not set here, do not pretend it is, otherwise bad things happen. # See astropy/package-template#157 if self.docs_path is not None: new_docs_path = os.path.join(self.testing_path, os.path.basename(self.docs_path)) shutil.copytree(self.docs_path, new_docs_path) self.docs_path = new_docs_path shutil.copy('setup.cfg', self.testing_path) def _change_permissions_testing_path(self, writable=False): if writable: basic_flags = stat.S_IRUSR | stat.S_IWUSR else: basic_flags = stat.S_IRUSR for root, dirs, files in os.walk(self.testing_path): for dirname in dirs: os.chmod(os.path.join(root, dirname), basic_flags | stat.S_IXUSR) for filename in files: os.chmod(os.path.join(root, filename), basic_flags) def _generate_coverage_commands(self): """ This method creates the post and pre commands if coverage is to be generated """ if self.parallel != 0: raise ValueError( "--coverage can not be used with --parallel") try: import coverage # pylint: disable=W0611 except ImportError: raise ImportError( "--coverage requires that the coverage package is " "installed.") # Don't use get_pkg_data_filename here, because it # requires importing astropy.config and thus screwing # up coverage results for those packages. coveragerc = os.path.join( self.testing_path, self.package_name.replace('.', '/'), 'tests', 'coveragerc') with open(coveragerc, 'r') as fd: coveragerc_content = fd.read() coveragerc_content = coveragerc_content.replace( "{packagename}", self.package_name.replace('.', '/')) tmp_coveragerc = os.path.join(self.tmp_dir, 'coveragerc') with open(tmp_coveragerc, 'wb') as tmp: tmp.write(coveragerc_content.encode('utf-8')) cmd_pre = ( 'import coverage; ' 'cov = coverage.coverage(data_file=r"{}", config_file=r"{}"); ' 'cov.start();'.format( os.path.abspath(".coverage"), os.path.abspath(tmp_coveragerc))) cmd_post = ( 'cov.stop(); ' 'from astropy.tests.helper import _save_coverage; ' '_save_coverage(cov, result, r"{}", r"{}");'.format( os.path.abspath('.'), os.path.abspath(self.testing_path))) return cmd_pre, cmd_post