From 6c98efebfa7cbf99954360c91385a871b0c0a2ca Mon Sep 17 00:00:00 2001 From: "Matthew W. Thompson" Date: Mon, 6 Jul 2026 11:05:56 -0500 Subject: [PATCH 1/3] Merge utilities package --- devtools/conda-envs/openeye-examples.yaml | 1 - devtools/conda-envs/openeye.yaml | 1 - devtools/conda-envs/rdkit-examples.yaml | 1 - devtools/conda-envs/rdkit.yaml | 1 - devtools/conda-envs/test_env.yaml | 1 - docs/environment.yml | 1 - openff/toolkit/_tests/test_interchange.py | 2 +- openff/toolkit/_tests/test_nagl.py | 2 +- openff/toolkit/_tests/test_topology.py | 2 +- .../toolkit/_tests/test_utilities/__init__.py | 0 .../_tests/test_utilities/test_exceptions.py | 17 ++ .../_tests/test_utilities/test_provenance.py | 54 ++++ .../test_utilities/test_testing_utilities.py | 16 ++ .../_tests/test_utilities/test_utilities.py | 181 ++++++++++++ .../_tests/test_utils_serialization.py | 2 +- openff/toolkit/_utilities/__init__.py | 26 ++ openff/toolkit/_utilities/data/data.dat | 2 + openff/toolkit/_utilities/data/more/more.dat | 0 openff/toolkit/_utilities/exceptions.py | 52 ++++ openff/toolkit/_utilities/provenance.py | 65 +++++ openff/toolkit/_utilities/py.typed | 0 openff/toolkit/_utilities/testing.py | 54 ++++ openff/toolkit/_utilities/utilities.py | 262 ++++++++++++++++++ openff/toolkit/_utilities/warnings.py | 4 + openff/toolkit/topology/molecule.py | 2 +- openff/toolkit/utils/ambertools_wrapper.py | 2 +- openff/toolkit/utils/utils.py | 45 ++- pyproject.toml | 2 +- 28 files changed, 782 insertions(+), 16 deletions(-) create mode 100644 openff/toolkit/_tests/test_utilities/__init__.py create mode 100644 openff/toolkit/_tests/test_utilities/test_exceptions.py create mode 100644 openff/toolkit/_tests/test_utilities/test_provenance.py create mode 100644 openff/toolkit/_tests/test_utilities/test_testing_utilities.py create mode 100644 openff/toolkit/_tests/test_utilities/test_utilities.py create mode 100644 openff/toolkit/_utilities/__init__.py create mode 100644 openff/toolkit/_utilities/data/data.dat create mode 100644 openff/toolkit/_utilities/data/more/more.dat create mode 100644 openff/toolkit/_utilities/exceptions.py create mode 100644 openff/toolkit/_utilities/provenance.py create mode 100644 openff/toolkit/_utilities/py.typed create mode 100644 openff/toolkit/_utilities/testing.py create mode 100644 openff/toolkit/_utilities/utilities.py create mode 100644 openff/toolkit/_utilities/warnings.py diff --git a/devtools/conda-envs/openeye-examples.yaml b/devtools/conda-envs/openeye-examples.yaml index a5b4c78e0..afb9077fc 100644 --- a/devtools/conda-envs/openeye-examples.yaml +++ b/devtools/conda-envs/openeye-examples.yaml @@ -16,7 +16,6 @@ dependencies: - openff-forcefields >=2023.11 - openff-amber-ff-ports >=0.0.3 - openff-units - - openff-utilities >=0.1.5 - openff-interchange-base >=0.5 - openff-nagl-base >=0.4.0 - openff-nagl-models >=0.3.0 diff --git a/devtools/conda-envs/openeye.yaml b/devtools/conda-envs/openeye.yaml index 384dd02f2..9a7693ff7 100644 --- a/devtools/conda-envs/openeye.yaml +++ b/devtools/conda-envs/openeye.yaml @@ -16,7 +16,6 @@ dependencies: - openff-forcefields >=2023.05.1 - openff-units - openff-amber-ff-ports - - openff-utilities >=0.1.5 - openff-interchange-base >=0.5 - openff-nagl-base =0.4.0 - openff-nagl-models >=0.3.0 diff --git a/devtools/conda-envs/rdkit-examples.yaml b/devtools/conda-envs/rdkit-examples.yaml index c163bbe4c..ffce39d88 100644 --- a/devtools/conda-envs/rdkit-examples.yaml +++ b/devtools/conda-envs/rdkit-examples.yaml @@ -15,7 +15,6 @@ dependencies: - openff-forcefields >=2023.11 - openff-amber-ff-ports >=0.0.3 - openff-units - - openff-utilities >=0.1.5 - openff-interchange-base >=0.5 - openff-nagl-base >=0.4.0 - openff-nagl-models >=0.3.0 diff --git a/devtools/conda-envs/rdkit.yaml b/devtools/conda-envs/rdkit.yaml index 4eea69998..2404ab846 100644 --- a/devtools/conda-envs/rdkit.yaml +++ b/devtools/conda-envs/rdkit.yaml @@ -15,7 +15,6 @@ dependencies: - openff-forcefields >=2023.05.1 - openff-units - openff-amber-ff-ports - - openff-utilities >=0.1.5 - openff-interchange-base >=0.5 - openff-nagl-base >=0.4.0 - openff-nagl-models >=0.3.0 diff --git a/devtools/conda-envs/test_env.yaml b/devtools/conda-envs/test_env.yaml index ba5626b6c..055ef01a1 100644 --- a/devtools/conda-envs/test_env.yaml +++ b/devtools/conda-envs/test_env.yaml @@ -16,7 +16,6 @@ dependencies: - openff-forcefields >=2023.05.1 - openff-units - openff-amber-ff-ports - - openff-utilities >=0.1.5 - openff-interchange-base >=0.5 - openff-nagl-base >=0.4.0 - openff-nagl-models >=0.3.0 diff --git a/docs/environment.yml b/docs/environment.yml index a447a1b06..797ead2a6 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -23,7 +23,6 @@ dependencies: - ambertools - packaging - openff-units - - openff-utilities - cachetools - python-constraint - mdtraj diff --git a/openff/toolkit/_tests/test_interchange.py b/openff/toolkit/_tests/test_interchange.py index 318608b23..288795e1d 100644 --- a/openff/toolkit/_tests/test_interchange.py +++ b/openff/toolkit/_tests/test_interchange.py @@ -1,6 +1,6 @@ import pytest -from openff.utilities import requires_package as requires_pkg +from openff.toolkit._utilities import requires_package as requires_pkg from openff.toolkit.topology import Molecule from openff.toolkit.typing.engines.smirnoff.forcefield import ForceField diff --git a/openff/toolkit/_tests/test_nagl.py b/openff/toolkit/_tests/test_nagl.py index 845b12e3d..053e8b445 100644 --- a/openff/toolkit/_tests/test_nagl.py +++ b/openff/toolkit/_tests/test_nagl.py @@ -3,7 +3,6 @@ import numpy import pytest -from openff.utilities import has_package, skip_if_missing from openff.toolkit import Molecule, unit from openff.toolkit._tests.create_molecules import ( @@ -14,6 +13,7 @@ create_reversed_ethanol, ) from openff.toolkit._tests.utils import requires_openeye +from openff.toolkit._utilities import has_package, skip_if_missing from openff.toolkit.utils import GLOBAL_TOOLKIT_REGISTRY from openff.toolkit.utils.exceptions import ( ToolkitUnavailableException, diff --git a/openff/toolkit/_tests/test_topology.py b/openff/toolkit/_tests/test_topology.py index a41e5dd4f..4617e2da1 100644 --- a/openff/toolkit/_tests/test_topology.py +++ b/openff/toolkit/_tests/test_topology.py @@ -16,7 +16,6 @@ import openmm.unit import pytest from openff.units.openmm import from_openmm -from openff.utilities import skip_if_missing from openff.toolkit import Molecule, Quantity, Topology, unit from openff.toolkit._tests.create_molecules import ( @@ -41,6 +40,7 @@ requires_pkg, requires_rdkit, ) +from openff.toolkit._utilities import skip_if_missing from openff.toolkit.topology import ( Atom, ImproperDict, diff --git a/openff/toolkit/_tests/test_utilities/__init__.py b/openff/toolkit/_tests/test_utilities/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openff/toolkit/_tests/test_utilities/test_exceptions.py b/openff/toolkit/_tests/test_utilities/test_exceptions.py new file mode 100644 index 000000000..8ebe9e667 --- /dev/null +++ b/openff/toolkit/_tests/test_utilities/test_exceptions.py @@ -0,0 +1,17 @@ +import pytest + +from openff.toolkit._utilities.exceptions import MissingOptionalDependencyError, OpenFFError + + +def test_exceptions(): + with pytest.raises( + MissingOptionalDependencyError, + match=r"The required foobar module could not be imported.*Try installing.*conda-forge.*", + ): + raise MissingOptionalDependencyError(library_name="foobar") + + with pytest.raises(MissingOptionalDependencyError, match=r".*barbaz.*missing license."): + raise MissingOptionalDependencyError(library_name="barbaz", license_issue=True) + + with pytest.raises(OpenFFError): + raise MissingOptionalDependencyError("numpy") diff --git a/openff/toolkit/_tests/test_utilities/test_provenance.py b/openff/toolkit/_tests/test_utilities/test_provenance.py new file mode 100644 index 000000000..2d4900637 --- /dev/null +++ b/openff/toolkit/_tests/test_utilities/test_provenance.py @@ -0,0 +1,54 @@ +import importlib + +import pytest + +from openff.toolkit._utilities.provenance import ( + _get_conda_list_package_versions, + get_ambertools_version, +) +from openff.toolkit._utilities.warnings import CondaExecutableNotFoundWarning + + +def mock_has_executable(executable_name: str) -> bool: + return False + + +@pytest.mark.leaky +def test_conda_unavailable_returns_empty_dict(monkeypatch): + """ + Test that if `conda|etc. list` fails, with or without AmberTools (properly) installed, + get_ambertools_version returns `None`. + + This test leaks! Do not run it alongside other tests! Select either it "pytest -m leaky" + or avoid it "pytest -m 'not leaky'" + """ + with ( + monkeypatch.context() as m, + pytest.warns(CondaExecutableNotFoundWarning, match="No .* executable found"), + ): + m.setenv("PIXI_IN_SHELL", "0") + m.setenv("CONDA_SHLVL", "0") + + assert _get_conda_list_package_versions() == dict() + + +def test_conda_available_get_ambertools_version_found(): + # Skip if ambertools is not installed - sander would a better import test + # for AmberTools (since parmed could come from the standalone + # parmed package), however it's currently broken on ARM macs. + pytest.importorskip("parmed") + + assert get_ambertools_version() is not None, ( + f"note that len of package versions is {len(_get_conda_list_package_versions())}" + ) + + +def test_conda_available_get_ambertools_version_not_found(): + try: + importlib.import_module("parmed") + except ImportError: + assert len(_get_conda_list_package_versions()) > 0 + assert get_ambertools_version() is None + return + + pytest.skip("only run when ambertools is not installed.") diff --git a/openff/toolkit/_tests/test_utilities/test_testing_utilities.py b/openff/toolkit/_tests/test_utilities/test_testing_utilities.py new file mode 100644 index 000000000..b948623b5 --- /dev/null +++ b/openff/toolkit/_tests/test_utilities/test_testing_utilities.py @@ -0,0 +1,16 @@ +import pytest + +from openff.toolkit._utilities.testing import skip_if_missing, skip_if_missing_exec + + +def test_skips(): + assert not skip_if_missing("pip").args[0] + assert skip_if_missing("numpynumpy").args[0] + + assert not skip_if_missing_exec("python").args[0] + assert skip_if_missing_exec("python4").args[0] + assert not skip_if_missing_exec(["python", "python4"]).args[0] + assert skip_if_missing_exec(["python4", "python5"]).args[0] + + with pytest.raises(ValueError, match=r"Bad type.*int"): + skip_if_missing_exec(0) diff --git a/openff/toolkit/_tests/test_utilities/test_utilities.py b/openff/toolkit/_tests/test_utilities/test_utilities.py new file mode 100644 index 000000000..2b78ca15b --- /dev/null +++ b/openff/toolkit/_tests/test_utilities/test_utilities.py @@ -0,0 +1,181 @@ +import os + +import pytest + +from openff.toolkit._utilities.exceptions import MissingOptionalDependencyError +from openff.toolkit._utilities.testing import skip_if_missing +from openff.toolkit._utilities.utilities import ( + get_data_dir_path, + get_data_file_path, + has_executable, + has_package, + requires_oe_module, + requires_package, + temporary_cd, +) + + +def compare_paths(path_1: str, path_2: str) -> bool: + """Checks whether two paths are the same. + + Parameters + ---------- + path_1 + The first path. + path_2 + The second path. + + Returns + ------- + True if the paths are equivalent. + """ + return os.path.normpath(path_1) == os.path.normpath(path_2) + + +def test_get_data_dir_path(): + """Tests that the `get_data_dir_path` can correctly find + data directories. + """ + + # Test a path which should exist. + data_file_path = get_data_dir_path("data/more", package_name="openff.toolkit._utilities") + assert os.path.isdir(data_file_path) + + # Ensure a double-checking through data/ takes place + data_file_path = get_data_dir_path("more", package_name="openff.toolkit._utilities") + assert os.path.isdir(data_file_path) + + # Test a path which should not exist. + with pytest.raises(NotADirectoryError): + get_data_dir_path("missing", package_name="openff.toolkit._utilities") + + # Test that a file directory raises NotaDirectoryError + with pytest.raises(NotADirectoryError): + get_data_dir_path("data/data.dat", package_name="openff.toolkit._utilities") + + +def test_get_data_file_path(): + """Tests that the `get_data_file_path` can correctly find + data files. + """ + + # Test a path which should exist. + data_file_path = get_data_file_path("data/data.dat", package_name="openff.toolkit._utilities") + assert os.path.isfile(data_file_path) + + # Ensure a double-checking through data/ takes place + data_file_path = get_data_file_path("data.dat", package_name="openff.toolkit._utilities") + assert os.path.isfile(data_file_path) + + # Test a path which should not exist. + with pytest.raises(FileNotFoundError): + get_data_file_path("missing.file", package_name="openff.toolkit._utilities") + + # Test that a directory raises FileNotFoundError + with pytest.raises(FileNotFoundError): + get_data_file_path("data/", package_name="openff.toolkit._utilities") + + +def test_temporary_cd(): + """Tests that temporary cd works as expected""" + + original_directory = os.getcwd() + + # Move to the parent directory + with temporary_cd(os.pardir): + current_directory = os.getcwd() + expected_directory = os.path.abspath(os.path.join(original_directory, os.pardir)) + + assert compare_paths(current_directory, expected_directory) + + assert compare_paths(os.getcwd(), original_directory) + + # Move to a temporary directory + with temporary_cd(): + assert not compare_paths(os.getcwd(), original_directory) + + assert compare_paths(os.getcwd(), original_directory) + + # Move to the same directory + with temporary_cd(""): + assert compare_paths(os.getcwd(), original_directory) + + assert compare_paths(os.getcwd(), original_directory) + + with temporary_cd(os.curdir): + assert compare_paths(os.getcwd(), original_directory) + + assert compare_paths(os.getcwd(), original_directory) + + +def test_has_package(): + assert has_package("os") + assert has_package("pytest") + assert not has_package("nummmmmmpy") + + +def test_has_executable(): + assert has_executable("pwd") + assert has_executable("pytest") + + assert has_executable("/usr/bin/whoami") + + assert not has_package("pyyyyython") + + +def test_requires_package(): + """Tests that the ``requires_package`` utility behaves as expected.""" + + def dummy_function(): + pass + + # sys should always be found so this should not raise an exception. + requires_package("sys")(dummy_function)() + + with pytest.raises(MissingOptionalDependencyError) as error_info: + requires_package("fake-lib")(dummy_function)() + + assert error_info.value.library_name == "fake-lib" + + +@skip_if_missing("openeye.oechem") +@pytest.mark.skipif("OE_LICENSE" not in os.environ, reason="Requires an OpenEye license is NOT set up") +def test_requires_oe_module(): + """Tests that the ``requires_package`` utility behaves as expected when an OpenEye license is set up.""" + + def dummy_function(): + pass + + requires_oe_module("oechem")(dummy_function)() + + +@pytest.mark.parametrize("oe_module", ["oechem", "oeiupac", "oeomega", "oequacpac", "oedepict"]) +@skip_if_missing("openeye") +@pytest.mark.skipif("OE_LICENSE" in os.environ, reason="Requires an OpenEye license is NOT set up") +def test_requires_oe_module_installed_missing_license(oe_module): + """Tests that the ``requires_package`` utility behaves as expected while OpenEye toolkits are + installed but no OpenEye license is set up.""" + + def dummy_function(): + pass + + with pytest.raises(MissingOptionalDependencyError) as error_info: + requires_oe_module(oe_module)(dummy_function)() + + assert oe_module in str(error_info.value) + assert "conda-forge" not in str(error_info.value) + + +@pytest.mark.skipif(has_package("openeye.oechem"), reason="Requires OpenEye toolkits are NOT installed") +def test_requires_oe_module_not_installed(): + """Tests that the ``requires_package`` utility behaves as expected while OpenEye toolkits are + installed.""" + + def dummy_function(): + pass + + with pytest.raises(MissingOptionalDependencyError) as error_info: + requires_oe_module("oechem")(dummy_function)() + + assert "oechem" in str(error_info.value) + assert "conda-forge" not in str(error_info.value) diff --git a/openff/toolkit/_tests/test_utils_serialization.py b/openff/toolkit/_tests/test_utils_serialization.py index 71c1d7dd4..c51d17391 100644 --- a/openff/toolkit/_tests/test_utils_serialization.py +++ b/openff/toolkit/_tests/test_utils_serialization.py @@ -5,8 +5,8 @@ import numpy as np import pytest -from openff.utilities import requires_package as requires_pkg +from openff.toolkit._utilities import requires_package as requires_pkg from openff.toolkit.utils.serialization import Serializable from openff.toolkit.utils.utils import deserialize_numpy, serialize_numpy diff --git a/openff/toolkit/_utilities/__init__.py b/openff/toolkit/_utilities/__init__.py new file mode 100644 index 000000000..520d49552 --- /dev/null +++ b/openff/toolkit/_utilities/__init__.py @@ -0,0 +1,26 @@ +from openff.toolkit._utilities.exceptions import MissingOptionalDependencyError +from openff.toolkit._utilities.provenance import get_ambertools_version +from openff.toolkit._utilities.testing import skip_if_missing, skip_if_missing_exec +from openff.toolkit._utilities.utilities import ( + get_data_dir_path, + get_data_file_path, + has_executable, + has_package, + requires_oe_module, + requires_package, + temporary_cd, +) + +__all__ = ( + "MissingOptionalDependencyError", + "get_ambertools_version", + "get_data_dir_path", + "get_data_file_path", + "has_executable", + "has_package", + "requires_oe_module", + "requires_package", + "skip_if_missing", + "skip_if_missing_exec", + "temporary_cd", +) diff --git a/openff/toolkit/_utilities/data/data.dat b/openff/toolkit/_utilities/data/data.dat new file mode 100644 index 000000000..86359f218 --- /dev/null +++ b/openff/toolkit/_utilities/data/data.dat @@ -0,0 +1,2 @@ +1 0 +0 1 diff --git a/openff/toolkit/_utilities/data/more/more.dat b/openff/toolkit/_utilities/data/more/more.dat new file mode 100644 index 000000000..e69de29bb diff --git a/openff/toolkit/_utilities/exceptions.py b/openff/toolkit/_utilities/exceptions.py new file mode 100644 index 000000000..a417f7508 --- /dev/null +++ b/openff/toolkit/_utilities/exceptions.py @@ -0,0 +1,52 @@ +class OpenFFError(BaseException): + """The base exception from which most custom exceptions in openff-utilities inherited.""" + + +class MissingOptionalDependencyError(OpenFFError, ImportError): + """An exception raised when an optional dependency is required + but cannot be found. + + Attributes + ---------- + library_name + The name of the missing library. + license_issue + Whether the library was importable but was unusable due + to a missing license. + """ + + def __init__(self, library_name: str, license_issue: bool = False): + """ + + Parameters + ---------- + library_name + The name of the missing library. + license_issue + Whether the library was importable but was unusable due + to a missing license. + """ + + message = f"The required {library_name} module could not be imported." + + if license_issue: + message = f"{message} This is due to a missing license." + + library_name_corrected = library_name.replace(".", "-") + + if "openeye" not in library_name: + message = ( + f"{message} Try installing the package by running " + f"`conda install -c conda-forge {library_name_corrected}`" + ) + + super().__init__(message) + + self.library_name = library_name + self.license_issue = license_issue + + +class CondaExecutableNotFoundError(OpenFFError): + """ + A conda (or mamba/micromamba) executable is not found. + """ diff --git a/openff/toolkit/_utilities/provenance.py b/openff/toolkit/_utilities/provenance.py new file mode 100644 index 000000000..e149068b2 --- /dev/null +++ b/openff/toolkit/_utilities/provenance.py @@ -0,0 +1,65 @@ +import functools +import json +import os +import subprocess +import warnings + + +@functools.lru_cache +def _get_conda_list_package_versions() -> dict[str, str]: + """ + Returns the versions of any packages found while executing `conda list`. + If no conda executable is found, emits CondaExecutableNotFoundWarning + """ + from openff.toolkit._utilities.warnings import CondaExecutableNotFoundWarning + + if os.environ.get("PIXI_IN_SHELL") == "1" and os.environ.get("PIXI_EXE"): + conda_command = "{} list --json --manifest-path {}".format( + os.environ["PIXI_EXE"], os.environ["PIXI_PROJECT_MANIFEST"] + ) + elif os.environ.get("CONDA_SHLVL", "0") != "0" and os.environ.get("CONDA_EXE"): + conda_command = "{} list --json".format(os.environ["CONDA_EXE"]) + elif os.environ.get("CONDA_SHLVL", "0") != "0" and os.environ.get("MAMBA_EXE"): + conda_command = "{} list --json".format(os.environ["MAMBA_EXE"]) + else: + warnings.warn( + "No conda/mamba/micromamba executable found. Unable to determine package versions.", + CondaExecutableNotFoundWarning, + ) + return dict() + + output = json.loads(subprocess.check_output(conda_command.split()).decode()) + + package_versions = {} + for _package in output: + package_versions[_package["name"]] = _package["version"] + + return package_versions + + +def get_ambertools_version() -> str | None: + """ + Attempts to retrieve the version of the currently installed AmberTools. + + There are two soft failure modes, each of which cause this function to return `None`: + 1. If there is a failure calling `{conda|mamba|etc.} list`, the user is + warned by `_get_conda_list_package_versions` and this function returns `None`. + 2. If there is a failure calling `{conda|mamba|etc.} list`, this function + still returns `None`, but without a warning associated with the above failure. + """ + + try: + return _get_conda_list_package_versions().get("ambertools", None) + except ( + ValueError, # Issue 98 + subprocess.CalledProcessError, # Issue 101 + ): + from openff.toolkit._utilities.warnings import CondaExecutableNotFoundWarning + + warnings.warn( + "Something went wrong parsing the output of `conda list` or similar. Unable to " + "determine AmberTools version, returning None.", + CondaExecutableNotFoundWarning, + ) + + return None diff --git a/openff/toolkit/_utilities/py.typed b/openff/toolkit/_utilities/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/openff/toolkit/_utilities/testing.py b/openff/toolkit/_utilities/testing.py new file mode 100644 index 000000000..285a1011b --- /dev/null +++ b/openff/toolkit/_utilities/testing.py @@ -0,0 +1,54 @@ +from typing import TYPE_CHECKING + +from openff.toolkit._utilities.utilities import has_executable, has_package + +if TYPE_CHECKING: + from _pytest.mark.structures import MarkDecorator + + +def skip_if_missing(package_name: str, reason: str | None = None) -> "MarkDecorator": + """ + Helper function to generate a pytest.mark.skipif decorator + for any package. This allows tests to be skipped if some + optional dependency is not found. + + Parameters + ---------- + package_name : str + The name of the package that is required for a test(s) + reason : str, optional + Explanation of why the skipped it to be tested + + Returns + ------- + requires_package : _pytest.mark.structures.MarkDecorator + A pytest decorator that will skip tests if the package is not available + """ + import pytest + + if not reason: + reason = f"Package {package_name} is required, but was not found." + requires_package = pytest.mark.skipif(not has_package(package_name), reason=reason) + return requires_package + + +def skip_if_missing_exec(exec: str | list[str]) -> "MarkDecorator": + """Helper function to generate a pytest.mark.skipif decorator + if an executable(s) is not found.""" + import pytest + + execs: list[str] + if isinstance(exec, str): + execs = [exec] + elif isinstance(exec, list): + execs = exec + else: + raise ValueError(f"Bad type passed to skip_if_missing_exec. Found type {type(exec)}") + + found_exec = False + for exec_ in execs: + found_exec = found_exec or has_executable(exec_) + + reason = f"Package {exec!s} is required, but was not found." + mark = pytest.mark.skipif(not found_exec, reason=reason) + return mark diff --git a/openff/toolkit/_utilities/utilities.py b/openff/toolkit/_utilities/utilities.py new file mode 100644 index 000000000..550e78809 --- /dev/null +++ b/openff/toolkit/_utilities/utilities.py @@ -0,0 +1,262 @@ +import errno +import importlib +import os +from collections.abc import Callable, Generator +from contextlib import contextmanager +from functools import wraps +from importlib.resources import as_file, files +from tempfile import TemporaryDirectory +from typing import Any, Literal, TypeVar + +from openff.toolkit._utilities.exceptions import MissingOptionalDependencyError + +# https://mypy.readthedocs.io/en/stable/generics.html#declaring-decorators + +F = TypeVar("F", bound=Callable[..., Any]) + + +def has_package(package_name: str) -> bool: + """ + Helper function to generically check if a Python package is installed. + Intended to be used to check for optional dependencies. + + Parameters + ---------- + package_name : str + The name of the Python package to check the availability of + + Returns + ------- + package_available : bool + Boolean indicator if the package is available or not + + Examples + -------- + >>> has_numpy = has_package('numpy') + >>> has_numpy + True + >>> has_foo = has_package('other_non_installed_package') + >>> has_foo + False + """ + try: + importlib.import_module(package_name) + except ModuleNotFoundError: + return False + return True + + +def requires_package(package_name: str) -> Callable[..., Any]: + """ + Helper function to denote that a funciton requires some optional + dependency. A function decorated with this decorator will raise + `MissingOptionalDependencyError` if the package is not found by + `importlib.import_module()`. + + Parameters + ---------- + package_name : str + The name of the module to be imported. + + Raises + ------ + MissingOptionalDependencyError + + """ + + def inner_decorator(function: F) -> F: + @wraps(function) + def wrapper(*args, **kwargs): # type: ignore[no-untyped-def] + import importlib + + try: + importlib.import_module(package_name) + except ImportError: + raise MissingOptionalDependencyError(library_name=package_name) + except Exception as e: + raise e + + return function(*args, **kwargs) + + return wrapper # type: ignore[return-value] + + return inner_decorator + + +def requires_oe_module( + module_name: Literal["oechem", "oeomega", "oequacpac", "oeiupac", "oedepict"], +) -> Callable[..., Any]: + """ + Helper function to denote that a funciton requires a particular OpenEye library. + A function decorated with this decorator will raise `MissingOptionalDependencyError` if + the module is not found by @requires_package or the module is not found to be + licensed. + + Parameters + ---------- + module_name : str + The name of the OpenEye module to be imported. + + Raises + ------ + MissingOptionalDependencyError + """ + + def inner_decorator(function: F) -> F: + @requires_package(f"openeye.{module_name}") + @wraps(function) + def wrapper(*args, **kwargs): # type: ignore[no-untyped-def] + oe_module = importlib.import_module(f"openeye.{module_name}") + + license_functions = { + "oechem": "OEChemIsLicensed", + "oequacpac": "OEQuacPacIsLicensed", + "oeiupac": "OEIUPACIsLicensed", + "oeomega": "OEOmegaIsLicensed", + "oedepict": "OEDepictIsLicensed", + } + + is_licensed = getattr(oe_module, license_functions[module_name])() + + if not is_licensed: + raise MissingOptionalDependencyError(library_name=f"openeye.{module_name}", license_issue=True) + + return function(*args, **kwargs) + + return wrapper # type: ignore + + return inner_decorator + + +def has_executable(program_name: str) -> bool: + import os + + def _is_executable(fpath: str) -> bool: + return os.path.isfile(fpath) and os.access(fpath, os.X_OK) + + fpath, _ = os.path.split(program_name) + + if fpath: + if _is_executable(program_name): + return True + else: + for path in os.environ["PATH"].split(os.pathsep): + path = path.strip('"') + exe_file = os.path.join(path, program_name) + if _is_executable(exe_file): + return True + + return False + + +@contextmanager +def temporary_cd(directory_path: str | None = None) -> Generator[None, None, None]: + """Temporarily move the current working directory to the path + specified. If no path is given, a temporary directory will be + created, moved into, and then destroyed when the context manager + is closed. + + Parameters + ---------- + directory_path: str, optional + + Returns + ------- + + """ + + if directory_path is not None and len(directory_path) == 0: + yield + return + + old_directory = os.getcwd() + + try: + if directory_path is None: + with TemporaryDirectory() as new_directory: + os.chdir(new_directory) + yield + + else: + os.chdir(directory_path) + yield + + finally: + os.chdir(old_directory) + + +def get_data_dir_path(relative_path: str, package_name: str) -> str: + """Get the full path to a directory within a module's tree. + + If no directory is found at `relative_path`, a second attempt will be made + with `data/` preprended. If no directory is found at path, a NotADirectoryError + is raised. + + Parameters + ---------- + relative_path : str + The relative path of the file to load. + package_name : str + The name of the package in which a file is to be loaded, i.e. "openff.toolkit" or "openff.evaluator". + + Returns + ------- + The absolute path to the file. + + Raises + ------ + NotADirectoryError + + See Also + -------- + get_data_file_path, for getting the path to a particular file in a data directory. + + """ + with as_file(files(package_name) / relative_path) as dir_path: + if dir_path.is_dir(): + return dir_path.as_posix() + + with as_file(files(package_name) / "data" / relative_path) as dir_path: + if dir_path.is_dir(): + return dir_path.as_posix() + + raise NotADirectoryError(f"Directory {relative_path} not found in {package_name}.") + + +def get_data_file_path(relative_path: str, package_name: str) -> str: + """Get the full path to one of the files in the data directory. + + If no file is found at `relative_path`, a second attempt will be made + with `data/` preprended. If no files exist at either path, a FileNotFoundError + is raised. + + Parameters + ---------- + relative_path : str + The relative path of the file to load. + package_name : str + The name of the package in which a file is to be loaded, i.e. + "openff.toolkit" or "openff.evaluator" + + Returns + ------- + The absolute path to the file. + + Raises + ------ + FileNotFoundError + + See Also + -------- + get_data_dir_path, for getting the path to a directory instead of an individual file. + + """ + with as_file(files(package_name) / relative_path) as file_path: + if file_path.is_file(): + return file_path.as_posix() + + with as_file(files(package_name) / "data" / relative_path) as file_path: + if file_path.is_file(): + return file_path.as_posix() + + raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), file_path) diff --git a/openff/toolkit/_utilities/warnings.py b/openff/toolkit/_utilities/warnings.py new file mode 100644 index 000000000..6afb2c25a --- /dev/null +++ b/openff/toolkit/_utilities/warnings.py @@ -0,0 +1,4 @@ +class CondaExecutableNotFoundWarning(UserWarning): + """ + A conda (or mamba/micromamba) executable is not found. + """ diff --git a/openff/toolkit/topology/molecule.py b/openff/toolkit/topology/molecule.py index fee8216df..d65671f6e 100644 --- a/openff/toolkit/topology/molecule.py +++ b/openff/toolkit/topology/molecule.py @@ -48,9 +48,9 @@ import numpy as np from openff.units import Unit from openff.units.elements import MASSES, SYMBOLS -from openff.utilities.exceptions import MissingOptionalDependencyError from openff.toolkit import Quantity, unit +from openff.toolkit._utilities.exceptions import MissingOptionalDependencyError from openff.toolkit.utils.constants import DEFAULT_AROMATICITY_MODEL from openff.toolkit.utils.exceptions import ( AtomMappingWarning, diff --git a/openff/toolkit/utils/ambertools_wrapper.py b/openff/toolkit/utils/ambertools_wrapper.py index 3792b9cd5..157d9bc57 100644 --- a/openff/toolkit/utils/ambertools_wrapper.py +++ b/openff/toolkit/utils/ambertools_wrapper.py @@ -74,7 +74,7 @@ def __init__(self): @functools.cached_property def _toolkit_version(self): - from openff.utilities.provenance import get_ambertools_version + from openff.toolkit._utilities.provenance import get_ambertools_version return get_ambertools_version() diff --git a/openff/toolkit/utils/utils.py b/openff/toolkit/utils/utils.py index 5cc7a6a13..397515ffa 100644 --- a/openff/toolkit/utils/utils.py +++ b/openff/toolkit/utils/utils.py @@ -22,24 +22,26 @@ "temporary_cd", "unit_to_string", ] - import contextlib import functools import logging -from collections.abc import Iterable +from collections.abc import Callable, Iterable +from functools import wraps from typing import TYPE_CHECKING, Any, TypeVar, overload import numpy as np import pint from numpy.typing import NDArray from openff.units import Quantity, Unit -from openff.utilities import requires_package + +from openff.toolkit._utilities.exceptions import MissingOptionalDependencyError if TYPE_CHECKING: from openff.toolkit import ForceField, Molecule logger = logging.getLogger(__name__) +F = TypeVar("F", bound=Callable[..., Any]) # Pre-create an instance of the dimensionless unit to speed up comparisons later _DIMENSIONLESS = Unit("dimensionless") @@ -803,3 +805,40 @@ def sort_smirnoff_dict(data): # Handle metadata or the bottom of a recursive dict sorted_dict[key] = val return sorted_dict + + +def requires_package(package_name: str) -> Callable[..., Any]: + """ + Helper function to denote that a funciton requires some optional + dependency. A function decorated with this decorator will raise + `MissingOptionalDependencyError` if the package is not found by + `importlib.import_module()`. + + Parameters + ---------- + package_name : str + The name of the module to be imported. + + Raises + ------ + MissingOptionalDependencyError + + """ + + def inner_decorator(function: F) -> F: + @wraps(function) + def wrapper(*args, **kwargs): # type: ignore[no-untyped-def] + import importlib + + try: + importlib.import_module(package_name) + except ImportError: + raise MissingOptionalDependencyError(library_name=package_name) + except Exception as e: + raise e + + return function(*args, **kwargs) + + return wrapper # type: ignore[return-value] + + return inner_decorator diff --git a/pyproject.toml b/pyproject.toml index aeca291d6..1bf6071f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ lint.per-file-ignores."openff/toolkit/typing/engines/smirnoff.parameters.py" = [ lint.isort.known-first-party = [ "openff.toolkit" ] # can't find a clean way to get Rust's globset to handle this via regex ... # https://docs.astral.sh/ruff/settings/#lint_isort_known-third-party -lint.isort.known-third-party = [ "openff.interchange", "openff.utilities", "openff.units" ] +lint.isort.known-third-party = [ "openff.interchange", "openff.units" ] [tool.mypy] python_version = 3.12 From 250891f7e946b9a5ed28098f78c9bcabe766eff5 Mon Sep 17 00:00:00 2001 From: "Matthew W. Thompson" Date: Mon, 6 Jul 2026 15:14:52 -0500 Subject: [PATCH 2/3] Fix test configuration --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1bf6071f4..3aca02616 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,10 +80,11 @@ module = [ ignore_missing_imports = true [tool.pytest] -ini_options.addopts = [ "--strict-markers" ] +ini_options.addopts = [ "--strict-markers", '-m "not leaky"' ] ini_options.markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", "wip: marks tests as work in progress", + "leaky: run leaky tests", ] ini_options.doctest_optionflags = [ "ELLIPSIS", From 4be052cfb765d5e9b89a5b40399591c90611a335 Mon Sep 17 00:00:00 2001 From: "Matthew W. Thompson" Date: Mon, 6 Jul 2026 15:25:15 -0500 Subject: [PATCH 3/3] Fix configuration again --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3aca02616..149ad3e54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,7 @@ module = [ ignore_missing_imports = true [tool.pytest] -ini_options.addopts = [ "--strict-markers", '-m "not leaky"' ] +ini_options.addopts = '--strict-markers -m "not leaky"' ini_options.markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", "wip: marks tests as work in progress",