Skip to content

Commit df31923

Browse files
authored
Merge pull request #3548 from vkarak/feat/custom-config-imports
[feat] Facilitate local imports from configuration files
2 parents 8000b58 + 92800e3 commit df31923

File tree

5 files changed

+78
-45
lines changed

5 files changed

+78
-45
lines changed

docs/config_reference.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2390,6 +2390,8 @@ Dynamic configuration
23902390

23912391
One advantage of ReFrame's configuration is that it is programmable, especially if you are using the Python files.
23922392
Since the configuration is loaded as a Python module, you can generate parts of the configuration dynamically.
2393+
You can also import seamlessly other modules that reside inside the configuration directory.
2394+
This is particularly useful when you define custom schedulers or :ref:`parallel launcher <custom-launchers>` backends.
23932395

23942396
The YAML configuration on the other hand is more static, although not fully.
23952397
Code generation can still be used with the YAML configuration as it is treated as a Jinja2 template, where ReFrame provides the following bindings:

reframe/core/config.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ def subconfig_system(self):
343343

344344
def load_config_python(self, filename):
345345
try:
346-
mod = util.import_module_from_file(filename)
346+
mod = util.import_module_from_file(filename, load_parents=True)
347347
except ImportError as e:
348348
# import_module_from_file() may raise an ImportError if the
349349
# configuration file is under ReFrame's top-level directory
@@ -695,6 +695,7 @@ def load_config(*filenames):
695695
# The builtin configuration is always loaded at the beginning
696696
continue
697697

698+
f = os.path.abspath(f)
698699
getlogger().debug(f'Loading configuration file: {f!r}')
699700
_, ext = os.path.splitext(f)
700701
if ext == '.py':

reframe/frontend/loader.py

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -192,33 +192,9 @@ def load_from_file(self, filename, force=False):
192192
return []
193193

194194
try:
195-
dirname = os.path.dirname(filename)
196-
197-
# Load all parent modules of test file
198-
parents = []
199-
while os.path.exists(os.path.join(dirname, '__init__.py')):
200-
parents.append(os.path.join(dirname))
201-
dirname = os.path.split(dirname)[0]
202-
203-
parent_module = None
204-
for pdir in reversed(parents):
205-
with osext.change_dir(pdir):
206-
with util.temp_sys_path(pdir):
207-
package_path = os.path.join(pdir, '__init__.py')
208-
parent_module = util.import_module_from_file(
209-
package_path, parent=parent_module
210-
).__name__
211-
212-
# Now load the actual test file
213-
if not parents:
214-
pdir = dirname
215-
216-
with osext.change_dir(pdir):
217-
with util.temp_sys_path(pdir):
218-
return self.load_from_module(
219-
util.import_module_from_file(filename, force,
220-
parent_module)
221-
)
195+
return self.load_from_module(util.import_module_from_file(
196+
filename, force=force, load_parents=True
197+
))
222198
except Exception:
223199
exc_info = sys.exc_info()
224200
if not is_severe(*exc_info):

reframe/utility/__init__.py

Lines changed: 70 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -71,18 +71,8 @@ def _do_import_module_from_file(filename, module_name=None):
7171
return module
7272

7373

74-
def import_module_from_file(filename, force=False, parent=None):
75-
'''Import module from file.
76-
77-
If the file location refers to a directory, the contained ``__init__.py``
78-
will be loaded. If the filename resolves to a location that is within the
79-
current working directory, a module name will be derived from the supplied
80-
file name and Python's :func:`importlib.import_module` will be invoked to
81-
actually load the module. If the file location refers to a path outside
82-
the current working directory, then the module will be loaded directly
83-
from the file, but it will be assigned a mangled name in
84-
:obj:`sys.modules`, to avoid clashes with other modules loaded using the
85-
standard import mechanism.
74+
def _import_module_from_file(filename, force, parent):
75+
'''Low-level non-recusrive import from file
8676
8777
:arg filename: The path to the filename of a Python module.
8878
:arg force: Force reload of module in case it is already loaded.
@@ -91,9 +81,6 @@ def import_module_from_file(filename, force=False, parent=None):
9181
``parent`` so that Python would be able to resolve relative imports in
9282
the module file.
9383
:returns: The loaded Python module.
94-
95-
.. versionchanged:: 4.6
96-
The ``parent`` argument is added.
9784
'''
9885

9986
# Expand and sanitize filename
@@ -130,6 +117,73 @@ def import_module_from_file(filename, force=False, parent=None):
130117
return importlib.import_module(module_name)
131118

132119

120+
def import_module_from_file(filename, *, force=False, load_parents=False):
121+
'''Import module from file.
122+
123+
If the file location refers to a directory, the contained ``__init__.py``
124+
will be loaded. If the filename resolves to a location that is within the
125+
current working directory, a module name will be derived from the supplied
126+
file name and Python's :func:`importlib.import_module` will be invoked to
127+
actually load the module. If the file location refers to a path outside
128+
the current working directory, then the module will be loaded directly
129+
from the file, but it will be assigned a mangled name in
130+
:obj:`sys.modules`, to avoid clashes with other modules loaded using the
131+
standard import mechanism.
132+
133+
If ``load_parents`` is set, any modules along the path will also be
134+
loaded. A path will considered as a parent module and loaded if it
135+
contains an ``__init__.py`` file.
136+
137+
:arg filename: The path to the filename of a Python module.
138+
:arg force: Force reload of module in case it is already loaded. This does
139+
not apply to the parent modules that have been loaded with
140+
``load_parents=True``.
141+
:arg load_parents: Load parent modules along the path.
142+
:returns: The loaded Python module.
143+
144+
.. versionchanged:: 4.6
145+
The ``parent`` argument is added.
146+
147+
.. versionchanged:: 4.9
148+
The ``parent`` argumet is replaced by ``load_parents``. Also, all
149+
arguments except ``filename`` are now keyword-only arguments.
150+
151+
If the old interface is desired, you should use directly the
152+
lower-level :func:`_import_module_from_file` function.
153+
'''
154+
155+
import reframe.utility.osext as osext
156+
157+
158+
if not load_parents:
159+
return _import_module_from_file(filename, force, None)
160+
161+
dirname = os.path.dirname(filename)
162+
163+
# Load all parent modules of test file
164+
parents = []
165+
while os.path.exists(os.path.join(dirname, '__init__.py')):
166+
parents.append(os.path.join(dirname))
167+
dirname = os.path.split(dirname)[0]
168+
169+
parent_module = None
170+
for pdir in reversed(parents):
171+
with osext.change_dir(pdir):
172+
with temp_sys_path(pdir):
173+
package_path = os.path.join(pdir, '__init__.py')
174+
parent_module = _import_module_from_file(
175+
package_path, force, parent=parent_module
176+
).__name__
177+
178+
# Now load the actual test file
179+
if not parents:
180+
pdir = dirname
181+
182+
with osext.change_dir(pdir):
183+
with temp_sys_path(pdir):
184+
return _import_module_from_file(filename, force, parent_module)
185+
186+
133187
def import_module(module_name, force=False):
134188
'''Import a module.
135189
@@ -162,7 +216,7 @@ def import_module(module_name, force=False):
162216
else:
163217
path += '.py'
164218

165-
return import_module_from_file(path, force)
219+
return _import_module_from_file(path, force, None)
166220

167221

168222
def import_from_module(module_name, symbol):

unittests/test_config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def test_load_config_json(tmp_path):
109109
json_file = tmp_path / 'settings.json'
110110
json_file.write_text(json.dumps(settings.site_configuration, indent=4))
111111
site_config = config.load_config(json_file)
112-
assert site_config.sources == ['<builtin>', json_file]
112+
assert site_config.sources == ['<builtin>', f'{json_file}']
113113

114114

115115
def test_load_config_json_invalid_syntax(tmp_path):

0 commit comments

Comments
 (0)