Repository: matchms/matchms (python)

Issue:
Would be nice to have a save_as_msp module
Hi All, Hi @florian-huber 

matchms is a really nice tools for the community, Thanks.
I would like to know if you plan to create a save_as_msp module?

I'm asking because I'm using NIST database a lot and it would be nice to be able to create MSP database from GNPS/MSHUB MGF outputs.



Best
Yann


Pull request description:
Added save_as_msp function.
Implemented save_as_msp function and test cases for both msp files contained in test data.

Closes #166 

Patch:
diff --git a/.github/workflows/CI_build.yml b/.github/workflows/CI_build.yml
index 560f56f3..5da54c30 100644
--- a/.github/workflows/CI_build.yml
+++ b/.github/workflows/CI_build.yml
@@ -257,16 +257,11 @@ jobs:
         run: |
           export BUILDDIR=$RUNNER_TEMP/matchms/_build/noarch/
           [ "$RUNNING_OS" = "Windows" ] && export BUILDDIR=$RUNNER_TEMP\\matchms\\_build\\noarch\\
-          conda install \
-            --channel bioconda \
-            --channel conda-forge \
-            --channel nlesc \
-            matchms
           # Install matchms without nlesc channel to prevent package to be installed from nlesc channel instead of wanted $BUILDDIR channel
           conda install \
-            --channel bioconda \
+            --channel $BUILDDIR \
             --channel conda-forge \
-            --channel $BUILDDIR -v \
+            --channel bioconda -v \
             matchms
       - name: List conda packages
         shell: bash -l {0}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d42aaf67..59a1d1ee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+### Added
+
+- `save_as_msp()` function to export spectrums to .msp file [#215](https://github.com/matchms/matchms/pull/215)
+
 ## [0.8.2] - 2021-03-08
 
 ### Added
diff --git a/conda/environment-dev.yml b/conda/environment-dev.yml
index 4f1fa6f0..69db070c 100644
--- a/conda/environment-dev.yml
+++ b/conda/environment-dev.yml
@@ -14,4 +14,14 @@ dependencies:
   - rdkit >=2020.03.1
   - scipy >=1.4.0
   - pip:
-    - -e ..[dev]
+    - bump2version
+    - deprecated
+    - lxml
+    - isort>=4.2.5,<5
+    - prospector[with_pyroma]
+    - pytest
+    - pytest-cov
+    - sphinx>=3.0.0,!=3.2.0,!=3.5.0,<4.0.0
+    - sphinx_rtd_theme
+    - sphinxcontrib-apidoc
+    - yapf
diff --git a/matchms/exporting/__init__.py b/matchms/exporting/__init__.py
index cd91b5dc..4de3e38f 100644
--- a/matchms/exporting/__init__.py
+++ b/matchms/exporting/__init__.py
@@ -3,13 +3,15 @@ Functions for exporting mass spectral data
 ##########################################
 
 Individual :meth:`~matchms.Spectrum`, or lists of :meth:`~matchms.Spectrum`
-can be exported to json or mgf files.
+can be exported to json, mgf, or msp files.
 """
 from .save_as_json import save_as_json
 from .save_as_mgf import save_as_mgf
+from .save_as_msp import save_as_msp
 
 
 __all__ = [
     "save_as_json",
     "save_as_mgf",
+    "save_as_msp",
 ]
diff --git a/matchms/exporting/save_as_msp.py b/matchms/exporting/save_as_msp.py
new file mode 100644
index 00000000..a440705e
--- /dev/null
+++ b/matchms/exporting/save_as_msp.py
@@ -0,0 +1,68 @@
+import os
+from typing import IO
+from typing import List
+from ..Spectrum import Spectrum
+from ..Spikes import Spikes
+
+
+def save_as_msp(spectra: List[Spectrum], filename: str):
+    """Save spectrum(s) as msp file.
+
+    :py:attr:`~matchms.Spectrum.losses` of spectrum will not be saved.
+
+    Example:
+
+    .. code-block:: python
+
+        import numpy
+        from matchms import Spectrum
+        from matchms.exporting import save_as_msp
+
+        # Create dummy spectrum
+        spectrum = Spectrum(mz=numpy.array([100, 200, 300], dtype="float"),
+                            intensities=numpy.array([10, 10, 500], dtype="float"),
+                            metadata={"charge": -1,
+                                      "inchi": '"InChI=1S/C6H12"',
+                                      "precursor_mz": 222.2})
+
+        # Write spectrum to test file
+        save_as_msp(spectrum, "test.msp")
+
+    Parameters
+    ----------
+    spectra:
+        Expected input are match.Spectrum.Spectrum() objects.
+    filename:
+        Provide filename to save spectrum(s).
+    """
+
+    assert filename.endswith('.msp'), "File extension must be 'msp'."
+
+    spectra = ensure_list(spectra)
+
+    with open(filename, 'w') as outfile:
+        for spectrum in spectra:
+            write_spectrum(spectrum, outfile)
+
+
+def write_spectrum(spectrum: Spectrum, outfile: IO):
+    write_metadata(spectrum.metadata, outfile)
+    write_peaks(spectrum.peaks, outfile)
+    outfile.write(os.linesep)
+
+
+def write_peaks(peaks: Spikes, outfile: IO):
+    for mz, intensity in zip(peaks.mz, peaks.intensities):
+        outfile.write('%s\t%s\n' % (str(mz), str(intensity)))
+
+
+def write_metadata(metadata: dict, outfile: IO):
+    for key, value in metadata.items():
+        outfile.write('%s: %s\n' % (key.upper(), str(value)))
+
+
+def ensure_list(spectra) -> List[Spectrum]:
+    if not isinstance(spectra, list):
+        # Assume that input was single Spectrum
+        spectra = [spectra]
+    return spectra


Test patch:
diff --git a/tests/test_save_as_msp.py b/tests/test_save_as_msp.py
new file mode 100644
index 00000000..129b6c40
--- /dev/null
+++ b/tests/test_save_as_msp.py
@@ -0,0 +1,108 @@
+import os
+import tempfile
+from typing import List
+import numpy
+import pytest
+from matchms import Spectrum
+from matchms.exporting import save_as_msp
+from matchms.importing import load_from_msp
+
+
+@pytest.fixture
+def none_spectrum():
+    return None
+
+
+@pytest.fixture
+def spectrum():
+    return Spectrum(mz=numpy.array([100, 200, 290, 490, 510], dtype="float"),
+                    intensities=numpy.array([0.1, 0.2, 1.0, 0.3, 0.4], dtype="float"))
+
+
+@pytest.fixture(params=["rcx_gc-ei_ms_20201028_perylene.msp", "MoNA-export-GC-MS-first10.msp"])
+def data(request):
+    module_root = os.path.join(os.path.dirname(__file__), "..")
+    spectrums_file = os.path.join(module_root, "tests", request.param)
+    spectra = load_from_msp(spectrums_file)
+    return list(spectra)
+
+
+@pytest.yield_fixture
+def filename():
+    with tempfile.TemporaryDirectory() as temp_dir:
+        filename = os.path.join(temp_dir, "test.msp")
+        yield filename
+
+
+def test_spectrum_none_exception(none_spectrum, filename):
+    """ Test for exception being thrown if the spectrum to be saved. """
+    with pytest.raises(AttributeError) as exception:
+        save_as_msp(none_spectrum, filename)
+
+    message = exception.value.args[0]
+    assert message == "'NoneType' object has no attribute 'metadata'"
+
+
+def test_wrong_filename_exception():
+    """ Test for exception being thrown if output file doesn't end with .msp. """
+    with tempfile.TemporaryDirectory() as temp_dir:
+        filename = os.path.join(temp_dir, "test.mzml")
+
+        with pytest.raises(AssertionError) as exception:
+            save_as_msp(None, filename)
+
+        message = exception.value.args[0]
+        assert message == "File extension must be 'msp'."
+
+
+# Using tmp_path fixture from pytest: https://docs.pytest.org/en/stable/tmpdir.html#the-tmp-path-fixture
+def test_file_exists_single_spectrum(spectrum, filename):
+    """ Test checking if the file is created. """
+    save_as_msp(spectrum, filename)
+
+    assert os.path.isfile(filename)
+
+
+def test_stores_all_spectra(filename, data):
+    """ Test checking if all spectra contained in the original file are stored
+    and loaded back in properly. """
+    spectra = save_and_reload_spectra(filename, data)
+
+    assert len(spectra) == len(data)
+
+
+def test_have_metadata(filename, data):
+    """ Test checking of all metadate is stored correctly. """
+    spectra = save_and_reload_spectra(filename, data)
+
+    assert len(spectra) == len(data)
+
+    for actual, expected in zip(spectra, data):
+        assert actual.metadata == expected.metadata
+
+
+def test_have_peaks(filename, data):
+    """ Test checking if all peaks are stored correctly. """
+    spectra = save_and_reload_spectra(filename, data)
+
+    assert len(spectra) == len(data)
+
+    for actual, expected in zip(spectra, data):
+        assert actual.peaks == expected.peaks
+
+
+def save_and_reload_spectra(filename, spectra: List[Spectrum]):
+    """ Utility function to save spectra to msp and load them again.
+
+    Params:
+    -------
+    spectra: Spectra objects to store
+
+    Returns:
+    --------
+    reloaded_spectra: Spectra loaded from saved msp file.
+    """
+
+    save_as_msp(spectra, filename)
+    reloaded_spectra = list(load_from_msp(filename))
+    return reloaded_spectra