Repository: matchms/matchms
Pull request #148 (2020-09-18 11:36:50)

Prepare pip package
- made ``scipy`` and ``rdkit`` optional imports
- add requirements to ``setup.py``
- add CI to test pip package ~~--> does not work with ``macos-latest`` because that will install Python 2, no idea why.~~
- CI upon ``[release]`` still needs added token to secrets (I will do the first pypi upload manually and then add the token to this repo)


Patch:
diff --git a/.github/workflows/CI_publish_pypi.yml b/.github/workflows/CI_publish_pypi.yml
new file mode 100644
index 00000000..9eab48e4
--- /dev/null
+++ b/.github/workflows/CI_publish_pypi.yml
@@ -0,0 +1,25 @@
+name: Publish
+
+on:
+  release:
+    types: [published]
+
+jobs:
+  publish:
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v2
+    - name: Set up Python
+      uses: actions/setup-python@v1
+      with:
+        python-version: 3.7
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install setuptools wheel twine
+        python setup.py sdist bdist_wheel
+    - name: Publish package
+      uses: pypa/gh-action-pypi-publish@master
+      with:
+        user: __token__
+        password: ${{ secrets.PYPI_TOKEN }}
diff --git a/.github/workflows/CI_push_pypi.yml b/.github/workflows/CI_push_pypi.yml
new file mode 100644
index 00000000..dbc53c6a
--- /dev/null
+++ b/.github/workflows/CI_push_pypi.yml
@@ -0,0 +1,37 @@
+name: Pypi Build
+
+on: [push]
+
+jobs:
+
+  build:
+    name: Build for (${{ matrix.python-version }}, ${{ matrix.os }})
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
+        python-version: ['3.7', '3.8']
+    steps:
+      - uses: actions/checkout@v2
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v1
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Python info
+        run: |
+          which python
+          python --version
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+      - name: Build
+        run: |
+          python setup.py build
+      - name: Show pip list
+        run: |
+          pip list
+      - name: Test
+        run: |
+          pip install -e .[dev]
+          pytest
diff --git a/.github/workflows/conda_build.yml b/.github/workflows/conda_build.yml
index 3cb4c8a0..d9d40ec7 100644
--- a/.github/workflows/conda_build.yml
+++ b/.github/workflows/conda_build.yml
@@ -73,7 +73,7 @@ jobs:
         if: matrix.os != 'ubuntu-latest'
         shell: bash -l {0}
         run: |
-          python setup.py test
+          pytest
       - name: Test with coverage
         if: matrix.os == 'ubuntu-latest'
         shell: bash -l {0}
diff --git a/README.rst b/README.rst
index 8e359fc8..bfd62d35 100644
--- a/README.rst
+++ b/README.rst
@@ -99,9 +99,9 @@ Installation
 Prerequisites:  
 
 - Python 3.7 or 3.8 
-- Anaconda
+- Anaconda (recommended)
 
-Install matchms from Anaconda Cloud with
+We recommend installing matchms from Anaconda Cloud with
 
 .. code-block:: console
 
@@ -110,6 +110,13 @@ Install matchms from Anaconda Cloud with
   conda activate matchms
   conda install --channel nlesc --channel bioconda --channel conda-forge matchms
 
+Alternatively, matchms can also be installed using ``pip`` but users will then either have to install ``rdkit`` on their own or won't be able to use the entire functionality. Without ``rdkit`` installed several filter functions related to processing and cleaning chemical metadata will not run.
+To install matchms with ``pip`` simply run
+
+.. code-block:: console
+
+  pip install matchms
+
 Example
 =======
 
diff --git a/conda/environment-dev.yml b/conda/environment-dev.yml
index c3714c21..4c040b07 100644
--- a/conda/environment-dev.yml
+++ b/conda/environment-dev.yml
@@ -15,17 +15,4 @@ dependencies:
   - rdkit >=2020.03.1
   - scipy
   - pip:
-    - bump2version
-    - deprecated
-    - isort>=4.2.5,<5
-    - prospector[with_pyroma]
-    - pycodestyle>=2.0.0,<=2.4.0
-    - pytest
-    - pytest-cov
-    - pytest-runner
-    - recommonmark
-    - ruamel.yaml
-    - sphinx>=3.0.0,!=3.2.0,<4.0.0
-    - sphinx_rtd_theme
-    - sphinxcontrib-apidoc
-    - yapf
+    - -e ..[dev]
diff --git a/conda/meta.yaml b/conda/meta.yaml
index 0a28ef31..fd329c5d 100644
--- a/conda/meta.yaml
+++ b/conda/meta.yaml
@@ -64,3 +64,4 @@ about:
 extra:
   recipe-maintainers:
     - fdiblen
+    - florian-huber
diff --git a/matchms/utils.py b/matchms/utils.py
index a7679665..d1fe939f 100644
--- a/matchms/utils.py
+++ b/matchms/utils.py
@@ -1,8 +1,26 @@
 import re
 from typing import Optional
 import numpy
-from rdkit import Chem
-from rdkit.Chem import AllChem
+
+
+try:  # rdkit is not included in pip package
+    from rdkit import Chem
+    from rdkit.Chem import AllChem
+except ImportError:
+    _has_rdkit = False
+    from collections import UserString
+
+    class ChemMock(UserString):
+        def __call__(self, *args, **kwargs):
+            return self
+
+        def __getattr__(self, key):
+            return self
+
+    Chem = AllChem = ChemMock("")
+else:
+    _has_rdkit = True
+rdkit_missing_message = "Conda package 'rdkit' is required for this functionality."
 
 
 def convert_smiles_to_inchi(smiles: str) -> Optional[str]:
@@ -21,6 +39,7 @@ def mol_converter(mol_input: str, input_type: str, output_type: str) -> Optional
     """Convert molecular representations using rdkit.
 
     Convert from "smiles" or "inchi" to "inchi", "smiles", or "inchikey".
+    Requires conda package *rdkit* to be installed.
 
     Parameters
     ----------
@@ -36,6 +55,8 @@ def mol_converter(mol_input: str, input_type: str, output_type: str) -> Optional
 
     Mol string in output type or None when conversion failure occurs.
     """
+    if not _has_rdkit:
+        raise ImportError(rdkit_missing_message)
     input_function = {"inchi": Chem.MolFromInchi,
                       "smiles": Chem.MolFromSmiles}
     output_function = {"inchi": Chem.MolToInchi,
@@ -56,12 +77,16 @@ def is_valid_inchi(inchi: str) -> bool:
     """Return True if input string is valid InChI.
 
     This functions test if string can be read by rdkit as InChI.
+    Requires conda package *rdkit* to be installed.
 
     Parameters
     ----------
     inchi
         Input string to test if it has format of InChI.
     """
+    if not _has_rdkit:
+        raise ImportError(rdkit_missing_message)
+
     # First quick test to avoid excess in-depth testing
     if inchi is None:
         return False
@@ -80,12 +105,16 @@ def is_valid_smiles(smiles: str) -> bool:
     """Return True if input string is valid smiles.
 
     This functions test if string can be read by rdkit as smiles.
+    Requires conda package *rdkit* to be installed.
 
     Parameters
     ----------
     smiles
         Input string to test if it can be imported as smiles.
     """
+    if not _has_rdkit:
+        raise ImportError(rdkit_missing_message)
+
     if smiles is None:
         return False
 
@@ -118,6 +147,7 @@ def is_valid_inchikey(inchikey: str) -> bool:
 
 def derive_fingerprint_from_smiles(smiles: str, fingerprint_type: str, nbits: int) -> numpy.ndarray:
     """Calculate molecule fingerprint based on given smiles or inchi (using rdkit).
+    Requires conda package *rdkit* to be installed.
 
     Parameters
     ----------
@@ -134,6 +164,9 @@ def derive_fingerprint_from_smiles(smiles: str, fingerprint_type: str, nbits: in
     fingerprint
         Molecular fingerprint.
     """
+    if not _has_rdkit:
+        raise ImportError(rdkit_missing_message)
+
     mol = Chem.MolFromSmiles(smiles)
     if mol is None:
         return None
@@ -142,6 +175,7 @@ def derive_fingerprint_from_smiles(smiles: str, fingerprint_type: str, nbits: in
 
 def derive_fingerprint_from_inchi(inchi: str, fingerprint_type: str, nbits: int) -> numpy.ndarray:
     """Calculate molecule fingerprint based on given inchi (using rdkit).
+    Requires conda package *rdkit* to be installed.
 
     Parameters
     ----------
@@ -158,6 +192,9 @@ def derive_fingerprint_from_inchi(inchi: str, fingerprint_type: str, nbits: int)
     fingerprint: numpy.array
         Molecular fingerprint.
     """
+    if not _has_rdkit:
+        raise ImportError(rdkit_missing_message)
+
     mol = Chem.MolFromInchi(inchi)
     if mol is None:
         return None
@@ -166,6 +203,7 @@ def derive_fingerprint_from_inchi(inchi: str, fingerprint_type: str, nbits: int)
 
 def mol_to_fingerprint(mol: Chem.rdchem.Mol, fingerprint_type: str, nbits: int) -> numpy.ndarray:
     """Convert rdkit mol (molecule) to molecular fingerprint.
+    Requires conda package *rdkit* to be installed.
 
     Parameters
     ----------
@@ -182,6 +220,9 @@ def mol_to_fingerprint(mol: Chem.rdchem.Mol, fingerprint_type: str, nbits: int)
     fingerprint
         Molecular fingerprint.
     """
+    if not _has_rdkit:
+        raise ImportError(rdkit_missing_message)
+
     assert fingerprint_type in ["daylight", "morgan1", "morgan2", "morgan3"], "Unkown fingerprint type given."
 
     if fingerprint_type == "daylight":
diff --git a/setup.py b/setup.py
index a4c959b4..bc88db46 100644
--- a/setup.py
+++ b/setup.py
@@ -43,15 +43,27 @@ setup(
         "Programming Language :: Python :: 3.8"
     ],
     test_suite="tests",
+    python_requires='>=3.7',
     install_requires=[
-        # see conda/environment.yml
+        "deprecated",
+        "lxml",
+        "matplotlib",
+        "numba >=0.47",
+        "numpy",
+        "pyteomics >=4.2",
+        "pyyaml",
+        "requests",
+        "scipy",
     ],
-    setup_requires=[
-    ],
-    tests_require=[
-        # see conda/environment-dev.yml
-    ],
-    extras_require={
-    },
+    extras_require={"dev": ["bump2version",
+                            "isort>=4.2.5,<5",
+                            "prospector[with_pyroma]",
+                            "pytest",
+                            "pytest-cov",
+                            "sphinx>=3.0.0,!=3.2.0,<4.0.0",
+                            "sphinx_rtd_theme",
+                            "sphinxcontrib-apidoc",
+                            "yapf",],
+                    "chemistry": ["rdkit >=2020.03.1"]},
     package_data={"matchms": ["data/*.yaml"]},
 )


Test patch:
diff --git a/tests/test_add_fingerprint.py b/tests/test_add_fingerprint.py
index 2a25ab68..8f288d8f 100644
--- a/tests/test_add_fingerprint.py
+++ b/tests/test_add_fingerprint.py
@@ -1,10 +1,12 @@
 import numpy
+import pytest
 from matchms import Spectrum
 from matchms.filtering import add_fingerprint
 
 
 def test_add_fingerprint_from_smiles():
     """Test if fingerprint it generated correctly."""
+    pytest.importorskip("rdkit")
     spectrum_in = Spectrum(mz=numpy.array([], dtype="float"),
                            intensities=numpy.array([], dtype="float"),
                            metadata={"smiles": "[C+]#C[O-]"})
@@ -16,6 +18,7 @@ def test_add_fingerprint_from_smiles():
 
 def test_add_fingerprint_from_inchi():
     """Test if fingerprint it generated correctly."""
+    pytest.importorskip("rdkit")
     spectrum_in = Spectrum(mz=numpy.array([], dtype="float"),
                            intensities=numpy.array([], dtype="float"),
                            metadata={"inchi": "InChI=1S/C2O/c1-2-3"})
diff --git a/tests/test_derive_inchi_from_inchikey.py b/tests/test_derive_inchi_from_inchikey.py
index 1c9f73c2..b605b106 100644
--- a/tests/test_derive_inchi_from_inchikey.py
+++ b/tests/test_derive_inchi_from_inchikey.py
@@ -1,10 +1,12 @@
 import numpy
+import pytest
 from matchms import Spectrum
 from matchms.filtering import derive_inchikey_from_inchi
 
 
 def test_derive_inchikey_from_inchi():
     """Test if conversion from inchi and inchikey works."""
+    pytest.importorskip("rdkit")
     spectrum_in = Spectrum(mz=numpy.array([], dtype='float'),
                            intensities=numpy.array([], dtype='float'),
                            metadata={"inchi": '"InChI=1S/C6H12/c1-2-4-6-5-3-1/h1-6H2"',
diff --git a/tests/test_derive_inchi_from_smiles.py b/tests/test_derive_inchi_from_smiles.py
index 339eae3b..7fd21cb0 100644
--- a/tests/test_derive_inchi_from_smiles.py
+++ b/tests/test_derive_inchi_from_smiles.py
@@ -1,4 +1,5 @@
 import numpy
+import pytest
 from matchms import Spectrum
 from matchms.filtering import derive_inchi_from_smiles
 
@@ -6,6 +7,7 @@ from matchms.filtering import derive_inchi_from_smiles
 def test_derive_inchi_from_smiles():
     """Test if conversion to inchi works when only smiles is given.
     """
+    pytest.importorskip("rdkit")
     spectrum_in = Spectrum(mz=numpy.array([], dtype='float'),
                            intensities=numpy.array([], dtype='float'),
                            metadata={"smiles": "C1CCCCC1"})
diff --git a/tests/test_derive_smiles_from_inchi.py b/tests/test_derive_smiles_from_inchi.py
index ee4ab5fc..f4f81d87 100644
--- a/tests/test_derive_smiles_from_inchi.py
+++ b/tests/test_derive_smiles_from_inchi.py
@@ -1,4 +1,5 @@
 import numpy
+import pytest
 from matchms import Spectrum
 from matchms.filtering import derive_smiles_from_inchi
 
@@ -6,6 +7,7 @@ from matchms.filtering import derive_smiles_from_inchi
 def test_derive_smiles_from_inchi():
     """Test if conversion to smiles works when only inchi is given.
     """
+    pytest.importorskip("rdkit")
     spectrum_in = Spectrum(mz=numpy.array([], dtype='float'),
                            intensities=numpy.array([], dtype='float'),
                            metadata={"inchi": '"InChI=1S/C6H12/c1-2-4-6-5-3-1/h1-6H2"',
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 25839ec0..4ef47eb3 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,4 +1,12 @@
+# pylint: disable=wildcard-import, method-hidden
+import sys
+from contextlib import nullcontext
+from importlib import reload
+from importlib.util import find_spec
+from unittest import mock
 import numpy
+import pytest
+import matchms.utils
 from matchms.utils import derive_fingerprint_from_inchi
 from matchms.utils import derive_fingerprint_from_smiles
 from matchms.utils import is_valid_inchi
@@ -9,6 +17,8 @@ from matchms.utils import mol_converter
 
 def test_mol_converter_smiles_to_inchi():
     """Test if smiles is correctly converted to inchi."""
+    pytest.importorskip("rdkit")
+
     mol_input = "C[Si](Cn1cncn1)(c1ccc(F)cc1)"
     output_inchi = mol_converter(mol_input, "smiles", "inchi")
     assert output_inchi == "InChI=1S/C10H11FN3Si/c1-15(8-14-7-12-6-13-14)10-4-2-9(11)3-5-10/h2-7H,8H2,1H3"
@@ -16,6 +26,8 @@ def test_mol_converter_smiles_to_inchi():
 
 def test_mol_converter_inchi_to_smiles():
     """Test if inchi is correctly converted to smiles."""
+    pytest.importorskip("rdkit")
+
     mol_input = "InChI=1S/C10H11FN3Si/c1-15(8-14-7-12-6-13-14)10-4-2-9(11)3-5-10/h2-7H,8H2,1H3"
     output_smiles = mol_converter(mol_input, "inchi", "smiles")
     assert output_smiles == "C[Si](Cn1cncn1)=C1C=C[C](F)C=C1"
@@ -23,6 +35,8 @@ def test_mol_converter_inchi_to_smiles():
 
 def test_mol_converter_smiles_to_inchikey():
     """Test if smiles is correctly converted to inchikey."""
+    pytest.importorskip("rdkit")
+
     mol_input = "C[Si](Cn1cncn1)(c1ccc(F)cc1)"
     output_inchikey = mol_converter(mol_input, "smiles", "inchikey")
     assert output_inchikey == "HULABQRTZQYJBQ-UHFFFAOYSA-N"
@@ -30,6 +44,8 @@ def test_mol_converter_smiles_to_inchikey():
 
 def test_mol_converter_invalid_input():
     """Test invalid entry."""
+    pytest.importorskip("rdkit")
+
     assert mol_converter("invalid_test", "smiles", "inchikey") is None, "Expected None."
 
 
@@ -59,6 +75,8 @@ def test_is_valid_inchikey_none_input():
 
 def test_is_valid_inchi():
     """Test if strings are correctly classified."""
+    pytest.importorskip("rdkit")
+
     inchi_true = [
         "InChI=1S/C2H7N3/c1-5-2(3)4/h1H3,(H4,3,4,5)",
         '"InChI=1S/C2H7N3/c1-5-2(3)4/h1H3,(H4,3,4,5)"'
@@ -78,11 +96,15 @@ def test_is_valid_inchi():
 
 def test_is_valid_inchi_none_input():
     """Test None entry."""
+    pytest.importorskip("rdkit")
+
     assert not is_valid_inchi(None), "Expected None entry to give False."
 
 
 def test_is_valid_smiles():
     """Test if strings are correctly classified."""
+    pytest.importorskip("rdkit")
+
     smiles_true = [
         r"CN1COCN(CC2=CN=C(Cl)S2)\C1=N\[N+]([O-])=O",
         r"CN1N(C(=O)C=C1C)c1ccccc1",
@@ -103,11 +125,15 @@ def test_is_valid_smiles():
 
 def test_is_valid_smiles_none_input():
     """Test None entry."""
+    pytest.importorskip("rdkit")
+
     assert not is_valid_smiles(None), "Expected None entry to give False."
 
 
 def test_derive_fingerprint_from_smiles():
     """Test if correct fingerprint is derived from given smiles."""
+    pytest.importorskip("rdkit")
+
     fingerprint = derive_fingerprint_from_smiles("[C+]#C[O-]", "daylight", 16)
     expected_fingerprint = numpy.array([0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 0, 0, 0])
     assert numpy.all(fingerprint == expected_fingerprint), "Expected different fingerprint."
@@ -115,6 +141,8 @@ def test_derive_fingerprint_from_smiles():
 
 def test_derive_fingerprint_from_inchi():
     """Test if correct fingerprint is derived from given inchi."""
+    pytest.importorskip("rdkit")
+
     fingerprint = derive_fingerprint_from_inchi("InChI=1S/C2O/c1-2-3", "daylight", 16)
     expected_fingerprint = numpy.array([0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 0, 0, 0])
     assert numpy.all(fingerprint == expected_fingerprint), "Expected different fingerprint."
@@ -122,6 +150,8 @@ def test_derive_fingerprint_from_inchi():
 
 def test_derive_fingerprint_different_types_from_smiles():
     """Test if correct fingerprints are derived from given smiles when using different types."""
+    pytest.importorskip("rdkit")
+
     types = ["daylight", "morgan1", "morgan2", "morgan3"]
     expected_fingerprints = [
         numpy.array([0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 0, 0, 0]),
@@ -133,3 +163,39 @@ def test_derive_fingerprint_different_types_from_smiles():
     for i, fingerprint_type in enumerate(types):
         fingerprint = derive_fingerprint_from_smiles("[C+]#C[O-]", fingerprint_type, 16)
         assert numpy.all(fingerprint == expected_fingerprints[i]), "Expected different fingerprint."
+
+
+def test_missing_rdkit_module_error():
+    """Test if different functions return correct error when *rdkit* is not available"""
+    if find_spec("rdkit") is not None:
+        context = mock.patch.dict(sys.modules, {"rdkit": None})
+    else:
+        context = nullcontext()
+
+    expected_msg = "Conda package 'rdkit' is required for this functionality."
+    with context:
+        reload(matchms.utils)
+        mol_input = "C[Si](Cn1cncn1)(c1ccc(F)cc1)"
+        with pytest.raises(ImportError) as msg:
+            _ = matchms.utils.mol_converter(mol_input, "smiles", "inchikey")
+        assert expected_msg in str(msg.value), "Expected different ImportError."
+
+        with pytest.raises(ImportError) as msg:
+            _ = matchms.utils.is_valid_inchi("test")
+        assert expected_msg in str(msg.value), "Expected different ImportError."
+
+        with pytest.raises(ImportError) as msg:
+            _ = matchms.utils.is_valid_smiles("test")
+        assert expected_msg in str(msg.value), "Expected different ImportError."
+
+        with pytest.raises(ImportError) as msg:
+            _ = matchms.utils.derive_fingerprint_from_inchi(mol_input, "test", 0)
+        assert expected_msg in str(msg.value), "Expected different ImportError."
+
+        with pytest.raises(ImportError) as msg:
+            _ = matchms.utils.derive_fingerprint_from_smiles(mol_input, "test", 0)
+        assert expected_msg in str(msg.value), "Expected different ImportError."
+
+        with pytest.raises(ImportError) as msg:
+            _ = matchms.utils.mol_to_fingerprint(mol_input, "test", 0)
+        assert expected_msg in str(msg.value), "Expected different ImportError."