"""
Custom test classes
Copyright Schrodinger, LLC. All rights reserved.
"""
import inspect
import os.path
import shutil
import tempfile
import unittest
from collections import defaultdict
from contextlib import contextmanager
from unittest.mock import Mock
from unittest.mock import patch
from schrodinger import project
from schrodinger import structure
from schrodinger.ui.qt.appframework2.maestro_callback import CallbackInfo
from . import custom_assertions
[docs]class StructureTestCase(custom_assertions.StructureAssertionsTestCase):
"""
A unit test class that reads in a structure. This class will only read the
structure from disk once (to save time), but will create a new copy of the
structure object for each test (to avoid test-test interactions).
The subclass must define the file to read in as class variable
STRUCTURE_FILE, and each test will then have access to the structure as
self.struc. The setUp() function in any subclass must call super().setUp.
"""
STRUCTURE_FILE = None
[docs] @classmethod
def setUpClass(cls):
"""
Read in the structure
"""
if cls.STRUCTURE_FILE is None:
raise TypeError("%s.STRUCTURE_FILE must be defined." % cls.__name__)
super().setUpClass()
test_file = inspect.getfile(cls)
test_file_dir = os.path.dirname(test_file)
test_file_dir = os.path.abspath(test_file_dir)
full_filename = os.path.join(test_file_dir, cls.STRUCTURE_FILE)
cls._struc = structure.Structure.read(full_filename)
[docs] @classmethod
def tearDownClass(cls):
"""
Overwrite the structure reference so it can be garbage collected
"""
super().tearDownClass()
cls._struc = None
[docs] def setUp(self):
"""
Create a copy of the structure (in case a test accidentally
modifies it)
"""
super().setUp()
self.struc = self.__class__._struc.copy()
[docs]class ProjectTestMixin:
"""
A mixin for base testing classes that provides methods to set up and tear
down a project instance, which will be available as the instance variable
`proj`.
:cvar PROJ_DIR: the path to the project directory that will be opened when
the tests are run. Must be defined in subclasses.
:vartype PROJ_DIR: str
"""
PROJ_DIR = None
@classmethod
def _check_for_proj_dir(cls):
if cls.PROJ_DIR is None:
err = "%s.PROJ_DIR must be defined." % cls.__name__
raise TypeError(err)
elif not os.path.isdir(cls.PROJ_DIR):
err = "%s is not a valid directory." % cls.PROJ_DIR
raise TypeError(err)
def _set_up_proj(self):
self._temp_dir = tempfile.mkdtemp(dir=os.getcwd())
self._proj_dir = os.path.join(self._temp_dir, "temp.prj")
shutil.copytree(self.PROJ_DIR, self._proj_dir)
self.proj = project.Project(project_name=self._proj_dir)
def _tear_down_proj(self):
self.proj.close()
project.delete_temp_project_directory(self._proj_dir, self._temp_dir)
[docs]class ProjectTestCase(ProjectTestMixin, unittest.TestCase):
"""
A unit test class that uses a project. A new copy of the project will be
created for each test.
The subclass must define the project to read in as class variable PROJ_DIR.
Each test will then have access to a `schrodinger.project.Project` object
as self.proj. The setUp and tearDown methods in any subclass must call
super().setUp and super().tearDown.
"""
[docs] @classmethod
def setUpClass(cls):
cls._check_for_proj_dir()
[docs] def setUp(self):
self._set_up_proj()
[docs] def tearDown(self):
self._tear_down_proj()
[docs]class ProjectPytestCase(ProjectTestMixin):
"""
A pytest base class that uses a project. A new copy of the project will be
created for each test.
The subclass must define the project to read in as class variable
`PROJ_DIR`. Each test will then have access to a `project.Project` object as
`self.proj`. The `setup()` and `teardown()` methods in any subclass must
call `super().setup()` and `super().teardown()`.
"""
[docs] @classmethod
def setup_class(cls):
cls._check_for_proj_dir()
[docs] def setup(self):
self._set_up_proj()
[docs] def teardown(self):
self._tear_down_proj()
[docs]class MultiModulePatchMixin:
"""
A pytest test class mixin to be used when a module should be patched out of
multiple different modules in the same way. For example, this mixin will
allow you to make sure that `maestro.project_table_get()` returns the same
`Project` instance across multiple modules.
:ivar _patchers: a set of all patchers created and started in
`_setupPatchers()`. Keep this around until `_teardownPatchers()` is
called from `teardown()`
:vartype _patchers: set(mock.mock._patch)
:ivar _mock_module_map: a dictionary mapping the string representation of
a mocked module (e.g. `'maestro'`) to the actual mock class used for
those modules. This variable is initialized in `setup()` using the keys
defined in `MODULE_MAP` and values of `None`. Real mock values should be
defined and set in `_setupMockedModules()`, which must be overridden in
concrete subclasses.
:vartype _mock_module_map: dict(str, mock.Mock or None)
:cvar MODULE_MAP: a dictionary mapping the name of a module that should be
mocked to the string representation of a a module (or list of same) in
which the mocking should occur. This variable must be defined in a
subclass for any patching to occur.
:vartype MODULE_MAP: dict(str, list(str) or str)
For example::
MODULE_MAP = {
'maestro': ['ligand_designer_gui_dir.maestro_sync',
'schrodinger.project.utils']
'login': 'schrodinger.application.livedesign.ld_export'
}
"""
MODULE_MAP = {}
[docs] def setup(self):
if hasattr(super(), 'setup'):
super().setup()
self._patchers = set()
self._mock_module_map = {
mocked_module: Mock() for mocked_module in self.MODULE_MAP.keys()
}
self._setupMockedModules()
self._setupPatchers()
[docs] def teardown(self):
if hasattr(super(), 'teardown'):
super().teardown()
self._teardownPatchers()
del self._patchers
del self._mock_module_map
def _setupMockedModules(self):
"""
Create the module mocks that will be patched into the specified modules,
then save them to the relevant key in `_mock_module_map`. For example::
mock_maestro = Mock()
mock_maestro.project_table_get.return_value = self.proj
self._mock_module_map['maestro'] = mock_maestro
or, equivalently, ::
mock_maestro = self._mock_module_map['maestro']
mock_maestro.project_table_get.return_value = self.proj
This method should be defined in concrete subclasses if the module mock
needs to have special behavior that is not provided by a `Mock`
instance (the default value).
"""
pass
def _setupPatchers(self):
"""
Create, start, and store patchers for all specified modules using the
module mock objects defined in `_setupMockedModules()`.
"""
for mocked_module, modules_to_patch in self.MODULE_MAP.items():
if isinstance(modules_to_patch, str):
modules_to_patch = [modules_to_patch]
for module_to_patch in modules_to_patch:
target = module_to_patch + '.' + mocked_module
mock_obj = self._mock_module_map[mocked_module]
patcher = patch(target, new=mock_obj)
patcher.start()
self._patchers.add(patcher)
def _teardownPatchers(self):
"""
Stop all active patchers created in `_setupPatchers()`.
"""
for patcher in self._patchers:
patcher.stop()
[docs]class MaestroProjectPytestCase(MultiModulePatchMixin, ProjectPytestCase):
"""
A pytest class with a project that's returned from
`maestro.project_table_get()`.
The subclass must define the project to read as `PROJ_DIR` and specify at
least one module to have maestro mocked in `MODULE_MAP`.
See ProjectTestMixin and MultiModulePatchMixin for additional documentation.
"""
MODULE_MAP = {
'maestro': NotImplemented,
'CALLBACK_FUNCTIONS': NotImplemented
}
def _setupMockedModules(self):
maestro = MaestroMock(pt=self.proj)
self._mock_module_map['maestro'] = maestro
callback_fns = {
maestro.PROJECT_UPDATE_CALLBACK: CallbackInfo(
maestro.project_update_callback_add,
maestro.project_update_callback_remove, True),
maestro.PROJECT_CLOSE_CALLBACK: CallbackInfo(
maestro.project_close_callback_add,
maestro.project_close_callback_remove, True),
maestro.WORKSPACE_CHANGED_CALLBACK: CallbackInfo(
maestro.workspace_changed_callback_add,
maestro.workspace_changed_callback_remove, True),
maestro.HOVER_CALLBACK: CallbackInfo(maestro.hover_callback_add,
maestro.hover_callback_remove,
True),
}
self._mock_module_map['CALLBACK_FUNCTIONS'] = callback_fns
[docs]class MaestroMock:
in_maestro = True
[docs] def __init__(self, pt):
super().__init__()
self._callbacks = defaultdict(set)
self._pt = pt
# We can't use constants from the "actual" maestro.py module, since
# it's not importable outside of Maestro, so hard code in the strings:
self.PROJECT_UPDATE_CALLBACK = 'project_update'
self.PROJECT_CLOSE_CALLBACK = 'project_close'
self.WORKSPACE_CHANGED_CALLBACK = 'workspace_changed'
self.HOVER_CALLBACK = 'hover'
self.WORKSPACE_CHANGED_EVERYTHING = 'everything'
self.WORKSPACE_CHANGED_CONNECTIVITY = 'connectivity'
self.WORKSPACE_CHANGED_SELECTION = 'selection'
self.WORKSPACE_CHANGED_APPEND = "append"
self._workspace_st = None
self.project_update_callback_add(self._clearWSStructure)
self._command_history = []
def __bool__(self):
return self.in_maestro
[docs] def project_table_get(self):
return self._pt
[docs] def is_function_registered(self, callback_type, callback_function):
return callback_function in self._callbacks[callback_type]
[docs] @contextmanager
def IgnoreProjectUpdate(self, callback_fn):
registered = self.is_function_registered(self.PROJECT_UPDATE_CALLBACK,
callback_fn)
if registered:
self.project_update_callback_remove(callback_fn)
try:
yield
finally:
if registered:
self.project_update_callback_add(callback_fn)
[docs] def project_update_callback_add(self, callback_function):
self._callbacks[self.PROJECT_UPDATE_CALLBACK].add(callback_function)
[docs] def project_update_callback_remove(self, callback_function):
self._callbacks[self.PROJECT_UPDATE_CALLBACK].remove(callback_function)
[docs] def project_close_callback_add(self, callback_function):
self._callbacks[self.PROJECT_CLOSE_CALLBACK].add(callback_function)
[docs] def project_close_callback_remove(self, callback_function):
self._callbacks[self.PROJECT_CLOSE_CALLBACK].remove(callback_function)
[docs] def workspace_changed_callback_add(self, callback_function):
self._callbacks[self.WORKSPACE_CHANGED_CALLBACK].add(callback_function)
[docs] def workspace_changed_callback_remove(self, callback_function):
self._callbacks[self.WORKSPACE_CHANGED_CALLBACK].remove(
callback_function)
[docs] def hover_callback_add(self, callback_function):
self._callbacks[self.HOVER_CALLBACK].add(callback_function)
[docs] def hover_callback_remove(self, callback_function):
self._callbacks[self.HOVER_CALLBACK].remove(callback_function)
[docs] def command(self, cmd):
"""
Rather than execute the command, as the true Maestro would do, store the
command.
"""
self._command_history.append(cmd)
[docs] def project_table_synchronize(self):
pass
[docs] def get_included_entry_ids(self):
return [r.entry_id for r in self._pt.included_rows]
def _clearWSStructure(self):
"""
Set the stored WS structure to `None`. When the user attempts to access
it, it will be reconstituted from included row structures.
"""
self._workspace_st = None
[docs] def selected_atoms_get(self):
"""
Returns all atoms in workspace as selection.
:return: A tuple of atom indices.
"""
if self._workspace_st is not None:
selected_atoms = tuple(at.index for at in self._workspace_st.atom)
else:
selected_atoms = ()
return selected_atoms
[docs] def workspace_get(self, copy=True):
"""
:param copy: whether to copy the Workspace structure
:type copy: bool
:return: the Workspace structure
:rtype: structure.Structure
"""
if self._workspace_st is None:
merged_st = structure.create_new_structure()
for row in self._pt.included_rows:
merged_st.extend(row.getStructure())
self._workspace_st = merged_st
return self._workspace_st
[docs] def workspace_set(self, struct, copy=True):
"""
Set the current Workspace structure.
This action will desynchronize the WS structure from the included row
structures.
:param struct: a structure
:type struct: structure.Structure
:param copy: whether to copy the supplied structure
:type copy: bool
"""
if copy:
self._workspace_st = struct.copy()
else:
self._workspace_st = struct
# # # # # # # # # # # # # # # # #
# CONVENIENCE METHODS
# # # # # # # # # # # # # # # # #
[docs] def execute_callbacks(self, callback_type, *args):
"""
Execute all callbacks of the specified type.
This method exists in this mock only, and there is no corresponding
method in Maestro.
:param callback_type: the type of callback to execute
:type callback_type: str
"""
for callback_fn in self._callbacks[callback_type]:
callback_fn(*args)
[docs] def getLastNCommands(self, command_count):
"""
Return the last `command_count` commands executed in this mock object
via the `command()` method.
This method exists in this mock only, and there is no corresponding
method in Maestro.
:return: a list of the last `command_count` commands
:rtype: list[str]
"""
return self._command_history[-command_count:]