Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OpenFF overhaul #32

Merged
merged 36 commits into from
Dec 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
2a19f18
Added importutils submodule for dynamically checking module dependencies
timbernat Nov 20, 2024
129a7b1
Moved importutils tests into dedicated tests.gentutils.importutils su…
timbernat Nov 21, 2024
d077bd4
Reimplemented find_module() with importlib.util.find_spec() to avoid …
timbernat Nov 21, 2024
ff82901
Wrte unit tests for importutils.dependencies
timbernat Nov 21, 2024
c7ac3f7
Standardized docstrings
timbernat Nov 21, 2024
e14afd7
Added unit tests module for mdutils.openfftools.partialcharge.molchar…
timbernat Nov 21, 2024
1a1b702
Filled out unit tests for molchargers
timbernat Nov 21, 2024
84964f1
Ignored GNN charge model ".model.pt" junk files
timbernat Nov 21, 2024
121e61c
Added extra arg to specify custom Exception type raised for missing d…
timbernat Nov 21, 2024
e13ffe2
Updated MolCharger "CHARGING_METHOD" class attribute to be handled b…
timbernat Nov 21, 2024
eba751b
Made MolCharger subclasses aware of package dependences, directly mak…
timbernat Nov 21, 2024
1cc2743
Updated and pinned NAGL tooling and model versions in env
timbernat Nov 21, 2024
b27c4ab
Strengthened openff.nagl package dependencies
timbernat Nov 21, 2024
458a841
Mae unit tests aware of installed packages (avoid erroneous errors ca…
timbernat Nov 21, 2024
e102159
Added OpenFF Toolkit-specific exceptions to package requirement decor…
timbernat Nov 21, 2024
e51f1d3
Re-added OpenFF Toolkit-specific exceptions to package requirement de…
timbernat Nov 21, 2024
0ec3e00
Added provisional module for smart OpenFF ToolkitWrapper registration
timbernat Nov 21, 2024
844fa03
Reworked type hints to deprecate external dependency on typetools
timbernat Nov 22, 2024
9dda6bf
Removed dependency on typetools
timbernat Nov 22, 2024
4943e10
Fixed ambertools package check, TOOLKITS_BY_CHARGE_METHOD population …
timbernat Dec 2, 2024
4062ab4
Added dynamic ToolkitRegistry and name-based toolkit wrapper registra…
timbernat Dec 2, 2024
589c495
Added submodule for SMIRNOFF force field registrations
timbernat Dec 2, 2024
fbd98c1
Relativized imports, broke up logic blocks among respective OpenFf de…
timbernat Dec 2, 2024
aa2cd54
Inserted author and email tags
timbernat Dec 2, 2024
c1393ac
Deprecated chargemethods submodule in favor of direct registry import…
timbernat Dec 2, 2024
61e3980
Cleaned up openfftools subpackage-level __init__ with openff dependen…
timbernat Dec 2, 2024
b4afb52
Fixed back-to-front imports for offxml getter functions
timbernat Dec 2, 2024
a2d3881
Shunted openff.units dependency into mdtools.openfftools subpackage
timbernat Dec 3, 2024
8bc7326
Created openfftools subpackage for OpenMM interoperability, absorbed …
timbernat Dec 3, 2024
43836a3
Added unit test placeholder for omminter
timbernat Dec 3, 2024
1f8fb8d
Simplified and clarified typehints
timbernat Dec 3, 2024
c06c384
Extended is_volume() to support Pint-style objects, clarified unit-li…
timbernat Dec 3, 2024
8f0fbde
Wrote unit tests for unitutils.dimensions
timbernat Dec 3, 2024
ae9e8b4
Added requirement for "pint" (the units package)
timbernat Dec 3, 2024
9f4b82c
Fixed indents on YAML header comments
timbernat Dec 3, 2024
00f5f97
Inverted import order to cause OpenFF installation error to raise pri…
timbernat Dec 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,6 @@ ENV/

# In-tree generated files
*/_version.py

# Espaloma junk output
**/.model.pt
4 changes: 3 additions & 1 deletion devtools/conda-envs/release-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ dependencies:
- openmm
- lammps
- mdtraj
- pint # for units in case OpenFF is not installed

# Molecule building
- mbuild
Expand All @@ -42,7 +43,8 @@ dependencies:
# OpenFF stack
- openff-toolkit ~=0.16
- openff-interchange >=0.3.28
- openff-nagl
- openff-nagl >= 0.4
- openff-nagl-models >= 0.3

# Chemical database queries
- cirpy
Expand Down
8 changes: 5 additions & 3 deletions devtools/conda-envs/test-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ channels:
- conda-forge
- openeye
dependencies:
# Basic Python dependencies
# Basic Python dependencies
- python
- pip
- jupyterlab

# Testing and docs
# Testing and docs
- pytest
- pytest-cov
- codecov
Expand All @@ -25,6 +25,7 @@ dependencies:
- openmm
- lammps
- mdtraj
- pint # for units in case OpenFF is not installed

# Molecule building
- mbuild
Expand All @@ -42,7 +43,8 @@ dependencies:
# OpenFF stack
- openff-toolkit ~=0.16
- openff-interchange >=0.3.28
- openff-nagl
- openff-nagl >= 0.4
- openff-nagl-models >= 0.3

# Chemical database queries
- cirpy
Expand Down
23 changes: 12 additions & 11 deletions polymerist/genutils/decorators/functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
__author__ = 'Timotej Bernat'
__email__ = '[email protected]'

from typing import Callable, Iterable, Optional, Type, Union
from typing import Callable, Concatenate, Iterable, Iterator, Optional, ParamSpec, TypeVar, Union

T = TypeVar('T')
Params = ParamSpec('Params')

from inspect import signature, Parameter
from functools import wraps, partial
Expand All @@ -13,20 +16,18 @@

from .meta import extend_to_methods
from . import signatures
from ..typetools.parametric import T, Args, KWArgs
from ..typetools.categorical import ListLike
from ..fileutils.pathutils import aspath, asstrpath


@extend_to_methods
def optional_in_place(funct : Callable[[object, Args, KWArgs], None]) -> Callable[[object, Args, bool, KWArgs], Optional[object]]:
def optional_in_place(funct : Callable[[Concatenate[object, Params]], None]) -> Callable[[Concatenate[object, Params]], Optional[object]]:
'''Decorator function for allowing in-place (writeable) functions which modify object attributes
to be not performed in-place (i.e. read-only), specified by a boolean flag'''
# TODO : add assertion that the wrapped function has at least one arg AND that the first arg is of the desired (limited) type
old_sig = signature(funct)

@wraps(funct) # for preserving docstring and type annotations / signatures
def in_place_wrapper(obj : object, *args : Args, in_place : bool=False, **kwargs : KWArgs) -> Optional[object]: # read-only by default
def in_place_wrapper(obj : object, *args : Params.args, in_place : bool=False, **kwargs : Params.kwargs) -> Optional[object]: # read-only by default
'''If not in-place, create a clone on which the method is executed''' # NOTE : old_sig.bind screws up arg passing
if in_place:
funct(obj, *args, **kwargs) # default call to writeable method - implicitly returns None
Expand Down Expand Up @@ -54,9 +55,9 @@ def in_place_wrapper(obj : object, *args : Args, in_place : bool=False, **kwargs
return in_place_wrapper

# TODO : implement support for extend_to_methods (current mechanism is broken by additional deocrator parameters)
def flexible_listlike_input(funct : Callable[[ListLike], T]=None, CastType : Type[ListLike]=list, valid_member_types : Union[Type, tuple[Type]]=object) -> Callable[[Iterable], T]:
def flexible_listlike_input(funct : Callable[[Iterator], T]=None, CastType : type[Iterator]=list, valid_member_types : Union[type, tuple[type]]=object) -> Callable[[Iterable], T]:
'''Wrapper which allows a function which expects a single list-initializable, Container-like object to accept any Iterable (or even star-unpacked arguments)'''
if not issubclass(CastType, ListLike):
if not issubclass(CastType, Iterator):
raise TypeError(f'Cannot wrap listlike input with non-listlike type "{CastType.__name__}"')

@wraps(funct)
Expand All @@ -79,13 +80,13 @@ def wrapper(*args) -> T: # wrapper which accepts an arbitrary number of non-keyw
return wrapper

@extend_to_methods
def allow_string_paths(funct : Callable[[Path, Args, KWArgs], T]) -> Callable[[Union[Path, str], Args, KWArgs], T]:
def allow_string_paths(funct : Callable[[Concatenate[Path, Params]], T]) -> Callable[[Concatenate[Union[Path, str], Params]], T]:
'''Modifies a function which expects a Path as its first argument to also accept string-paths'''
# TODO : add assertion that the wrapped function has at least one arg AND that the first arg is of the desired (limited) type
old_sig = signature(funct) # lookup old type signature

@wraps(funct) # for preserving docstring and type annotations / signatures
def str_path_wrapper(flex_path : Union[str, Path], *args : Args, **kwargs : KWArgs) -> T:
def str_path_wrapper(flex_path : Union[str, Path], *args : Params.args, **kwargs : Params.kwargs) -> T:
'''First converts stringy paths into normal Paths, then executes the original function'''
return funct(aspath(flex_path), *args, **kwargs)

Expand All @@ -99,13 +100,13 @@ def str_path_wrapper(flex_path : Union[str, Path], *args : Args, **kwargs : KWAr
return str_path_wrapper

@extend_to_methods
def allow_pathlib_paths(funct : Callable[[str, Args, KWArgs], T]) -> Callable[[Union[Path, str], Args, KWArgs], T]:
def allow_pathlib_paths(funct : Callable[[Concatenate[str, Params]], T]) -> Callable[[Concatenate[Union[Path, str], Params]], T]:
'''Modifies a function which expects a string path as its first argument to also accept canonical pathlib Paths'''
# TODO : add assertion that the wrapped function has at least one arg AND that the first arg is of the desired (limited) type
old_sig = signature(funct) # lookup old type signature

@wraps(funct) # for preserving docstring and type annotations / signatures
def str_path_wrapper(flex_path : Union[str, Path], *args : Args, **kwargs : KWArgs) -> T:
def str_path_wrapper(flex_path : Union[str, Path], *args : Params.args, **kwargs : Params.kwargs) -> T:
'''First converts normal Paths into stringy paths, then executes the original function'''
return funct(asstrpath(flex_path), *args, **kwargs)

Expand Down
11 changes: 6 additions & 5 deletions polymerist/genutils/decorators/meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
from typing import Concatenate, Callable, ParamSpec, TypeAlias, TypeVar
from functools import update_wrapper, wraps

from ..typetools.parametric import C, O, P, R, Args, KWArgs
Decorator : TypeAlias = Callable[[Callable[P, R]], Callable[P, R]]
Params = ParamSpec('Params') # can also use to typehint *args and **kwargs
ReturnType = TypeVar('ReturnType')
Decorator : TypeAlias = Callable[[Callable[Params, ReturnType]], Callable[Params, ReturnType]]


# META DECORATORS
Expand All @@ -18,16 +19,16 @@ def extend_to_methods(dec : Decorator) -> Decorator:

@wraps(dec, updated=()) # transfer decorator signature to decorator adapter class, without updating the __dict__ field
class AdaptedDecorator:
def __init__(self, funct : Callable[P, R]) -> None:
def __init__(self, funct : Callable[Params, ReturnType]) -> None:
'''Record function'''
self.funct = funct
update_wrapper(self, funct) # equivalent to functools.wraps, transfers docstring, module, etc. for documentation

def __call__(self, *args : Args, **kwargs : KWArgs) -> ReturnSignature: # TODO : fix this to reflect the decorator's return signature
def __call__(self, *args : Params.args, **kwargs : Params.kwargs) -> ReturnSignature: # TODO : fix this to reflect the decorator's return signature
'''Apply decorator to function, then call decorated function'''
return dec(self.funct)(*args, **kwargs)

def __get__(self, instance : O, owner : C) -> Callable[[Concatenate[O, P]], R]:
def __get__(self, instance : object, owner : type) -> Callable[[Concatenate[object, Params]], ReturnType]:
'''Generate partial application with calling instance as first argument (fills in for "self")'''
method = self.funct.__get__(instance, owner) # look up method belonging to owner class
return dec(method) # return the decorated method
Expand Down
93 changes: 93 additions & 0 deletions polymerist/genutils/importutils/dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
'''Utilities for checking and enforcing module dependencies within code'''

__author__ = 'Timotej Bernat'
__email__ = '[email protected]'

from typing import Callable, ParamSpec, TypeVar

Params = ParamSpec('Params')
ReturnType = TypeVar('ReturnType')
TCall = Callable[Params, ReturnType] # generic function of callable class

# from importlib import import_module
from importlib.util import find_spec
from functools import wraps


def module_installed(module_name : str) -> bool:
'''
Check whether a module of the given name is present on the system

Parameters
----------
module_name : str
The name of the module, as it would occur in an import statement
Do not support direct passing of module objects to avoid circularity
(i.e. no reason to check if a module is present if one has already imported it elsewhere)

Returns
-------
module_found : bool
Whether or not the module was found to be installed in the current working environment
'''
# try:
# package = import_module(module_name)
# except ModuleNotFoundError:
# return False
# else:
# return True

try: # NOTE: opted for this implementation, as it never actually imports the package in question (faster and fewer side-effects)
return find_spec(module_name) is not None
except (ValueError, AttributeError, ModuleNotFoundError): # these could all be raised by
return False

def modules_installed(*module_names : list[str]) -> bool:
'''
Check whether one or more modules are all present
Will only return true if ALL specified modules are found

Parameters
----------
module_names : *str
Any number of module names, passed as a comma-separated sequence of strings

Returns
-------
all_modules_found : bool
Whether or not all modules were found to be installed in the current working environment
'''
return all(module_installed(module_name) for module_name in module_names)

def requires_modules(
*required_module_names : list[str],
missing_module_error : type[Exception]=ImportError,
) -> Callable[[TCall[..., ReturnType]], TCall[..., ReturnType]]:
'''
Decorator which enforces optional module dependencies prior to function execution

Parameters
----------
module_names : *str
Any number of module names, passed as a comma-separated sequence of strings
missing_module_error : type[Exception], default ImportError
The type of Exception to raise if a module is not found installed
Defaults to ImportError

Raises
------
ImportError : Exception
Raised if any of the specified packages is not found to be installed
Exception message will indicate the name of the specific package found missing
'''
def decorator(func) -> TCall[..., ReturnType]:
@wraps(func)
def req_wrapper(*args : Params.args, **kwargs : Params.kwargs) -> ReturnType:
for module_name in required_module_names:
if not module_installed(module_name):
raise missing_module_error(f'No installation found for module "{module_name}"')
else:
return func(*args, **kwargs)

return req_wrapper
return decorator
86 changes: 30 additions & 56 deletions polymerist/mdtools/openfftools/__init__.py
Original file line number Diff line number Diff line change
@@ -1,60 +1,34 @@
'''Tools for manipulating and extending OpenFF objects, and for interfacing with other tools and formats'''
'''Extensions, interfaces, and convenience methods built around the functionality in the OpenFF software stack'''

__author__ = 'Timotej Bernat'
__email__ = '[email protected]'

from typing import Any
from pathlib import Path

import openforcefields
from openff.toolkit import ToolkitRegistry
from openff.toolkit import GLOBAL_TOOLKIT_REGISTRY as GTR
from openff.toolkit.utils.base_wrapper import ToolkitWrapper
from openff.toolkit.utils.utils import all_subclasses
from openff.toolkit.utils.exceptions import LicenseError, ToolkitUnavailableException
from openff.toolkit.typing.engines.smirnoff.forcefield import _get_installed_offxml_dir_paths

from openff.toolkit.utils.openeye_wrapper import OpenEyeToolkitWrapper
from espaloma_charge.openff_wrapper import EspalomaChargeToolkitWrapper
from openff.nagl.toolkits import NAGLRDKitToolkitWrapper, NAGLOpenEyeToolkitWrapper


# FORCE FIELD AND ToolkitWrapper REFERENCE
FFDIR = Path(openforcefields.get_forcefield_dirs_paths()[0]) # Locate path where OpenFF forcefields are installed
FF_DIR_REGISTRY : dict[Path, Path] = {}
FF_PATH_REGISTRY : dict[Path, Path] = {}
for ffdir_str in _get_installed_offxml_dir_paths():
ffdir = Path(ffdir_str)
ffdir_name = ffdir.parent.stem

FF_DIR_REGISTRY[ ffdir_name] = ffdir
FF_PATH_REGISTRY[ffdir_name] = [path for path in ffdir.glob('*.offxml')]

# CHECKING FOR OpenEye
ALL_IMPORTABLE_TKWRAPPERS = all_subclasses(ToolkitWrapper) # References to every registered ToolkitWrapper and ToolkitRegistry
try:
_ = OpenEyeToolkitWrapper()
_OE_TKWRAPPER_IS_AVAILABLE = True
OEUnavailableException = None
except (LicenseError, ToolkitUnavailableException) as error:
_OE_TKWRAPPER_IS_AVAILABLE = False
OEUnavailableException = error # catch and record relevant error message for use (rather than trying to replicate it elsewhere)

# Register OpenFF-compatible GNN ToolkitWrappers
GTR.register_toolkit(EspalomaChargeToolkitWrapper)
GTR.register_toolkit(NAGLRDKitToolkitWrapper)
if _OE_TKWRAPPER_IS_AVAILABLE:
GTR.register_toolkit(NAGLOpenEyeToolkitWrapper)


# GENERATE LOOKUP DICTS FOR EVERY REGISTERED ToolkitWrappers and ToolkitRegistry
REGISTERED_TKWRAPPER_TYPES = [type(tkwrapper) for tkwrapper in GTR.registered_toolkits]
TKWRAPPERS = { # NOTE : this must be done AFTER any new registrations to thr GTR (e.g. after registering GNN ToolkitWrappers)
tk_wrap.toolkit_name : tk_wrap
for tk_wrap in GTR.registered_toolkits
}
TKREGS = {} # individually register toolkit wrappers for cases where a registry must be passed
for tk_name, tk_wrap in TKWRAPPERS.items():
tk_reg = ToolkitRegistry()
tk_reg.register_toolkit(tk_wrap)
TKREGS[tk_name] = tk_reg
# Subpackage-wide precheck to see if OpenFF is even usable in the first place
from ...genutils.importutils.dependencies import modules_installed
if not modules_installed('openff', 'openff.toolkit'):
raise ModuleNotFoundError(
f'''
OpenFF packages which are required to utilitize {__name__} not found in current environment
Please follow installation instructions at https://docs.openforcefield.org/projects/toolkit/en/stable/installation.html, then retry import
'''
)

# Import of toplevel OpenFF object registries
from ._forcefields import (
FFDIR,
FF_DIR_REGISTRY,
FF_PATH_REGISTRY,
)
from ._toolkits import (
## toolkit registries
GLOBAL_TOOLKIT_REGISTRY, GTR,
POLYMERIST_TOOLKIT_REGISTRY,
## catalogues of available toolkit wrappers
ALL_IMPORTABLE_TKWRAPPERS,
ALL_AVAILABLE_TKWRAPPERS,
TKWRAPPERS,
TKWRAPPER_TYPES,
## registry of partial charge methods by
CHARGE_METHODS_BY_TOOLKIT,
TOOLKITS_BY_CHARGE_METHOD,
)
29 changes: 29 additions & 0 deletions polymerist/mdtools/openfftools/_forcefields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'''For dynamically determining and cataloging which SMIRNOFF-copatible force fields are installed (and accompanying functionality) are available'''

__author__ = 'Timotej Bernat'
__email__ = '[email protected]'

from typing import Optional
from pathlib import Path

from ...genutils.importutils.dependencies import modules_installed


# Force field and ToolkitWrapper reference
FFDIR : Optional[Path] = None
if modules_installed('openff.toolkit'):
from openforcefields import get_forcefield_dirs_paths

FFDIR = Path(get_forcefield_dirs_paths()[0]) # Locate path where OpenFF forcefields are installed

FF_DIR_REGISTRY : dict[Path, Path] = {}
FF_PATH_REGISTRY : dict[Path, Path] = {}
if modules_installed('openforcefields'):
from openff.toolkit.typing.engines.smirnoff.forcefield import _get_installed_offxml_dir_paths

for ffdir_str in _get_installed_offxml_dir_paths():
ffdir = Path(ffdir_str)
ffdir_name = ffdir.parent.stem

FF_DIR_REGISTRY[ ffdir_name] = ffdir
FF_PATH_REGISTRY[ffdir_name] = [path for path in ffdir.glob('*.offxml')]
Loading
Loading