"""
Module containing installation tests.
These determine which features are available in an installation of MDMC.
This includes:
- Whether MDMC can be imported.
- Whether an MD engine can be run (e.g. LAMMPS).
- Whether X11 forwarding is enabled and the ASE gui can run.
- Whether the optional dependencies for the dynamic plotting utility have been
installed.
These tests exist so that a user can test their installation of MDMC.
"""
import logging
from abc import ABC, abstractmethod
from glob import glob
from importlib import import_module
from os.path import basename, dirname, join
from typing import Callable, Dict, Literal, Optional
LOGGER = logging.getLogger(__name__)
# pylint: disable=import-outside-toplevel, unused-import
# these utilities explicitly test importing on purpose in this way
[docs]
class InstlTestBase(ABC):
"""
Base class for installation tests.
Attributes
----------
success : Literal['PASSED', 'FAILED', 'INCOMPLETE']
A `str` denoting if the test has PASSED, FAILED, or is INCOMPLETE.
name : str
Name of current test.
"""
def __init__(self):
self._success: Optional[bool] = None
self.name: Optional[str] = None
@property
def success(self) -> Literal['PASSED', 'FAILED', 'INCOMPLETE']:
"""
Get whether or not the test has PASSED, FAILED or is INCOMPLETE.
Returns
-------
str
One of "PASSED", "FAILED" or "INCOMPLETE"
depending on test state.
Raises
------
ValueError
If test state is invalid.
"""
if self._success is True:
return 'PASSED'
if self._success is False:
return 'FAILED'
if self._success is None:
return 'INCOMPLETE'
raise ValueError('The success value of this test has been incorrectly'
' set. Please run the test again.')
[docs]
def log_test_passed(self) -> None:
"""
Log that the test passed.
"""
LOGGER.info('%s %s installation test passed',
self.__class__,
self.name)
[docs]
@abstractmethod
def run(self):
"""
Run the test and sets the value of `self.success`.
"""
raise NotImplementedError
[docs]
class InstlTestFactory:
"""
Testing factory class.
A factory class which keeps a registry of the installation tests
and creates instances of them.
"""
registry: Dict[str, InstlTestBase] = {}
[docs]
@classmethod
def register(cls, name: str) -> Callable:
"""
Decorator for registering installation test classes.
The name with which the test is registered should be the parameter
passed to the decorator.
Parameters
----------
name : str
The name with which the test is registered.
Returns
-------
`function`
Wrapped class registered in factory.
Examples
--------
To register the ``InstlTestCore`` class with ``InstlTestFactory``:
.. highlight:: python
.. code-block:: python
@InstlTestFactory.register('core')
class InstlTestCore(InstlTestBase):
"""
def class_wrapper(wrapped_class: InstlTestBase) -> Callable:
cls.registry[name] = wrapped_class
return wrapped_class
return class_wrapper
[docs]
@classmethod
def create_instl_test(cls, name: str) -> InstlTestBase:
"""
Instantiate the installation test for the class `name`.
Parameters
----------
name : str
The ``registry`` name of the class to be initialized.
Returns
-------
InstlTestBase
Corresponding class with name `name`.
"""
instl_test = cls.registry[name]()
instl_test.name = name
return instl_test
[docs]
def run_installation_tests():
"""
Run all installation tests and print the result for each test.
"""
for name in InstlTestFactory.registry:
instl_test = InstlTestFactory.create_instl_test(name)
instl_test.run()
# Padded with spaces to ensure alignment
print('{0: <30} {1}'.format(instl_test.name, instl_test.success))
[docs]
@InstlTestFactory.register('core')
class InstlTestCore(InstlTestBase):
"""
Class to test if all MDMC subpackages can be imported.
"""
[docs]
def run(self) -> None:
import MDMC
fs_objects = glob(join(dirname(MDMC.__file__), "*"))
for fso in fs_objects:
fso_base = basename(fso)
if '.py' not in fso_base[-4:] and fso_base != '__pycache__':
try:
import_module('MDMC.' + fso_base)
except ImportError:
self._success = False
LOGGER.error('%s %s installation test failed because the'
' following package/module did not import: %s',
self.__class__,
self.name,
fso_base,
exc_info=True)
break
except Exception as err:
self._success = False
LOGGER.error('%s %s installation test failed because an'
' exception occured (other than an'
' ImportError) while MDMC was being imported.',
self.__class__,
self.name,
exc_info=True)
raise Exception('An Exception (other than an ImportError)'
' occured while MDMC was being imported.'
' It appears MDMC has installed but this'
' Exception is likely to reduce'
' functionality.') from err
if self._success is None:
self._success = True
self.log_test_passed()
[docs]
@InstlTestFactory.register('LAMMPS')
class InstlTestLAMMPS(InstlTestBase):
"""
Class to test if LAMMPS is installed and PyLammps interface can be accessed.
"""
LOG_ERROR_MSG: str = ('Due to this, MDMC will not be able to run MD'
' simulations with LAMMPS as the MD engine. Other MD'
' engines may still be available.')
[docs]
def run(self) -> None:
try:
from lammps import PyLammps
except ImportError:
self._success = False
LOGGER.error('%s %s installation test failed because LAMMPS Python'
' interface could not be imported, as lammps.py is not'
' in the PYTHONPATH. Please see the LAMMPS'
' documentation for how to rectify this. %s',
self.__class__,
self.name,
self.LOG_ERROR_MSG,
exc_info=True)
except Exception as err:
self._success = False
LOGGER.error('%s %s installation test failed because the following'
' Exception was thrown while the LAMMPS Python'
' interface was being imported: %s',
self.__class__,
self.name,
self.LOG_ERROR_MSG,
exc_info=True)
raise Exception from err
try:
lmp = PyLammps()
except OSError:
self._success = False
LOGGER.error('%s %s installation test failed because a PyLammps'
' instance could not be initialized. This is because'
' lammps.py cannot find the LAMMPS library (either'
' liblammps.so or liblammps.dylib). This can be'
' rectified by ensuring that LAMMPS has been built as'
' a shared library and the LAMMPS library is in the'
' PYTHONPATH. Please see the LAMMPS documentation'
' for further information. %s',
self.__class__,
self.name,
self.LOG_ERROR_MSG,
exc_info=True)
except Exception as err:
self._success = False
LOGGER.error('%s %s installation test failed because the following'
' Exception was thrown while a PyLammps instance was'
' being initialized. %s',
self.__class__,
self.name,
self.LOG_ERROR_MSG,
exc_info=True)
raise Exception from err
if self._success is None:
self._success = True
self.log_test_passed()
lmp.close()
[docs]
@InstlTestFactory.register('X11 forwarding')
class InstlTestX11Forwarding(InstlTestBase):
"""
Class to test if tkinter can access the display.
This is used to determine if X11 forwarding is working
in Docker/Singularity containers.
"""
LOG_ERROR_MSG: str = ('Due to this, GUI elements requiring tkinter, such as'
' the ASE viewer, will not be available. Other viewer'
' options, such as the X3DOM viewer, can still be'
' used.')
[docs]
def run(self) -> None:
try:
from tkinter import TclError, Tk
except ImportError:
self._success = False
LOGGER.error('%s %s installation test failed because tkinter could'
' not be imported. %s',
self.__class__,
self.name,
self.LOG_ERROR_MSG,
exc_info=True)
try:
Tk()
except TclError:
LOGGER.error('%s %s installation test failed because tkinter.Tk'
' could not be initialized. This is probably because'
' the DISPLAY cannot be accessed, which will occur if'
' X11 forwarding has not been enabled. If MDMC is'
' being run within Docker, please see the MDMC'
' installation instructions for how to enable X11'
' forwarding. %s',
self.__class__,
self.name,
self.LOG_ERROR_MSG,
exc_info=True)
self._success = False
if self._success is None:
self._success = True
self.log_test_passed()
[docs]
@InstlTestFactory.register('Dynamic plotting dependencies')
class InstlTestDynamicPlottingDependencies(InstlTestBase):
"""
Test if the dynamic plotting optional dependencies are installed.
"""
LOG_ERROR_MSG = ('Due to this, dynamic plotting of the refinement will not'
' be possible.')
[docs]
def run(self) -> None:
try:
import ipywidgets # noqa: F401
import jupyter # noqa: F401
import matplotlib # noqa: F401
except ImportError as err:
self._success = False
# err.name for an ImportError is the name of the missing library
LOGGER.error('%s %s installation test failed because %s could not'
' be imported. Please install this library to use'
' dynamic plotting. %s',
self.__class__,
self.name,
err.name,
self.LOG_ERROR_MSG,
exc_info=True)
except Exception as err:
self._success = False
LOGGER.error('%s %s installation test failed because the following'
' Exception was thrown when the required dependencies'
' were being imported. %s',
self.__class__,
self.name,
self.LOG_ERROR_MSG,
exc_info=True)
raise Exception from err
if self._success is None:
self._success = True
self.log_test_passed()