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