diff --git a/CHANGELOG.md b/CHANGELOG.md index 1894a41..f87aa4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,30 +10,25 @@ Contributors: @RMeli, @takluyver, @Jnelen ### Added +* Functionality to manually select the backend from CLI [PR #108 | @RMeli] * Functionality to manually select the backend [PR #107 | @Jnelen] +* Python `3.12` to CI [PR #102 | @RMeli] +* macOS M1 (`macoOS-14`) to CI [PR #102 | @RMeli] ### Changed -* Molecular graphs are now cached per backend using a dictionary [PR #107 | @Jnelen] +* Molecular graphs cache to cache by backend [PR #107 | @Jnelen] +* Python build system to use flit_core directly [PR #103 | @takluyver] +* Minimum version of Python to `3.9` (to reduce CI matrix) [PR #102 | @RMeli] ### Fixed * Failing tests with `pytest=8.0.0` [PR #101 | @RMeli] -### Changed - -* Updated Python build system to use flit_core directly [PR #103 | @takluyver] -* Minimum version of Python to `3.9` (to reduce CI matrix) [PR #102 | @RMeli] - ### Improved * Messages for `NotImplementedError` exceptions [PR #90 | @RMeli] -### Added - -* Python `3.12` to CI [PR #102 | @RMeli] -* macOS M1 (`macoOS-14`) to CI [PR #102 | @RMeli] - ### Removed * `.gitattributes` and `.lgtm.yaml` stale files diff --git a/README.md b/README.md index 701a1e6..8a926bb 100644 --- a/README.md +++ b/README.md @@ -89,27 +89,12 @@ Additionally, one of the following packages is required to use `spyrmsd` as a st ### Standalone Tool +`spyrmsd` provides a convenient CLI tool. See `spyrmsd`'s `--help` for the usage: + ```bash python -m spyrmsd -h ``` -```text -usage: python -m spyrmsd [-h] [-m] [-c] [--hydrogens] [-n] reference molecules [molecules ...] - -Symmetry-corrected RMSD calculations in Python. - -positional arguments: - reference Reference file - molecules Input file(s) - -optional arguments: - -h, --help show this help message and exit - -m, --minimize Minimize (fit) - -c, --center Center molecules at origin - --hydrogens Keep hydrogen atoms - -n, --nosymm No graph isomorphism -``` - ### Module ```python @@ -159,6 +144,25 @@ def symmrmsd( > [!NOTE] > Atomic properties (`aprops`) can be any Python object when using [NetworkX](https://networkx.github.io/), or integers, floats, or strings when using [graph-tool](https://graph-tool.skewed.de/). +#### Select Backend + +`spyrmsd` supports both [NetworkX](https://networkx.github.io/) and [graph-tool](https://graph-tool.skewed.de/) for the calculation of graph isomorphisms. You can check which backend is being used with + +```python +spyrmsd.get_backend() +``` + +You can also manually select your preferred backend with + +```python +spyrmsd.set_backend("networkx") +# spyrmsd uses NetworkX +spyrmsd.set_backend("graph_tool") +# spyrmsd uses graph_tool +``` + +The available backends (which depend on the installed dependencies) are stored in `spyrmsd.available_backends`. + ## Development To ensure code quality and consistency the following tools are used during development: diff --git a/docs/source/tutorials/tutorial.ipynb b/docs/source/tutorials/tutorial.ipynb index 5b626ea..1cf200e 100644 --- a/docs/source/tutorials/tutorial.ipynb +++ b/docs/source/tutorials/tutorial.ipynb @@ -14,6 +14,7 @@ "metadata": {}, "outputs": [], "source": [ + "import spyrmsd\n", "from spyrmsd import io, rmsd" ] }, @@ -107,8 +108,8 @@ "output_type": "stream", "text": [ ":241: RuntimeWarning: to-Python converter for std::__1::pair already registered; second conversion method ignored.\n", - "[18:44:03] Molecule does not have explicit Hs. Consider calling AddHs()\n", - "[18:44:03] Molecule does not have explicit Hs. Consider calling AddHs()\n" + "[21:58:01] Molecule does not have explicit Hs. Consider calling AddHs()\n", + "[21:58:01] Molecule does not have explicit Hs. Consider calling AddHs()\n" ] }, { @@ -289,6 +290,102 @@ "\n", "print(RMSD)" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Change Backend" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "`spyrmsd` supports multiple backends. You see which backends are available by looking at the `available_backends` attribute:" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "['graph_tool', 'networkx']" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "spyrmsd.available_backends" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The available backends are a subset of the supported backends. Only the backends that are installed will be available." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can check the current backend with" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'graph_tool'" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "spyrmsd.get_backend()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "You can switch the backend using" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'networkx'" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "spyrmsd.set_backend(\"networkx\")\n", + "spyrmsd.get_backend()" + ] } ], "metadata": { @@ -307,7 +404,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.4" + "version": "3.11.8" } }, "nbformat": 4, diff --git a/docs/source/tutorials/tutorial.rst b/docs/source/tutorials/tutorial.rst index 10dcfb6..0c37d23 100644 --- a/docs/source/tutorials/tutorial.rst +++ b/docs/source/tutorials/tutorial.rst @@ -3,6 +3,7 @@ Tutorial .. code:: ipython3 + import spyrmsd from spyrmsd import io, rmsd OpenBabel or RDKit @@ -68,8 +69,8 @@ constructors. .. parsed-literal:: :241: RuntimeWarning: to-Python converter for std::__1::pair already registered; second conversion method ignored. - [18:44:03] Molecule does not have explicit Hs. Consider calling AddHs() - [18:44:03] Molecule does not have explicit Hs. Consider calling AddHs() + [21:58:01] Molecule does not have explicit Hs. Consider calling AddHs() + [21:58:01] Molecule does not have explicit Hs. Consider calling AddHs() @@ -95,7 +96,7 @@ Hydrogen atoms can be removed with the ``strip()`` function: mol.strip() Symmetry-Corrected RMSD ------------------------ +~~~~~~~~~~~~~~~~~~~~~~~ ``spyrmsd`` only needs atomic coordinates, atomic number and the molecular adjacency matrix to compute the standard RMSD with @@ -137,7 +138,7 @@ reference molecule and all other molecules: Minimum RMSD -~~~~~~~~~~~~ +------------ We can also compute the minimum RMSD obtained by superimposing the molecular structures: @@ -160,3 +161,56 @@ molecular structures: .. parsed-literal:: [1.2012368667355435, 1.0533413220699535, 1.153253104575529, 1.036542688936588, 0.8407673221224187, 1.1758143217869736, 0.7817315189656655, 1.0933314311267845, 1.0260767175206462, 0.9586369647000478] + + + +Change Backend +~~~~~~~~~~~~~~ + +``spyrmsd`` supports multiple backends. You see which backends are +available by looking at the ``available_backends`` attribute: + +.. code:: ipython3 + + spyrmsd.available_backends + + + + +.. parsed-literal:: + + ['graph_tool', 'networkx'] + + + +The available backends are a subset of the supported backends. Only the +backends that are installed will be available. + +You can check the current backend with + +.. code:: ipython3 + + spyrmsd.get_backend() + + + + +.. parsed-literal:: + + 'graph_tool' + + + +You can switch the backend using + +.. code:: ipython3 + + spyrmsd.set_backend("networkx") + spyrmsd.get_backend() + + + + +.. parsed-literal:: + + 'networkx' diff --git a/spyrmsd/__main__.py b/spyrmsd/__main__.py index b18dcac..e61563d 100644 --- a/spyrmsd/__main__.py +++ b/spyrmsd/__main__.py @@ -6,7 +6,9 @@ import argparse as ap import importlib.util import sys + import warnings + import spyrmsd from spyrmsd import io from spyrmsd.rmsd import rmsdwrapper @@ -25,6 +27,13 @@ parser.add_argument( "-n", "--nosymm", action="store_false", help="No graph isomorphism" ) + parser.add_argument( + "-g", + "--graph-backend", + type=str, + default=None, + help="Graph library (backend)", + ) args = parser.parse_args() @@ -49,6 +58,11 @@ print("ERROR: Molecule file(s) not found.", file=sys.stderr) exit(-1) + if args.graph_backend is not None: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + spyrmsd.set_backend(args.graph_backend) + # Loop over molecules within fil RMSDlist = rmsdwrapper( ref, diff --git a/spyrmsd/graph.py b/spyrmsd/graph.py index 3d27a16..b788992 100644 --- a/spyrmsd/graph.py +++ b/spyrmsd/graph.py @@ -1,32 +1,33 @@ +import importlib.util import warnings import numpy as np from spyrmsd import constants +_supported_backends = ("graph_tool", "networkx") + _available_backends = [] _current_backend = None -## Backend aliases -_graph_tool_aliases = ["graph_tool", "graphtool", "graph-tool", "graph tool", "gt"] -_networkx_aliases = ["networkx", "nx"] +_backend_to_alias = { + "graph_tool": ["graph_tool", "graphtool", "graph-tool", "graph tool", "gt"], + "networkx": ["networkx", "nx"], +} -## Construct the alias dictionary _alias_to_backend = {} -for alias in _graph_tool_aliases: - _alias_to_backend[alias.lower()] = "graph-tool" -for alias in _networkx_aliases: - _alias_to_backend[alias.lower()] = "networkx" +for backend, aliases in _backend_to_alias.items(): + for alias in aliases: + _alias_to_backend[alias] = backend def _dummy(*args, **kwargs): """ Dummy function for backend not set. """ - raise NotImplementedError("No backend is set.") + raise NotImplementedError("No backend is set for spyrmsd.") -## Assigning the properties/methods associated with a backend to a temporary dummy function cycle = _dummy graph_from_adjacency_matrix = _dummy lattice = _dummy @@ -35,58 +36,84 @@ def _dummy(*args, **kwargs): num_vertices = _dummy vertex_property = _dummy -try: - from spyrmsd.graphs.gt import cycle as gt_cycle - from spyrmsd.graphs.gt import ( - graph_from_adjacency_matrix as gt_graph_from_adjacency_matrix, - ) - from spyrmsd.graphs.gt import lattice as gt_lattice - from spyrmsd.graphs.gt import match_graphs as gt_match_graphs - from spyrmsd.graphs.gt import num_edges as gt_num_edges - from spyrmsd.graphs.gt import num_vertices as gt_num_vertices - from spyrmsd.graphs.gt import vertex_property as gt_vertex_property - - _available_backends.append("graph-tool") -except ImportError: - warnings.warn("The graph-tool backend does not seem to be installed.") - -try: - from spyrmsd.graphs.nx import cycle as nx_cycle - from spyrmsd.graphs.nx import ( - graph_from_adjacency_matrix as nx_graph_from_adjacency_matrix, - ) - from spyrmsd.graphs.nx import lattice as nx_lattice - from spyrmsd.graphs.nx import match_graphs as nx_match_graphs - from spyrmsd.graphs.nx import num_edges as nx_num_edges - from spyrmsd.graphs.nx import num_vertices as nx_num_vertices - from spyrmsd.graphs.nx import vertex_property as nx_vertex_property - - _available_backends.append("networkx") -except ImportError: - warnings.warn("The networkx backend does not seem to be installed.") +# Check which supported backend is available +for backend in _supported_backends: + if importlib.util.find_spec(backend) is not None: + _available_backends.append(backend) def _validate_backend(backend): + """ + Validate backend. + + Check if a backend is supported and installed (available). + + Parameters + ---------- + backend: str + Backend to validate + return: str + Standardized backend name + + Raises + ------ + ValueError + If the backend is not recognized or supported + ImportError + If the backend is not installed + + Notes + ----- + This function is case-insensitive. + """ standardized_backend = _alias_to_backend.get(backend.lower()) + if standardized_backend is None: raise ValueError(f"The {backend} backend is not recognized or supported") + if standardized_backend not in _available_backends: raise ImportError(f"The {backend} backend doesn't seem to be installed") + return standardized_backend def _set_backend(backend): + """ + Set backend to use for graph operations. + + Parameters + ---------- + backend: str + Backend to use + + Notes + ----- + This function sets the :code:`_current_backend` variable with a validated backend. + + This function modifies the global (module) variables. + """ + # Global (module) variables modified by this function global _current_backend + global cycle, graph_from_adjacency_matrix, lattice, match_graphs, num_edges, num_vertices, vertex_property + backend = _validate_backend(backend) - ## Check if we actually need to switch backends + # Check if we actually need to switch backends if backend == _current_backend: warnings.warn(f"The backend is already {backend}.") return - global cycle, graph_from_adjacency_matrix, lattice, match_graphs, num_edges, num_vertices, vertex_property + if backend == "graph_tool": + from spyrmsd.graphs.gt import cycle as gt_cycle + from spyrmsd.graphs.gt import ( + graph_from_adjacency_matrix as gt_graph_from_adjacency_matrix, + ) + from spyrmsd.graphs.gt import lattice as gt_lattice + from spyrmsd.graphs.gt import match_graphs as gt_match_graphs + from spyrmsd.graphs.gt import num_edges as gt_num_edges + from spyrmsd.graphs.gt import num_vertices as gt_num_vertices + from spyrmsd.graphs.gt import vertex_property as gt_vertex_property - if backend == "graph-tool": cycle = gt_cycle graph_from_adjacency_matrix = gt_graph_from_adjacency_matrix lattice = gt_lattice @@ -96,6 +123,16 @@ def _set_backend(backend): vertex_property = gt_vertex_property elif backend == "networkx": + from spyrmsd.graphs.nx import cycle as nx_cycle + from spyrmsd.graphs.nx import ( + graph_from_adjacency_matrix as nx_graph_from_adjacency_matrix, + ) + from spyrmsd.graphs.nx import lattice as nx_lattice + from spyrmsd.graphs.nx import match_graphs as nx_match_graphs + from spyrmsd.graphs.nx import num_edges as nx_num_edges + from spyrmsd.graphs.nx import num_vertices as nx_num_vertices + from spyrmsd.graphs.nx import vertex_property as nx_vertex_property + cycle = nx_cycle graph_from_adjacency_matrix = nx_graph_from_adjacency_matrix lattice = nx_lattice @@ -109,15 +146,18 @@ def _set_backend(backend): if len(_available_backends) == 0: raise ImportError( - "No valid backends found. Please ensure that either graph-tool or NetworkX are installed." + "No valid backends found. Please ensure that one of the supported backends is installed." + + f"\nSupported backends: {_supported_backends}" ) else: if _current_backend is None: - ## Set the backend to the first available (preferred) backend _set_backend(backend=_available_backends[0]) def _get_backend(): + """ + Get the current backend. + """ return _current_backend diff --git a/tests/test_graph.py b/tests/test_graph.py index 88c9643..cbd607f 100644 --- a/tests/test_graph.py +++ b/tests/test_graph.py @@ -143,11 +143,11 @@ def test_build_graph_node_features(property) -> None: assert graph.num_edges(G) == 3 +@pytest.mark.skipif( + spyrmsd.get_backend() != "graph_tool", + reason="NetworkX supports all Python objects as node properties.", +) def test_build_graph_node_features_unsupported() -> None: - pytest.importorskip( - "graph_tool", reason="NetworkX supports all Python objects as node properties." - ) - A = np.array([[0, 1, 1], [1, 0, 0], [1, 0, 1]]) property = [True, False, True] @@ -157,7 +157,8 @@ def test_build_graph_node_features_unsupported() -> None: @pytest.mark.skipif( - len(spyrmsd.available_backends) < 2, + # Run test if all supported backends are installed + not set(spyrmsd.graph._supported_backends) <= set(spyrmsd.available_backends), reason="Not all of the required backends are installed", ) def test_set_backend() -> None: @@ -173,7 +174,7 @@ def test_set_backend() -> None: assert isinstance(Gnx, nx.Graph) spyrmsd.set_backend("graph-tool") - assert spyrmsd.get_backend() == "graph-tool" + assert spyrmsd.get_backend() == "graph_tool" Ggt = graph.graph_from_adjacency_matrix(A) assert isinstance(Ggt, gt.Graph) diff --git a/tests/test_molecule.py b/tests/test_molecule.py index 450f453..4e2fcbd 100644 --- a/tests/test_molecule.py +++ b/tests/test_molecule.py @@ -240,7 +240,8 @@ def test_from_rdmol(adjacency): @pytest.mark.skipif( - len(spyrmsd.available_backends) < 2, + # Run test if all supported backends are installed + not set(spyrmsd.graph._supported_backends) <= set(spyrmsd.available_backends), reason="Not all of the required backends are installed", ) @pytest.mark.parametrize( @@ -256,14 +257,14 @@ def test_molecule_graph_cache(mol) -> None: mol.to_graph() assert isinstance(mol.G["networkx"], nx.Graph) - assert "graph-tool" not in mol.G.keys() + assert "graph_tool" not in mol.G.keys() spyrmsd.set_backend("graph-tool") mol.to_graph() ## Make sure both backends (still) have a cache assert isinstance(mol.G["networkx"], nx.Graph) - assert isinstance(mol.G["graph-tool"], gt.Graph) + assert isinstance(mol.G["graph_tool"], gt.Graph) ## Strip the molecule to ensure the cache is reset mol.strip()