Source code for schrodinger.test.custom_test_classes

"""
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:]