1import sys
2import os
3from pathlib import Path
4from subprocess import Popen, PIPE, check_output
5import zlib
6
7import pytest
8import numpy as np
9
10import ase
11from ase.utils import workdir, seterr
12from ase.test.factories import (CalculatorInputs,
13                                factory_classes,
14                                NoSuchCalculator,
15                                get_factories,
16                                make_factory_fixture)
17from ase.dependencies import all_dependencies
18
19helpful_message = """\
20 * Use --calculators option to select calculators.
21
22 * See "ase test --help-calculators" on how to configure calculators.
23
24 * This listing only includes external calculators known by the test
25   system.  Others are "configured" by setting an environment variable
26   like "ASE_xxx_COMMAND" in order to allow tests to run.  Please see
27   the documentation of that individual calculator.
28"""
29
30
31def pytest_report_header(config, startdir):
32    yield from library_header()
33    yield ''
34    yield from calculators_header(config)
35
36
37def library_header():
38    yield ''
39    yield 'Libraries'
40    yield '========='
41    yield ''
42    for name, path in all_dependencies():
43        yield '{:24} {}'.format(name, path)
44
45
46def calculators_header(config):
47    try:
48        factories = get_factories(config)
49    except NoSuchCalculator as err:
50        pytest.exit(f'No such calculator: {err}')
51
52    configpaths = factories.executable_config_paths
53    module = factories.datafiles_module
54
55    yield ''
56    yield 'Calculators'
57    yield '==========='
58
59    if not configpaths:
60        configtext = 'No configuration file specified'
61    else:
62        configtext = ', '.join(str(path) for path in configpaths)
63    yield f'Config: {configtext}'
64
65    if module is None:
66        datafiles_text = 'ase-datafiles package not installed'
67    else:
68        datafiles_text = str(Path(module.__file__).parent)
69
70    yield f'Datafiles: {datafiles_text}'
71    yield ''
72
73    for name in sorted(factory_classes):
74        if name in factories.builtin_calculators:
75            # Not interesting to test presence of builtin calculators.
76            continue
77
78        factory = factories.factories.get(name)
79
80        if factory is None:
81            configinfo = 'not installed'
82        else:
83            # Some really ugly hacks here:
84            if hasattr(factory, 'importname'):
85                import importlib
86                module = importlib.import_module(factory.importname)
87                configinfo = str(module.__path__[0])  # type: ignore
88            else:
89                configtokens = []
90                for varname, variable in vars(factory).items():
91                    configtokens.append(f'{varname}={variable}')
92                configinfo = ', '.join(configtokens)
93
94        enabled = factories.enabled(name)
95        if enabled:
96            version = '<unknown version>'
97            if hasattr(factory, 'version'):
98                try:
99                    version = factory.version()
100                except Exception:
101                    # XXX Add a test for the version numbers so that
102                    # will fail without crashing the whole test suite.
103                    pass
104            name = f'{name}-{version}'
105
106        run = '[x]' if enabled else '[ ]'
107        line = f'  {run} {name:16} {configinfo}'
108        yield line
109
110    yield ''
111    yield helpful_message
112    yield ''
113
114    # (Where should we do this check?)
115    for name in factories.requested_calculators:
116        if not factories.is_adhoc(name) and not factories.installed(name):
117            pytest.exit(f'Calculator "{name}" is not installed.  '
118                        'Please run "ase test --help-calculators" on how '
119                        'to install calculators')
120
121
122@pytest.fixture(scope='session', autouse=True)
123def monkeypatch_disabled_calculators(request, factories):
124    # XXX Replace with another mechanism.
125    factories.monkeypatch_disabled_calculators()
126
127
128@pytest.fixture(scope='session', autouse=True)
129def sessionlevel_testing_path():
130    # We cd into a tempdir so tests and fixtures won't create files
131    # elsewhere (e.g. in the unsuspecting user's directory).
132    #
133    # However we regard it as an error if the tests leave files there,
134    # because they can access each others' files and hence are not
135    # independent.  Therefore we want them to explicitly use the
136    # "testdir" fixture which ensures that each has a clean directory.
137    #
138    # To prevent tests from writing files, we chmod the directory.
139    # But if the tests are killed, we cannot clean it up and it will
140    # disturb other pytest runs if we use the pytest tempdir factory.
141    #
142    # So we use the tempfile module for this temporary directory.
143    import tempfile
144    with tempfile.TemporaryDirectory(prefix='ase-test-workdir-') as tempdir:
145        path = Path(tempdir)
146        path.chmod(0o555)
147        with workdir(path):
148            yield path
149        path.chmod(0o755)
150
151
152@pytest.fixture(autouse=False)
153def testdir(tmp_path):
154    # Pytest can on some systems provide a Path from pathlib2.  Normalize:
155    path = Path(str(tmp_path))
156    with workdir(path, mkdir=True):
157        yield tmp_path
158    # We print the path so user can see where test failed, if it failed.
159    print(f'Testdir: {path}')
160
161
162@pytest.fixture
163def allraise():
164    with seterr(all='raise'):
165        yield
166
167
168@pytest.fixture
169def KIM():
170    pytest.importorskip('kimpy')
171    from ase.calculators.kim import KIM as _KIM
172    from ase.calculators.kim.exceptions import KIMModelNotFound
173
174    def KIM(*args, **kwargs):
175        try:
176            return _KIM(*args, **kwargs)
177        except KIMModelNotFound:
178            pytest.skip('KIM tests require the example KIM models.  '
179                        'These models are available if the KIM API is '
180                        'built from source.  See https://openkim.org/kim-api/'
181                        'for more information.')
182
183    return KIM
184
185
186@pytest.fixture(scope='session')
187def tkinter():
188    import tkinter
189    try:
190        tkinter.Tk()
191    except tkinter.TclError as err:
192        pytest.skip('no tkinter: {}'.format(err))
193
194
195@pytest.fixture(autouse=True)
196def _plt_close_figures():
197    yield
198    plt = sys.modules.get('matplotlib.pyplot')
199    if plt is None:
200        return
201    fignums = plt.get_fignums()
202    for fignum in fignums:
203        plt.close(fignum)
204
205
206@pytest.fixture(scope='session', autouse=True)
207def _plt_use_agg():
208    try:
209        import matplotlib
210    except ImportError:
211        pass
212    else:
213        matplotlib.use('Agg')
214
215
216@pytest.fixture(scope='session')
217def plt(_plt_use_agg):
218    pytest.importorskip('matplotlib')
219
220    import matplotlib.pyplot as plt
221    return plt
222
223
224@pytest.fixture
225def figure(plt):
226    fig = plt.figure()
227    yield fig
228    plt.close(fig)
229
230
231@pytest.fixture(scope='session')
232def psycopg2():
233    return pytest.importorskip('psycopg2')
234
235
236@pytest.fixture(scope='session')
237def factories(pytestconfig):
238    return get_factories(pytestconfig)
239
240
241# XXX Maybe we should not have individual factory fixtures, we could use
242# the decorator @pytest.mark.calculator(name) instead.
243abinit_factory = make_factory_fixture('abinit')
244cp2k_factory = make_factory_fixture('cp2k')
245dftb_factory = make_factory_fixture('dftb')
246espresso_factory = make_factory_fixture('espresso')
247gpaw_factory = make_factory_fixture('gpaw')
248octopus_factory = make_factory_fixture('octopus')
249siesta_factory = make_factory_fixture('siesta')
250
251
252@pytest.fixture
253def factory(request, factories):
254    name, kwargs = request.param
255    if not factories.installed(name):
256        pytest.skip(f'Not installed: {name}')
257    if not factories.enabled(name):
258        pytest.skip(f'Not enabled: {name}')
259    factory = factories[name]
260    return CalculatorInputs(factory, kwargs)
261
262
263def pytest_generate_tests(metafunc):
264    from ase.test.factories import parametrize_calculator_tests
265    parametrize_calculator_tests(metafunc)
266
267    if 'seed' in metafunc.fixturenames:
268        seeds = metafunc.config.getoption('seed')
269        if len(seeds) == 0:
270            seeds = [0]
271        else:
272            seeds = list(map(int, seeds))
273        metafunc.parametrize('seed', seeds)
274
275
276class CLI:
277    def __init__(self, calculators):
278        self.calculators = calculators
279
280    def ase(self, *args, expect_fail=False):
281        environment = {}
282        environment.update(os.environ)
283        # Prevent failures due to Tkinter-related default backend
284        # on systems without Tkinter.
285        environment['MPLBACKEND'] = 'Agg'
286
287        proc = Popen(['ase', '-T'] + list(args),
288                     stdout=PIPE, stdin=PIPE,
289                     env=environment)
290        stdout, _ = proc.communicate(b'')
291        status = proc.wait()
292        assert (status != 0) == expect_fail
293        return stdout.decode('utf-8')
294
295    def shell(self, command, calculator_name=None):
296        # Please avoid using shell comamnds including this method!
297        if calculator_name is not None:
298            self.calculators.require(calculator_name)
299
300        actual_command = ' '.join(command.split('\n')).strip()
301        output = check_output(actual_command, shell=True)
302        return output.decode()
303
304
305@pytest.fixture(scope='session')
306def cli(factories):
307    return CLI(factories)
308
309
310@pytest.fixture(scope='session')
311def datadir():
312    test_basedir = Path(__file__).parent
313    return test_basedir / 'testdata'
314
315
316@pytest.fixture
317def pt_eam_potential_file(datadir):
318    # EAM potential for Pt from LAMMPS, also used with eam calculator.
319    # (Where should this fixture really live?)
320    return datadir / 'eam_Pt_u3.dat'
321
322
323@pytest.fixture(scope='session')
324def asap3():
325    return pytest.importorskip('asap3')
326
327
328@pytest.fixture(autouse=True)
329def arbitrarily_seed_rng(request):
330    # We want tests to not use global stuff such as np.random.seed().
331    # But they do.
332    #
333    # So in lieu of (yet) fixing it, we reseed and unseed the random
334    # state for every test.  That makes each test deterministic if it
335    # uses random numbers without seeding, but also repairs the damage
336    # done to global state if it did seed.
337    #
338    # In order not to generate all the same random numbers in every test,
339    # we seed according to a kind of hash:
340    ase_path = ase.__path__[0]
341    abspath = Path(request.module.__file__)
342    relpath = abspath.relative_to(ase_path)
343    module_identifier = relpath.as_posix()  # Same on all platforms
344    function_name = request.function.__name__
345    hashable_string = f'{module_identifier}:{function_name}'
346    # We use zlib.adler32() rather than hash() because Python randomizes
347    # the string hashing at startup for security reasons.
348    seed = zlib.adler32(hashable_string.encode('ascii')) % 12345
349    # (We should really use the full qualified name of the test method.)
350    state = np.random.get_state()
351    np.random.seed(seed)
352    yield
353    print(f'Global seed for "{hashable_string}" was: {seed}')
354    np.random.set_state(state)
355
356
357@pytest.fixture(scope='session')
358def povray_executable():
359    import shutil
360    exe = shutil.which('povray')
361    if exe is None:
362        pytest.skip('povray not installed')
363    return exe
364
365
366def pytest_addoption(parser):
367    parser.addoption('--calculators', metavar='NAMES', default='',
368                     help='comma-separated list of calculators to test or '
369                     '"auto" for all configured calculators')
370    parser.addoption('--seed', action='append', default=[],
371                     help='add a seed for tests where random number generators'
372                          ' are involved. This option can be applied more'
373                          ' than once.')
374