Source code for schrodinger.test.pytest.startup

"""
Schrodinger-specific modification to pytest startup.
"""
import argparse
import enum
import faulthandler
import os
import pathlib
import re
import sys
import warnings

import pytest
import _pytest

import schrodinger.job.util
from schrodinger.job import jobcontrol
from schrodinger.job import server
from schrodinger.test import is_display_present
from schrodinger.test.hypothesis import hypothesis_profiles
from schrodinger.test.jobserver import SCHRODINGER_JOBSERVER_CONFIG_FILE
from schrodinger.utils import mmutil
from schrodinger.utils.env import prepend_sys_path

from . import _i_am_buildbot
from . import faulthandler_setup
from .warnings import mark_warnings_as_errors

CURRENT_SESSION = None

hypothesis_profiles.register_profiles()


[docs]@enum.unique class SchrodingerIniOptions(enum.Enum): ALLOW_REMOTE_JOBS = "allow_remote_jobs" DISALLOW_MOCK_IN_SWIG = "disallow_mock_in_swig" WARNINGS_AS_ERRORS = "warnings_as_errors"
[docs]@pytest.mark.tryfirst def configure(config): """ Post-Process the pytest configuration. Basically to overload the -m argument and play well with --pypath. """ markers = { "memtest": "Run this test under memtest.", "memtest_skip": "Don't run this test under memtest.", "post_test": "Tests may require other products, and should be run after the normal tests.", "require_display": "Test requires a display (Linux: DISPLAY, Mac: Console session) to run", "slow": "Slow tests. Typical cutoff is about 0.1s", } for product in mmutil.get_product_names(): markers[ f"require_{product}"] = f"Requires installation of {product} and declares this a post-test" for marker_name, descr in sorted(markers.items()): config.addinivalue_line("markers", f"{marker_name}: {descr}") if not sys.platform.startswith('linux') and config.option.memtest: raise pytest.UsageError("memtest using Valgrind is only available on " "Linux.") markexpr = [config.option.markexpr] if config.option.markexpr else [] if config.option.post_test_only: markexpr.append('post_test') elif not config.option.post_test: markexpr.append('not post_test') if config.option.fast: markexpr.append('not slow') if config.option.memtest: markexpr.append('memtest and not memtest_skip') if config.option.no_display or not is_display_present(): markexpr.append('not require_display') config.option.markexpr = ' and '.join(markexpr) # disable faulthandler so that pytest's faulthandler plugin can reregister # with stderr that doesn't interfere with --capture object faulthandler.disable() config.option.faulthandler_timeout = faulthandler_setup.set_timeout(config) if config.option.tbstyle == 'auto': config.option.tbstyle = 'short' # The default number of workers to restart is unlimited! # Under some circumstances, this will fork bomb. if config.option.maxworkerrestart is None: config.option.maxworkerrestart = 3 pypath = config.option.pypath or [] for path in config.getini("pypath").split(): path = config.rootdir.join(path) pypath.append(str(path)) pypath.append(os.path.join(os.environ['SCHRODINGER'], 'internal', 'bin')) mmshare_exec = os.environ['MMSHARE_EXEC'] mmshare = os.path.dirname(os.path.dirname(mmshare_exec)) if config.option.from_product: product, product_exec = config.option.from_product pypath.append(product_exec) product_env = product.upper() + '_EXEC' os.environ[product_env] = product_exec else: pypath.append(mmshare_exec) extend_pythonpath(pypath) if sys.platform == 'darwin': os.environ['FONTCONFIG_FILE'] = os.path.realpath( os.path.join(mmshare, 'data', 'fonts.conf')) # buildbot continues on collection errors so one test can't prevent # all from running if _i_am_buildbot(): config.option.continue_on_collection_errors = True # allow test classes to also end with "Test" (in addition to starting with # "Test", which is the default) config.addinivalue_line("python_classes", "*Test") config.option.strict_markers = True set_current_session(config)
[docs]def addoption(parser): """ Add Schrodinger options to run the post tests, or just the fastest tests. This is a pytest hook. """ def product(product_name): """ Hunt for a product. Allows a failure for uninstalled product before tests are discovered. """ product_exec = schrodinger.job.util.hunt(product_name) if not product_exec: raise argparse.ArgumentTypeError( f'Product "{product_name}" is not installed.') return (product_name, product_exec) group = parser.getgroup('Schrodinger options') group.addoption( '--run-in-dir', action="store_true", help='Execute each test in the directory of the test file.') group.addoption( '--pypath', action='append', help=('Add the requested path to the PYTHONPATH before executing ' 'tests.')) parser.addini( 'pypath', help=( 'Add the requested path to the PYTHONPATH before executing tests.')) # Setting default product based on -FROM, this makes -FROM and --product # synonyms default = os.environ.get('SCHRODINGER_PRODUCT', None) help_msg = ('Also available as -FROM. Sets environment as if called with ' '$SCHRODINGER/run -FROM <PRODUCT>. Also adds the requested ' "product's bin directory to the PYTHONPATH.") if default: help_msg += f' (DEFAULT={default})' default = product(default) group.addoption( '--product', type=product, dest='from_product', default=default, help=help_msg) parser.addini( 'src_dirname', help="Directory name of product source repository, eg: 'maestro-src'") group.addoption( '--fast', action="store_true", help='Skip tests marked as as slow.') # This is never used in automated builds. I use it all the time, though, # to run both post tests and not post tests for a directory. group.addoption( '--post_test', '--post-test', action="store_true", help=("Include tests that are run from the " "post_test' target.")) group.addoption( '--post_test-only', '--post_test_only', '--post-test-only', action="store_true", help=("Run only the tests that rely on more than mmshare (i.e. the " "'post_test' target).")) if sys.platform.startswith('linux'): msg = "Execute non python tests using valgrind" else: msg = argparse.SUPPRESS group.addoption('--memtest', action="store_true", help=msg) group.addoption( '--default-feature-flags', action='store_true', help=('Use the feature flag settings from the ' 'installation, ignoring those in .schrodinger')) group.addoption( '--no-display', action='store_true', help='Simulate running on a computer with no display available. ' 'Tests marked require_display will be skipped, and the ' 'qapplication will not be started') parser.addini( SchrodingerIniOptions.DISALLOW_MOCK_IN_SWIG.value, type="bool", default=False, help="Raise a TypeError if a MagicMock is passed in to SWIG") parser.addini( SchrodingerIniOptions.WARNINGS_AS_ERRORS.value, type="bool", default=False, help="Turn any python warnings into errors") # It would be nice to kill each thread after running at most X tests. I # like that idea so we can avoid the OOM errors on win32. parser.addini( SchrodingerIniOptions.ALLOW_REMOTE_JOBS.value, type="bool", default=False, help="If True, allow launching remote job server jobs, " "otherwise hide remote JOB_SERVERs to make job manager access faster")
[docs]def extend_pythonpath(additional_paths): """ Add "additional_paths" to the PYTHONPATH. Also adds schrodinger.test and the test_modules directory. """ if not additional_paths: additional_paths = [] # Need to manipulate both sys.path and PYTHONPATH to deal with multiple # processes. pythonpath = os.environ.get('PYTHONPATH', []) if pythonpath: pythonpath = [pythonpath] else: pythonpath = [] def add_to_pypath(dirname): """ Adds to sys.path and PYTHONPATH in case processes are spawned. This definitely happens, for instance, when running tests in parallel. """ pythonpath.append(dirname) sys.path.append(dirname) for dirname in additional_paths: add_to_pypath(dirname) os.environ['PYTHONPATH'] = os.pathsep.join(pythonpath)
def _relaunch_pytest(*additional_arguments): """Relaunch py.test, possibly with some additional arguments""" with prepend_sys_path(os.environ['MMSHARE_EXEC']): import toplevel import shlex # regenerate the command line that pytest was called with, but add # -FROM product. pytest_utility = shlex.split(os.environ['SCHRODINGER_COMMANDLINE'])[0] basecmd = [ toplevel.__file__, pytest_utility, 'mmshare', '', 'MMSHARE_EXEC', os.path.basename(sys.executable), '-m', 'pytest' ] cmd = basecmd + list(additional_arguments) + sys.argv[1:] return toplevel.main(cmd)
[docs]def cmdline_main(config): """ Run the py.test main loop. Only affects if an option was requested that necessitates restarting toplevel. """ if config.option.default_feature_flags: if os.environ.get('SCHRODINGER_FEATURE_FLAGS', None) == '': # Already set to ignore feature flags from .schrodinger. config.option.default_feature_flags = False else: os.environ['SCHRODINGER_FEATURE_FLAGS'] = '' if config.option.from_product: # Make sure that toplevel was used to set the product directory. # if not, overload pytest's main function. schrodinger_product = os.environ.get('SCHRODINGER_PRODUCT') if schrodinger_product != config.option.from_product[0]: if schrodinger_product and schrodinger_product != 'mmshare': raise pytest.UsageError( "Product set using -FROM and --product must match, " f"currently -FROM={schrodinger_product} and " f"--product={config.option.from_product[0]}") # pytest was called with --product <product name>, but not -FROM. # This means that toplevel needs to be invoked to set the # environment up correctly. return _relaunch_pytest('-FROM', config.option.from_product[0]) if config.option.default_feature_flags: return _relaunch_pytest() # https://github.com/pytest-dev/pytest/issues/6936 warnings.filterwarnings( "ignore", message="The TerminalReporter.writer", category=pytest.PytestDeprecationWarning) if mmutil.feature_flag_is_enabled( mmutil.JOB_SERVER) and not jobcontrol.get_backend(): allow_remote_jobs = SCHRODINGER_JOBSERVER_CONFIG_FILE in os.environ or config.getini( SchrodingerIniOptions.ALLOW_REMOTE_JOBS.value) if not allow_remote_jobs: os.environ[ SCHRODINGER_JOBSERVER_CONFIG_FILE] = "REMOTE_JOBS_HIDDEN_FROM_PYTEST" server.ensure_localhost_server_running() if config.getini(SchrodingerIniOptions.WARNINGS_AS_ERRORS.value): mark_warnings_as_errors()
[docs]class CurrentSession:
[docs] def __init__(self, rootdir, pluginmanager): self.rootdir = rootdir self.pluginmanager = pluginmanager
[docs]def set_current_session(config): """ :param config: current config object for pytest :type config: pytest.config.Config Sets a module level variable to refer to in case of crash. """ global CURRENT_SESSION CURRENT_SESSION = CurrentSession(config.rootdir, config.pluginmanager)
[docs]def can_write_bytecode(): """ Return whether we can write bytecode to the __pycache__ directory. """ test_file = pathlib.Path( pathlib.Path(__file__).parent) / "__pycache__" / ".writeable_file.py" try: test_file.write_text("") except (FileNotFoundError, PermissionError): return False return True
[docs]def disable_bytecode_if_not_writable(): """ Determines if we need to disable bytecode writing, due to pytest using atomicwrites package, which uses mkstemp. It will attempt to create 2 billion files on windows, per file imported by pytest. https://bugs.python.org/issue22107 """ if sys.platform.startswith("win32") and not can_write_bytecode(): sys.dont_write_bytecode = True