Merge commit d30f2cb6 from release-2020 into master
[alexxy/gromacs.git] / python_packaging / src / test / conftest.py
1 #
2 # This file is part of the GROMACS molecular simulation package.
3 #
4 # Copyright (c) 2019, 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 """Configuration and fixtures for pytest."""
36
37 import json
38 import logging
39 import os
40 import shutil
41 import tempfile
42 import warnings
43 from contextlib import contextmanager
44
45 import pytest
46
47 pytest_plugins = ('gmxapi.testsupport',)
48
49 def pytest_addoption(parser):
50     """Add a command-line user option for the pytest invocation."""
51     parser.addoption(
52         '--rm',
53         action='store',
54         default='always',
55         choices=['always', 'never', 'success'],
56         help='Remove temporary directories "always", "never", or on "success".'
57     )
58
59
60 @pytest.fixture(scope='session')
61 def remove_tempdir(request):
62     """pytest fixture to get access to the --rm CLI option."""
63     return request.config.getoption('--rm')
64
65
66 @contextmanager
67 def scoped_chdir(dir):
68     oldpath = os.getcwd()
69     os.chdir(dir)
70     try:
71         yield dir
72         # If the `with` block using scoped_chdir produces an exception, it will
73         # be raised at this point in this function. We want the exception to
74         # propagate out of the `with` block, but first we want to restore the
75         # original working directory, so we skip `except` but provide a `finally`.
76     finally:
77         os.chdir(oldpath)
78
79
80 @contextmanager
81 def _cleandir(remove_tempdir):
82     """Context manager for a clean temporary working directory.
83
84     Arguments:
85         remove_tempdir (str): whether to remove temporary directory "always",
86                               "never", or on "success"
87
88     The context manager will issue a warning for each temporary directory that
89     is not removed.
90     """
91
92     newpath = tempfile.mkdtemp()
93
94     def remove():
95         shutil.rmtree(newpath)
96
97     def warn():
98         warnings.warn('Temporary directory not removed: {}'.format(newpath))
99
100     if remove_tempdir == 'always':
101         callback = remove
102     else:
103         callback = warn
104     try:
105         with scoped_chdir(newpath):
106             yield newpath
107         # If we get to this line, the `with` block using _cleandir did not throw.
108         # Clean up the temporary directory unless the user specified `--rm never`.
109         # I.e. If the user specified `--rm success`, then we need to toggle from `warn` to `remove`.
110         if remove_tempdir != 'never':
111             callback = remove
112     finally:
113         callback()
114
115
116 @pytest.fixture
117 def cleandir(remove_tempdir):
118     """Provide a clean temporary working directory for a test.
119
120     Example usage:
121
122         import os
123         import pytest
124
125         @pytest.mark.usefixtures("cleandir")
126         def test_cwd_starts_empty():
127             assert os.listdir(os.getcwd()) == []
128             with open("myfile", "w") as f:
129                 f.write("hello")
130
131         def test_cwd_also_starts_empty(cleandir):
132             assert os.listdir(os.getcwd()) == []
133             assert os.path.abspath(os.getcwd()) == os.path.abspath(cleandir)
134             with open("myfile", "w") as f:
135                 f.write("hello")
136
137         @pytest.mark.usefixtures("cleandir")
138         class TestDirectoryInit(object):
139             def test_cwd_starts_empty(self):
140                 assert os.listdir(os.getcwd()) == []
141                 with open("myfile", "w") as f:
142                     f.write("hello")
143
144             def test_cwd_also_starts_empty(self):
145                 assert os.listdir(os.getcwd()) == []
146                 with open("myfile", "w") as f:
147                     f.write("hello")
148
149     Ref: https://docs.pytest.org/en/latest/fixture.html#using-fixtures-from-classes-modules-or-projects
150     """
151     with _cleandir(remove_tempdir) as newdir:
152         yield newdir
153
154
155 @pytest.fixture(scope='session')
156 def gmxcli():
157     # TODO: (#2896) Find a more canonical way to identify the GROMACS commandline wrapper binary.
158     #  We should be able to get the GMXRC contents and related hints from a gmxapi
159     #  package resource or from module attributes of a ``gromacs`` stub package.
160     allowed_command_names = ['gmx', 'gmx_mpi']
161     command = None
162     for command_name in allowed_command_names:
163         if command is not None:
164             break
165         command = shutil.which(command_name)
166         if command is None:
167             gmxbindir = os.getenv('GMXBIN')
168             if gmxbindir is None:
169                 gromacsdir = os.getenv('GROMACS_DIR')
170                 if gromacsdir is not None and gromacsdir != '':
171                     gmxbindir = os.path.join(gromacsdir, 'bin')
172             if gmxbindir is None:
173                 gmxapidir = os.getenv('gmxapi_DIR')
174                 if gmxapidir is not None and gmxapidir != '':
175                     gmxbindir = os.path.join(gmxapidir, 'bin')
176             if gmxbindir is not None:
177                 gmxbindir = os.path.abspath(gmxbindir)
178                 command = shutil.which(command_name, path=gmxbindir)
179     if command is None:
180         message = "Tests need 'gmx' command line tool, but could not find it on the path."
181         raise RuntimeError(message)
182     try:
183         assert os.access(command, os.X_OK)
184     except Exception as E:
185         raise RuntimeError('"{}" is not an executable gmx wrapper program'.format(command)) from E
186     yield command
187
188
189 @pytest.fixture(scope='class')
190 def spc_water_box(gmxcli, remove_tempdir):
191     """Provide a TPR input file for a simple simulation.
192
193     Prepare the MD input in a freshly created working directory.
194     """
195     import gmxapi as gmx
196
197     # TODO: (#2896) Fetch MD input from package / library data.
198     # Example:
199     #     import pkg_resources
200     #     # Note: importing pkg_resources means setuptools is required for running this test.
201     #     # Get or build TPR file from data bundled via setup(package_data=...)
202     #     # Ref https://setuptools.readthedocs.io/en/latest/setuptools.html#including-data-files
203     #     from gmx.data import tprfilename
204
205     with _cleandir(remove_tempdir) as tempdir:
206
207         testdir = os.path.dirname(__file__)
208         with open(os.path.join(testdir, 'testdata.json'), 'r') as fh:
209             testdata = json.load(fh)
210
211         # TODO: (#2756) Don't rely on so many automagical behaviors (as described in comments below)
212
213         structurefile = os.path.join(tempdir, 'structure.gro')
214         # We let `gmx solvate` use the default solvent. Otherwise, we would do
215         #     gro_input = testdata['solvent_structure']
216         #     with open(structurefile, 'w') as fh:
217         #         fh.write('\n'.join(gro_input))
218         #         fh.write('\n')
219
220         topfile = os.path.join(tempdir, 'topology.top')
221         top_input = testdata['solvent_topology']
222         # `gmx solvate` will append a line to the provided file with the molecule count,
223         # so we strip the last line from the input topology.
224         with open(topfile, 'w') as fh:
225             fh.write('\n'.join(top_input[:-1]))
226             fh.write('\n')
227
228         assert os.path.exists(topfile)
229         solvate = gmx.commandline_operation(gmxcli,
230                                             arguments=['solvate', '-box', '5', '5', '5'],
231                                             # We use the default solvent instead of specifying one.
232                                             # input_files={'-cs': structurefile},
233                                             output_files={'-p': topfile,
234                                                           '-o': structurefile,
235                                                           }
236                                             )
237         assert os.path.exists(topfile)
238
239         if solvate.output.returncode.result() != 0:
240             logging.debug(solvate.output.erroroutput.result())
241             raise RuntimeError('solvate failed in spc_water_box testing fixture.')
242
243         # Choose an exactly representable dt of 2^-9 ps (approximately 0.002)
244         dt = 2.**-9.
245         mdp_input = [('integrator', 'md'),
246                      ('dt', dt),
247                      ('cutoff-scheme', 'Verlet'),
248                      ('nsteps', 2),
249                      ('nstxout', 1),
250                      ('nstvout', 1),
251                      ('nstfout', 1),
252                      ('tcoupl', 'v-rescale'),
253                      ('tc-grps', 'System'),
254                      ('tau-t', 1),
255                      ('ref-t', 298)]
256         mdp_input = '\n'.join([' = '.join([str(item) for item in kvpair]) for kvpair in mdp_input])
257         mdpfile = os.path.join(tempdir, 'md.mdp')
258         with open(mdpfile, 'w') as fh:
259             fh.write(mdp_input)
260             fh.write('\n')
261         tprfile = os.path.join(tempdir, 'topol.tpr')
262         # We don't use mdout_mdp, but if we don't specify it to grompp,
263         # it will be created in the current working directory.
264         mdout_mdp = os.path.join(tempdir, 'mdout.mdp')
265
266         grompp = gmx.commandline_operation(gmxcli, 'grompp',
267                                            input_files={
268                                                '-f': mdpfile,
269                                                '-p': solvate.output.file['-p'],
270                                                '-c': solvate.output.file['-o'],
271                                                '-po': mdout_mdp,
272                                            },
273                                            output_files={'-o': tprfile})
274         tprfilename = grompp.output.file['-o'].result()
275         if grompp.output.returncode.result() != 0:
276             logging.debug(grompp.output.erroroutput.result())
277             raise RuntimeError('grompp failed in spc_water_box testing fixture.')
278
279         # TODO: more inspection of grompp errors...
280         assert os.path.exists(tprfilename)
281         yield tprfilename