-
Notifications
You must be signed in to change notification settings - Fork 109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Support for ReFrame test fixtures #1577
Comments
What about a completely different test (in another dir) wants to use the same downloaded repo? would this handle that too? |
This is what I had in mind for the fixtures: A fixture is simply a test that performs a task that is required to run before the main test gets executed. From less to more restrictive, the fixture hierarchy levels should be:
When using a fixture, other reframe tests can override the fixture's default scope with a more restrictive one if they wish to do so. Changing the scope to a less restrictive one does not make sense. When a fixture is further restricted by a test and the less resticted fixture has already run, we can just deep-copy the fixture and its Some examples: class CloneRepo(rfm.RunOnlyRegressionTest, scope='session'):
'''Fixture to clone a repo and share it across the full reframe session.'''
valid_systems = ['*']
valid_prog_environs = ['*']
# Undefined variable. Must be defined in order to use this fixture.
repo = variable(str)
def __init__(self):
sourcesdir = self.repo
class CloneReframe(CloneRepo, scope='session'):
'''Fixture to clone ReFrame.'''
repo = '[email protected]:eth-cscs/reframe.git'
class CloneAndCheckout(CloneReframe, scope='session'):
'''Fixture to clone one a repo and checkout a specific version.
This version would also be shared across the full session. This fixture
would also do the cloning of the repo, which can lead to repeated cloning
if some other test has already used the fixture above.
'''
ver = variable(str)
def __init__(self):
self.postrun_cmds = [f'git checkout {self.ver}']
class Checkout(rfm.RunOnlyRegressionTest, scope='session'):
'''Fixture that uses the resources of another existing fixture.
Instead of cloning the resources for every checkout, this fixture uses the
logic from the fixture `CloneReframe`, and we just copy the sources from that
fixture's directory.
Note that this fixture has `session` for its scope. This is because the
variable `ver` must be set by a derived fixture, and that will make a
specific version of the code available for the full reframe session.
'''
clone = fixture(CloneReframe)
ver = variable(str)
valid_systems = ['*']
valid_prog_environs = ['*']
def __init__(self):
sourcesdir = clone.stagedir
executable = f'git checkout {self.ver}'
class CheckoutV4(Checkout, scope='session'):
'''Specialised fixture to checkout the version 4.0.'''
ver = 'v4.0'
@rfm.simple_test
class RemovePipelineFile(rfm.RunOnlyRegressionTest):
'''Test that removes a file from the sources of a repo (why not).
This will logically cause a side effect on other tests that might use these
source files, so the fixture here has to be restricted to the test only.
Before that, this test wants to remove a file from a specific version,
so we must specialise the `Checkout` fixture. Now, the checkout fixture has
a "session" scope, and removing a file from a fixture that is shared
amonst many test is not a good idea, so we can restrict that fixture to
act only locally on this test by further restricting the scope directly in
the fixture built-in.
This built-in will run the fixture if it hasn't been executed yet.
Otherwise, this will just return the handle to the test and use the
existing resources. Though, in this case, we're further restricting the
scope of the fixture, so the resources of the fixture will be copied from
its original directory onto the current test's stagedir (because the inline
scope is now "test").
'''
fetch_sources = fixture(CheckoutV4, scope='test')
def __init__(self):
the_file = os.path.join(
fetch_sources.stagedir,
'reframe/core/pipeline.py'
)
executable = f'rm {the_file}'
class CloneMultipleRepos(CloneRepo, scope='session'):
'''Fixture that clones a few repos.
Here we reuse the machinery from the `CloneRepo` fixture.
'''
my_repo = parameter([
'[email protected]:eth-cscs/production.git',
'[email protected]:eth-cscs/reframe.git',
])
def __init__(self):
self.repo = self.my_repo
super().__init__()
@rfm.simple_test
class UseFilesFromTwoRepos(rfm.RunOnlyRegressionTest):
'''Test that requires files from two different repos.
We use a parametrised fixture to fetch all these sources.
'''
# This will return a list with the handles for each instance from the fixture.
my_repos = fixture(CloneMultipleRepos)
def __init__(self):
# Copy the files from the repos into the stagedir.
repo = my_repos[0]
self.prerun_cmds = [f'cp {os.path.join(repo.stagedir, file_a.txt)} {self.stagedir}']
repo = my_repos[1]
self.prerun_cmds += [f'cp {os.path.join(repo.stagedir, file_b.txt)} {self.stagedir}']
# ...
@rfm.simple_test
class ForkOffTests(rf.RunOnlyRegressionTest):
'''For each of the parameters in a fixture, run an independent test.
The `CloneMultipleRepos` fixture is parametrized with 2 repos. This
test will run as many times as instances of the parametrized fixture.
This is the equivalent of parameters, but for fixtures.
This will generate 2 test, thanks to the `foreach_fixture` built-in.
'''
my_repo = foreach_fixture(CloneMultipleRepos)
def __init__(self):
self.prerun_cmds = [f'cp {os.path.join(my_repo.stagedir, file_a.txt)} {self.stagedir}']
# ... This last example does exactly what is mentioned in #1618. |
We have invested a lot in dependencies and they offer quite some flexibility and nice features, such as concurrent execution, restoring sessions, generating Gitlab pipelines, access to all the information of the target tests. It would be an immense effort to reimplement everything for fixtures. Why don't we look it the other way around? What is missing from the dependencies to act as fixtures? One obvious thing is the ability of a test to "execute anywhere" instead of the current "execute everywhere." Regarding the scopes, I believe that they can be implemented by creating the right dependency graph. Tests now do have access to everything from their parents and if we introduce the concept of "execute anywhere" or "execute with any environment" etc. (aka scope) then we can have what we need. I like the idea of fixtures being tests or, the other way around, normal tests acting as fixtures, but I would like to see what needs to be added to the tests and/or the dependencies, instead of implementing from scratch a new concept and ending up re-implementing the dependencies. Practically, if we introduce the concept of a test fixture that is basically a reframe test with the special characteristic of "execute in certain scope", I would like the other tests relate to it through the standard |
Where I said above "the main advantage", I should've said "the main difference instead". If the above implied that we should replaced dependencies with fixtures, that's not what I meant :) I think that by keeping fixtures as test, both dependencies and fixtures could very easily use the same machinery in the background, so I don't think there would be a lot to implement here. Whether we end up supporting fixtures or making dependencies act as fixtures, I think the changes would be the same and only the syntax would differ. To me, both dependencies and fixtures are very very similar things. However, the scope is the main difference between these two concepts, and also the fact that fixtures execute unconditionally whereas dependencies may or may not execute depending on the |
Here is an update on the fixtures. I've reworked a little bit the syntax from the previous comments and I've now made the examples with the affinity test. This test is ideal for fixtures, because it only needs to be compiled once and then ran multiple times with different options. class CloneAffinityRepo(rfm.RunOnlyRegressionTest):
sourcesdir = 'https://github.com/vkarak/affinity'
sanity_patterns = sn.assert_found(1)
class PatchAffinityRepo(rfm.RunOnlyRegressionTest):
'''Use the resources of an existing fixture and apply a patch.
Here we're altering the files from the original repo. So to avoid
altering any existing cached clones, we get a fresh clone by
marking the fixture scope as 'test'.
'''
clone = fixture(CloneAffinityRepo, scope='test')
# ...
@rfm.run_before('run')
def apply_patch(self):
'''The sources can be accessed as self.clone.stagedir'''
# ... Just to illustrate the point, we've created a second fixture that applies a patch to the original sources. Fixtures are registered by the fixture or test that uses them, and its scope is also set by the class that uses the fixtures. This is done with the So the real deal in this code snipped happens in the class BuildAffinityTool(rfm.CompileOnlyRegressionTest):
'''Use the patched affinity sources and build the executable.
Here we just read the sources from the `PatchAffinityRepo` fixture
and build the executable in the current test. Because we're not
altering the original sources, the scope of the fixture can be set
to 'session'.
'''
src = fixture(PatchAffinityRepo, scope='session')
# Build the executable ... This class RunAffinityTestBase(rfm.RunOnlyRegressionTest):
'''Here we use the `BuildAffinityTool` to generate the executable.
This one depends on the PrgEnv we're in, so we set that as the
fixture's scope.
'''
aff_tool = fixture(BuildAffinityTool, scope='PrgEnv')
# .... base run config.
@rfm.simple_test
class RunAffinity1(RunAffinityBase):
'''First specialisation of the affinity base test.
This will trigger all the above fixtures before its execution.
'''
# ...
@rfm.simple_test
class RunAffinity2(RunAffinityBase):
'''Another specialisation of the affinity base test.
This test will reuse the `aff_tool` fixture, which was triggered
by the `RunAffinity1` test. However, if this test runs on any
PrgEnv that was not used by `RunAffinity1`, the fixture will run
again for these new PrgEnvs.
'''
# ... Here the Finally, say that someone wants to specialise the build of the class SpecialBuildAffinityTool(BuildAffinityTool):
'''This class specialises the `BuildAffinityTool` fixture.
This could be, for example, adding new compile options.
'''
@rfm.run_before('compile')
def update_compile_opts(self):
self.build_sytem.cxx_flags += ['-O3']
@rfm.simple_test
class RunAffinity3(RunAffinity2):
'''Specialisation of the RunAffinity2.
Here we override the base aff_tool fixture.'''
aff_tool = fixture(SpecialBuildAffinityTool, scope='PrgEnv') Because each fixture is assigned a handle in the test class, we can easily override an existing fixture and fully reuse the existing code for the Next in line to all this is what happens when a fixture has one or more parameters. But I think we should agree on the above first 😅. |
I like the interface and particularly, that any test can be used as fixture. You don't have to mark it as such and there is no need for any magic machinery there. There are some things that I'm kind of missing:
class CloneAffinityRepo(rfm.RunOnlyRegressionTest):
sourcesdir = 'https://github.com/vkarak/affinity'
sanity_patterns = sn.assert_found(1)
class PatchAffinityRepo(rfm.RunOnlyRegressionTest):
clone = fixture(CloneAffinityRepo, scope='test')
@rfm.run_before('run')
def apply_patch(self):
'''The sources can be accessed as self.clone.stagedir'''
class BuildAffinityTool(rfm.CompileOnlyRegressionTest):
src = fixture(PatchAffinityRepo, scope='test')
clone = fixture(CloneAffinityRepo, scope='test') The As a side comment: |
Some updates on the progress here. The first draft implementation uses dependencies under the hood. The fixtures are registered by the "host" test during its instantiation and they get picked up later by the loader. From this point onwards, the fixture is a full reframe test, which the host test depends on. However, a pointer to the fixture object will be injected in the host test during its setup stage. This takes me to the points raised by @vkarak above:
class MyFixture(rfm.RunOnlyRegressionTest):
...
class MyTest(rfm.RunOnlyRegressionTest):
f = fixture(MyFixture)
@run_before('compile')
def print_fixture_stagedir(self):
print(f'Fixture staged: {self.f.stagedir}')
|
We need to support the notion of fixtures for a ReFrame test. A fixture is different from a test dependency and different from a test parameter. The will execute where ReFrame runs and perform operations such as cloning a repo, connecting to a resource that a test needs etc.
Test dependencies in ReFrame differ from fixtures in that they have to deal with the individual test cases (where a test can run and with which programming environments). A fixture will run regardless and will run on the same node that ReFrame itself executes.
Test parameters as discussed in #1567 differ again to fixtures in that the parameterization space is attached to a test we want it to be inherited by its children. Having parameters as fixtures, would require the children to redefine the whole parameter space everytime. We also want parameter attributes to be set.
A typical fixture example, is when you want a test to clone a repo and you don't want to do that for each test case. The current solution is by using test dependencies: have a test to run on a single partition and programming environment and the test doing the actual work depend fully on that one. The problem with this is that you can end up with unresolved dependencies if you run ReFrame with
--system=x:part
wherex:part
is not the partition that the test that clones the source is meant to run. This is in fact a restriction that should not exist. You want to clone the repo regardless of where the test executes. That's why we need to introduce the concept of fixtures. Here is an example that shows potential syntax (inspired by pytest fixtures):Fixtures can be quite intelligent so as not to clone the same repo again and do some reference counting before deleting paths.
In this example,
MyTest
will clone the test once for all the test cases ofMyTest
without having to rely on dependencies for this sort of task.The text was updated successfully, but these errors were encountered: