diff --git a/package/MDAnalysis/analysis/align.py b/package/MDAnalysis/analysis/align.py index 6b85b5d533..ac9771b85d 100644 --- a/package/MDAnalysis/analysis/align.py +++ b/package/MDAnalysis/analysis/align.py @@ -196,25 +196,24 @@ import numpy as np -try: - import Bio.AlignIO - import Bio.Align - import Bio.Align.Applications -except ImportError: - HAS_BIOPYTHON = False -else: - HAS_BIOPYTHON = True - import MDAnalysis as mda import MDAnalysis.lib.qcprot as qcp from MDAnalysis.exceptions import SelectionError, SelectionWarning import MDAnalysis.analysis.rms as rms from MDAnalysis.coordinates.memory import MemoryReader -from MDAnalysis.lib.util import get_weights +from MDAnalysis.lib.util import get_weights, optional_import from MDAnalysis.lib.util import deprecate # remove 3.0 from .base import AnalysisBase +biopython = optional_import('Bio', min_version='1.80') + +if biopython is not None: + import Bio.AlignIO + import Bio.Align + import Bio.Align.Applications + + logger = logging.getLogger('MDAnalysis.analysis.align') @@ -1070,7 +1069,7 @@ def sequence_alignment(mobile, reference, match_score=2, mismatch_penalty=-1, Biopython is now an optional dependency which this method requires. """ - if not HAS_BIOPYTHON: + if biopython is None: errmsg = ("The `sequence_alignment` method requires an installation " "of `Biopython`. Please install `Biopython` to use this " "method: https://biopython.org/wiki/Download") @@ -1194,7 +1193,7 @@ def fasta2select(fastafilename, is_aligned=False, Biopython is now an optional dependency which this method requires. """ - if not HAS_BIOPYTHON: + if biopython is None: errmsg = ("The `fasta2select` method requires an installation " "of `Biopython`. Please install `Biopython` to use this " "method: https://biopython.org/wiki/Download") diff --git a/package/MDAnalysis/lib/util.py b/package/MDAnalysis/lib/util.py index 072be67ee2..ca6a363ab9 100644 --- a/package/MDAnalysis/lib/util.py +++ b/package/MDAnalysis/lib/util.py @@ -200,14 +200,18 @@ import gzip import re import io +import importlib import warnings import functools from functools import wraps +import types +from typing import Optional import textwrap import weakref import mmtf import numpy as np +from packaging.version import Version from numpy.testing import assert_equal import inspect @@ -2552,3 +2556,59 @@ def wrapper(self, *args, **kwargs): self._kwargs[key] = arg return func(self, *args, **kwargs) return wrapper + + +def optional_import( + module_name: str, + min_version: Optional[str] = None, + max_version: Optional[str] = None +) -> Optional[types.ModuleType]: + """ + Optionally import modules, optionally checking the version + + Parameters + ---------- + module_name : str + Name of the package to be optionally imported. + min_version : Optional[str] + The minimum package version. If ``None`` will not check the lower + version bound. + max_version : Optional[str] + The maximum package version. If ``None`` will not check the upper + version bound. + + Returns + ------- + module : Optional[types.ModuleType] + The imported module. If the module could not be imported, will + return ``None``. + + Raises + ------ + ImportError + If the imported module version (assumed to be accessed under + ``module.__version__``) does not fit within the upper or lower + bound set by `min_version` and `max_version`. + """ + def _check_version( + lower_version: Optional[str], upper_version: Optional[str] + ): + if lower_version is None or upper_version is None: + return True + else: + return Version(lower_version) <= Version(upper_version) + + try: + module = importlib.import_module(module_name) + except ModuleNotFoundError: + return None + + if ((not _check_version(min_version, module.__version__)) or + (not _check_version(module.__version__, max_version))): + wmsg = (f"{module_name} version is {module.__version__} " + f"and allowed version ranges are >= {min_version} " + f"<= {max_version}") + warnings.warn(wmsg) + return None + + return module diff --git a/testsuite/MDAnalysisTests/analysis/test_align.py b/testsuite/MDAnalysisTests/analysis/test_align.py index e6ff3acb4c..f02ac94da2 100644 --- a/testsuite/MDAnalysisTests/analysis/test_align.py +++ b/testsuite/MDAnalysisTests/analysis/test_align.py @@ -24,7 +24,7 @@ import MDAnalysis as mda import MDAnalysis.analysis.align as align -from MDAnalysis.analysis.align import HAS_BIOPYTHON +from MDAnalysis.analysis.align import biopython import MDAnalysis.analysis.rms as rms import os import numpy as np @@ -501,13 +501,13 @@ class TestAlignmentProcessing: seq = FASTA error_msg = "selection string has unexpected length" - @pytest.mark.skipif(HAS_BIOPYTHON, reason='biopython is installed') + @pytest.mark.skipif(biopython is not None, reason='biopython is installed') def test_importerror_biopython(self): errmsg = "The `fasta2select` method requires an installation" with pytest.raises(ImportError, match=errmsg): _ = align.fasta2select(self.seq, is_aligned=True) - @pytest.mark.skipif(not HAS_BIOPYTHON, reason='requires biopython') + @pytest.mark.skipif(biopython is None, reason='requires biopython') def test_fasta2select_aligned(self): """test align.fasta2select() on aligned FASTA (Issue 112)""" sel = align.fasta2select(self.seq, is_aligned=True) @@ -516,7 +516,7 @@ def test_fasta2select_aligned(self): assert len(sel['mobile']) == 30623, self.error_msg @pytest.mark.skipif( - executable_not_found("clustalw2") or not HAS_BIOPYTHON, + executable_not_found("clustalw2") or biopython is None, reason="Test skipped because clustalw2 executable not found") def test_fasta2select_file(self, tmpdir): """test align.fasta2select() on a non-aligned FASTA with default @@ -528,7 +528,7 @@ def test_fasta2select_file(self, tmpdir): assert len(sel['mobile']) == 23090, self.error_msg @pytest.mark.skipif( - executable_not_found("clustalw2") or not HAS_BIOPYTHON, + executable_not_found("clustalw2") or biopython is None, reason="Test skipped because clustalw2 executable not found") def test_fasta2select_ClustalW(self, tmpdir): """MDAnalysis.analysis.align: test fasta2select() with ClustalW @@ -543,7 +543,7 @@ def test_fasta2select_ClustalW(self, tmpdir): assert len(sel['reference']) == 23080, self.error_msg assert len(sel['mobile']) == 23090, self.error_msg - @pytest.mark.skipif(not HAS_BIOPYTHON, reason='requires biopython') + @pytest.mark.skipif(biopython is None, reason='requires biopython') def test_fasta2select_resids(self, tmpdir): """test align.fasta2select() when resids provided (Issue #3124)""" resids = [x for x in range(705)] @@ -565,14 +565,14 @@ def atomgroups(): mobile = universe.select_atoms("resid 122-159") return reference, mobile - @pytest.mark.skipif(HAS_BIOPYTHON, reason='biopython installed') + @pytest.mark.skipif(biopython is not None, reason='biopython installed') def test_biopython_import_error(self, atomgroups): ref, mob = atomgroups errmsg = "The `sequence_alignment` method requires an installation of" with pytest.raises(ImportError, match=errmsg): align.sequence_alignment(mob, ref) - @pytest.mark.skipif(not HAS_BIOPYTHON, reason='requires biopython') + @pytest.mark.skipif(biopython is None, reason='requires biopython') @pytest.mark.filterwarnings("ignore:`sequence_alignment` is deprecated!") def test_sequence_alignment(self, atomgroups): reference, mobile = atomgroups @@ -588,7 +588,7 @@ def test_sequence_alignment(self, atomgroups): assert score == pytest.approx(54.6) assert_array_equal([begin, end], [0, reference.n_residues]) - @pytest.mark.skipif(not HAS_BIOPYTHON, reason='requires biopython') + @pytest.mark.skipif(biopython is None, reason='requires biopython') def test_sequence_alignment_deprecation(self, atomgroups): reference, mobile = atomgroups wmsg = ("`sequence_alignment` is deprecated!\n"