diff --git a/docs/manpage.rst b/docs/manpage.rst index 863150d811..4ad5176e1d 100644 --- a/docs/manpage.rst +++ b/docs/manpage.rst @@ -27,7 +27,7 @@ Test discovery and test loading This is the very first phase of the frontend. ReFrame will search for tests in its *check search path* and will load them. When ReFrame loads a test, it actually *instantiates* it, meaning that it will call its :func:`__init__` method unconditionally whether this test is meant to run on the selected system or not. -This is something that writers of regression tests should bear in mind. +This is something that test developers should bear in mind. .. option:: -c, --checkpath=PATH @@ -45,6 +45,14 @@ This is something that writers of regression tests should bear in mind. This option can also be set using the :envvar:`RFM_CHECK_SEARCH_RECURSIVE` environment variable or the :attr:`~config.general.check_search_recursive` general configuration parameter. +.. note:: + ReFrame will fail to load a test with a relative import unless *any* of the following holds true: + + 1. The test is located under ReFrame's installation prefix. + 2. The parent directory of the test contains an ``__init__.py`` file. + + For versions prior to 4.6, relative imports are supported only for case (1). + .. _test-filtering: diff --git a/reframe/frontend/loader.py b/reframe/frontend/loader.py index cc631e4822..d8fe75e738 100644 --- a/reframe/frontend/loader.py +++ b/reframe/frontend/loader.py @@ -195,8 +195,15 @@ def load_from_file(self, filename, force=False): dirname = os.path.dirname(filename) with osext.change_dir(dirname): with util.temp_sys_path(dirname): + if os.path.exists(os.path.join(dirname, '__init__.py')): + # If the containing directory is a package, + # import it, too. + parent = util.import_module_from_file(dirname).__name__ + else: + parent = None + return self.load_from_module( - util.import_module_from_file(filename, force) + util.import_module_from_file(filename, force, parent) ) except Exception: exc_info = sys.exc_info() diff --git a/reframe/utility/__init__.py b/reframe/utility/__init__.py index 09dca6c53e..7241517877 100644 --- a/reframe/utility/__init__.py +++ b/reframe/utility/__init__.py @@ -70,7 +70,7 @@ def _do_import_module_from_file(filename, module_name=None): return module -def import_module_from_file(filename, force=False): +def import_module_from_file(filename, force=False, parent=None): '''Import module from file. If the file location refers to a directory, the contained ``__init__.py`` @@ -85,7 +85,14 @@ def import_module_from_file(filename, force=False): :arg filename: The path to the filename of a Python module. :arg force: Force reload of module in case it is already loaded. + :arg parent: The name of the parent module of the one that will be loaded. + This will essentially prefix the module of the newly loaded module with + ``parent`` so that Python would be able to resolve relative imports in + the module file. :returns: The loaded Python module. + + .. versionchanged:: 4.6 + The ``parent`` argument is added. ''' # Expand and sanitize filename @@ -103,6 +110,9 @@ def import_module_from_file(filename, force=False): # with other modules loaded with a standard `import` or with multiple # test files with the same name that reside in different directories. module_hash = sha256(filename.encode('utf-8')).hexdigest()[:8] + if parent: + module_name = f'{parent}.{module_name}' + module_name = f'{module_name}@{module_hash}' return _do_import_module_from_file(filename, module_name) diff --git a/unittests/resources/checks_unlisted/testlib/__init__.py b/unittests/resources/checks_unlisted/testlib/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/unittests/resources/checks_unlisted/testlib/simple.py b/unittests/resources/checks_unlisted/testlib/simple.py index a8be65e55f..913cd8a0a9 100644 --- a/unittests/resources/checks_unlisted/testlib/simple.py +++ b/unittests/resources/checks_unlisted/testlib/simple.py @@ -5,21 +5,17 @@ import reframe as rfm import reframe.utility.sanity as sn - - -class dummy_fixture(rfm.RunOnlyRegressionTest, pin_prefix=True): - executable = 'echo' - sanity_patterns = sn.assert_true(1) +from .utility import dummy_fixture @rfm.simple_test -class simple_echo_check(rfm.RunOnlyRegressionTest): +class simple_echo_check(rfm.RunOnlyRegressionTest, pin_prefix=True): descr = 'Simple Echo Test' valid_systems = ['*'] valid_prog_environs = ['builtin'] executable = 'echo' executable_opts = ['Hello'] - message = variable(str, value='World') + message = variable(str, value='World') dummy = fixture(dummy_fixture, scope='environment') @run_before('run') diff --git a/unittests/resources/checks_unlisted/testlib/utility/__init__.py b/unittests/resources/checks_unlisted/testlib/utility/__init__.py new file mode 100644 index 0000000000..72f9a7f3a2 --- /dev/null +++ b/unittests/resources/checks_unlisted/testlib/utility/__init__.py @@ -0,0 +1,7 @@ +import reframe as rfm +import reframe.utility.sanity as sn + + +class dummy_fixture(rfm.RunOnlyRegressionTest): + executable = 'echo' + sanity_patterns = sn.assert_true(1) diff --git a/unittests/resources/checks_unlisted/testlib_inheritance_foo.py b/unittests/resources/checks_unlisted/testlib_inheritance_foo.py index 9efd1b281f..5b19aca2b2 100644 --- a/unittests/resources/checks_unlisted/testlib_inheritance_foo.py +++ b/unittests/resources/checks_unlisted/testlib_inheritance_foo.py @@ -4,7 +4,6 @@ # SPDX-License-Identifier: BSD-3-Clause import reframe as rfm - from testlib.simple import simple_echo_check diff --git a/unittests/test_loader.py b/unittests/test_loader.py index 1b8f74dcfe..954e094668 100644 --- a/unittests/test_loader.py +++ b/unittests/test_loader.py @@ -8,6 +8,7 @@ import shutil import reframe as rfm +import reframe.utility.osext as osext from reframe.core.exceptions import ReframeSyntaxError from reframe.frontend.loader import RegressionCheckLoader @@ -140,3 +141,16 @@ def setup(self, partition, environ, **job_opts): class TestSpecialDerived(TestSpecial): def setup(self, partition, environ, **job_opts): super().setup(partition, environ, **job_opts) + + +def test_relative_import_outside_rfm_prefix(loader, tmp_path): + # If a test file resides under the reframe installation prefix, it will be + # imported as a hierarchical module. If not, we want to make sure that + # reframe will still load its parent modules + + osext.copytree( + os.path.abspath('unittests/resources/checks_unlisted/testlib'), + tmp_path / 'testlib', dirs_exist_ok=True + ) + tests = loader.load_from_file(str(tmp_path / 'testlib' / 'simple.py')) + assert len(tests) == 2