64672fab50db91456cf4c9bb44b3fc9d9909c6b8
[alexxy/gromacs.git] / python_packaging / src / gmxapi / testsupport.py
1 #
2 # This file is part of the GROMACS molecular simulation package.
3 #
4 # Copyright (c) 2019,2020, by the GROMACS development team, led by
5 # Mark Abraham, David van der Spoel, Berk Hess, and Erik Lindahl,
6 # and including many others, as listed in the AUTHORS file in the
7 # top-level source directory and at http://www.gromacs.org.
8 #
9 # GROMACS is free software; you can redistribute it and/or
10 # modify it under the terms of the GNU Lesser General Public License
11 # as published by the Free Software Foundation; either version 2.1
12 # of the License, or (at your option) any later version.
13 #
14 # GROMACS is distributed in the hope that it will be useful,
15 # but WITHOUT ANY WARRANTY; without even the implied warranty of
16 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
17 # Lesser General Public License for more details.
18 #
19 # You should have received a copy of the GNU Lesser General Public
20 # License along with GROMACS; if not, see
21 # http://www.gnu.org/licenses, or write to the Free Software Foundation,
22 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA.
23 #
24 # If you want to redistribute modifications to GROMACS, please
25 # consider that scientific software is very special. Version
26 # control is crucial - bugs must be traceable. We will be happy to
27 # consider code for inclusion in the official distribution, but
28 # derived work must not be called official GROMACS. Details are found
29 # in the README & COPYING files - if they are missing, get the
30 # official version at http://www.gromacs.org.
31 #
32 # To help us fund GROMACS development, we humbly ask that you cite
33 # the research papers on the package. Check out http://www.gromacs.org.
34
35 """Reusable definitions for test modules.
36
37 Provides utilities and pytest fixtures for gmxapi and GROMACS tests.
38
39 To load these facilities in a pytest environment, set a `pytest_plugins`
40 variable in a conftest.py
41 (Reference https://docs.pytest.org/en/latest/writing_plugins.html#requiring-loading-plugins-in-a-test-module-or-conftest-file)
42
43     pytest_plugins = "gmxapi.testsupport"
44
45 .. seealso:: https://docs.pytest.org/en/latest/plugins.html#findpluginname
46
47 .. todo:: Consider moving this to a separate optional package.
48 """
49
50 import json
51 import logging
52 import os
53 import shutil
54 import tempfile
55 import warnings
56 from contextlib import contextmanager
57 from enum import Enum
58 from typing import Union
59
60 import pytest
61
62 mpi_status = 'Test requires mpi4py managing 2 MPI ranks.'
63 skip_mpi = False
64 try:
65     from mpi4py import MPI
66
67     if not MPI.Is_initialized():
68         skip_mpi = True
69         mpi_status += ' MPI is not initialized'
70     elif MPI.COMM_WORLD.Get_size() < 2:
71         skip_mpi = True
72         mpi_status += ' MPI context is too small.'
73 except ImportError:
74     skip_mpi = True
75     mpi_status += ' mpi4py is not available.'
76
77
78 def pytest_configure(config):
79     config.addinivalue_line("markers", "withmpi_only: test requires mpi4py managing 2 MPI ranks.")
80
81
82 def pytest_runtest_setup(item):
83     # Handle the withmpi_only marker.
84     for _ in item.iter_markers(name='withmpi_only'):
85         if skip_mpi:
86             pytest.skip(mpi_status)
87         # The API uses iteration because markers may be duplicated, but we only
88         # care about whether 'withmpi_only' occurs at all.
89         break
90
91
92 def pytest_addoption(parser):
93     """Add a command-line user option for the pytest invocation."""
94     parser.addoption(
95         '--rm',
96         action='store',
97         default='always',
98         choices=['always', 'never', 'success'],
99         help='Remove temporary directories "always", "never", or on "success".'
100     )
101
102
103 class RmOption(Enum):
104     """Enumerate allowable values of the --rm option."""
105     always = 'always'
106     never = 'never'
107     success = 'success'
108
109
110 @pytest.fixture(scope='session')
111 def remove_tempdir(request) -> RmOption:
112     """pytest fixture to get access to the --rm CLI option."""
113     arg = request.config.getoption('--rm')
114     return RmOption(arg)
115
116
117 @contextmanager
118 def scoped_chdir(dir):
119     oldpath = os.getcwd()
120     os.chdir(dir)
121     try:
122         yield dir
123         # If the `with` block using scoped_chdir produces an exception, it will
124         # be raised at this point in this function. We want the exception to
125         # propagate out of the `with` block, but first we want to restore the
126         # original working directory, so we skip `except` but provide a `finally`.
127     finally:
128         os.chdir(oldpath)
129
130
131 @contextmanager
132 def _cleandir(remove_tempdir: Union[str, RmOption]):
133     """Context manager for a clean temporary working directory.
134
135     Arguments:
136         remove_tempdir (RmOption): whether to remove temporary directory "always",
137                                    "never", or on "success"
138
139     Raises:
140         ValueError: if remove_tempdir value is not valid.
141
142     The context manager will issue a warning for each temporary directory that
143     is not removed.
144     """
145     if not isinstance(remove_tempdir, RmOption):
146         remove_tempdir = RmOption(remove_tempdir)
147
148     newpath = tempfile.mkdtemp()
149
150     def remove():
151         shutil.rmtree(newpath)
152
153     def warn():
154         warnings.warn('Temporary directory not removed: {}'.format(newpath))
155
156     # Initialize callback function reference
157     if remove_tempdir == RmOption.always:
158         callback = remove
159     else:
160         callback = warn
161
162     try:
163         with scoped_chdir(newpath):
164             yield newpath
165         # If we get to this line, the `with` block using _cleandir did not throw.
166         # Clean up the temporary directory unless the user specified `--rm never`.
167         # I.e. If the user specified `--rm success`, then we need to toggle from `warn` to `remove`.
168         if remove_tempdir != RmOption.never:
169             callback = remove
170     finally:
171         callback()
172
173
174 @pytest.fixture
175 def cleandir(remove_tempdir: RmOption):
176     """Provide a clean temporary working directory for a test.
177
178     Example usage:
179
180         import os
181         import pytest
182
183         @pytest.mark.usefixtures("cleandir")
184         def test_cwd_starts_empty():
185             assert os.listdir(os.getcwd()) == []
186             with open("myfile", "w") as f:
187                 f.write("hello")
188
189         def test_cwd_also_starts_empty(cleandir):
190             assert os.listdir(os.getcwd()) == []
191             assert os.path.abspath(os.getcwd()) == os.path.abspath(cleandir)
192             with open("myfile", "w") as f:
193                 f.write("hello")
194
195         @pytest.mark.usefixtures("cleandir")
196         class TestDirectoryInit(object):
197             def test_cwd_starts_empty(self):
198                 assert os.listdir(os.getcwd()) == []
199                 with open("myfile", "w") as f:
200                     f.write("hello")
201
202             def test_cwd_also_starts_empty(self):
203                 assert os.listdir(os.getcwd()) == []
204                 with open("myfile", "w") as f:
205                     f.write("hello")
206
207     Ref: https://docs.pytest.org/en/latest/fixture.html#using-fixtures-from-classes-modules-or-projects
208     """
209     with _cleandir(remove_tempdir) as newdir:
210         yield newdir
211
212
213 class GmxBin:
214     """Represent the detected GROMACS installation."""
215     def __init__(self):
216         # Try to use package resources to locate the "gmx" binary wrapper.
217         try:
218             from importlib.resources import open_text
219             with open_text('gmxapi', 'gmxconfig.json') as textfile:
220                 config = json.load(textfile)
221                 gmxbindir = config.get('gmx_bindir', None)
222                 command = config.get('gmx_executable', None)
223         except ImportError:
224             try:
225                 # A backport of importlib.resources is available as importlib_resources
226                 # with a somewhat different interface.
227                 from importlib_resources import files, as_file
228
229                 source = files('gmxapi').joinpath('gmxconfig.json')
230                 with as_file(source) as gmxconfig:
231                     with open(gmxconfig, 'r') as fp:
232                         config = json.load(fp)
233                         gmxbindir = config.get('gmx_bindir', None)
234                         command = config.get('gmx_executable', None)
235             except ImportError:
236                 gmxbindir = None
237                 command = None
238
239         # TODO: Remove fall-back when we can rely on gmxconfig.json via importlib.resources in Py 3.7+.
240         allowed_command_names = ['gmx', 'gmx_mpi']
241         for command_name in allowed_command_names:
242             if command is not None:
243                 break
244             command = shutil.which(command_name)
245             if command is None:
246                 gmxbindir = os.getenv('GMXBIN')
247                 if gmxbindir is None:
248                     gromacsdir = os.getenv('GROMACS_DIR')
249                     if gromacsdir is not None and gromacsdir != '':
250                         gmxbindir = os.path.join(gromacsdir, 'bin')
251                 if gmxbindir is None:
252                     gmxapidir = os.getenv('gmxapi_DIR')
253                     if gmxapidir is not None and gmxapidir != '':
254                         gmxbindir = os.path.join(gmxapidir, 'bin')
255                 if gmxbindir is not None:
256                     gmxbindir = os.path.abspath(gmxbindir)
257                     command = shutil.which(command_name, path=gmxbindir)
258
259         self._command = command
260         self._bindir = gmxbindir
261
262     def command(self):
263         return self._command
264
265     def bindir(self):
266         return self._bindir
267
268
269 _gmx = GmxBin()
270
271
272 @pytest.fixture(scope='session')
273 def gmxcli():
274     command = _gmx.command()
275     if command is None:
276         message = "Tests need 'gmx' command line tool, but could not find it on the path."
277         raise RuntimeError(message)
278     try:
279         assert os.access(command, os.X_OK)
280     except Exception as E:
281         raise RuntimeError('"{}" is not an executable gmx wrapper program'.format(command)) from E
282     yield command