Repository: matchms/matchms
Pull request #557 (2023-11-14 07:53:49)

add export_style parameter
Work on #556.

- [x] `save_as_mgf` supports `export_style` parameter
- [x] tests for `save_as_mgf` using other than the default `export_style` parameter (e.g. `gnps`).
- [x] `save_as_json` supports `export_style` parameter
- [x] tests for `save_as_jsonf` using other than the default `export_style` parameter (e.g. `gnps`).

The exporting module could need some refactoring. The focus of this PR is mostly to have a functioning export style option in all export formats.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d4218b17..339616cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,9 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [unreleased]
 ### Added
-- added option to set custom key replacements [#547](https://github.com/matchms/matchms/pull/547)
+- Option to set custom key replacements [#547](https://github.com/matchms/matchms/pull/547)
+- Option to set the export style in `save_as_mgf` and `save_as_json` to choose other than matchms styles such as `nist`, `riken`, `gnps` [#557](https://github.com/matchms/matchms/pull/557)
+
 ### Fixed
-- handle missing `precursor_mz` in representation and [#452](https://github.com/matchms/matchms/issues/452) introduced by [#514](https://github.com/matchms/matchms/pull/514/files)[#540](https://github.com/matchms/matchms/pull/540)
+- Handle missing `precursor_mz` in representation and [#452](https://github.com/matchms/matchms/issues/452) introduced by [#514](https://github.com/matchms/matchms/pull/514/files)[#540](https://github.com/matchms/matchms/pull/540)
 
 ## [0.23.1] - 2023-10-18
 ### Added
diff --git a/matchms/Scores.py b/matchms/Scores.py
index 274983f9..6c84e44e 100644
--- a/matchms/Scores.py
+++ b/matchms/Scores.py
@@ -1,11 +1,11 @@
 from __future__ import annotations
+import copy
 import json
 import pickle
 import numpy as np
 from numpy.lib.recfunctions import unstructured_to_structured
 from scipy.sparse import coo_matrix
 from sparsestack import StackedSparseArray
-from matchms.exporting.save_as_json import ScoresJSONEncoder
 from matchms.importing.load_from_json import scores_json_decoder
 from matchms.similarity import get_similarity_function_by_name
 from matchms.similarity.BaseSimilarity import BaseSimilarity
@@ -475,3 +475,14 @@ class ScoresBuilder:
                              Make sure the file contains the following keys:\n\
                              ['__Scores__', 'is_symmetric', 'references', 'queries', 'scores_row',\
                              'scores_col', 'scores_data', 'scores_dtype']")
+
+
+class ScoresJSONEncoder(json.JSONEncoder):
+    def default(self, o):
+        """JSON Encoder for a matchms.Scores.Scores object"""
+        class_name = o.__class__.__name__
+        # do isinstance(o, Scores) without importing matchms.Scores
+        if class_name == "Scores":
+            scores = copy.deepcopy(o)
+            return scores.to_dict()
+        return json.JSONEncoder.default(self, o)
diff --git a/matchms/Spectrum.py b/matchms/Spectrum.py
index ef20081f..7d2bba6e 100644
--- a/matchms/Spectrum.py
+++ b/matchms/Spectrum.py
@@ -187,17 +187,31 @@ class Spectrum:
         self._metadata.set(key, value)
         return self
 
-    def to_dict(self) -> dict:
-        """Return a dictionary representation of a spectrum."""
+    def to_dict(self, export_style: str = "matchms") -> dict:
+        """Return a dictionary representation of a spectrum.
+
+        Parameters
+        ----------
+        export_style:
+            Converts the keys to the required export style. One of ["matchms", "massbank", "nist", "riken", "gnps"].
+            Default is "matchms"
+        """
         peaks_list = np.vstack((self.peaks.mz, self.peaks.intensities)).T.tolist()
-        spectrum_dict = dict(self.metadata.items())
+        spectrum_dict = self.metadata_dict(export_style)  # dict(self.metadata.items())
         spectrum_dict["peaks_json"] = peaks_list
         if "fingerprint" in spectrum_dict:
             spectrum_dict["fingerprint"] = spectrum_dict["fingerprint"].tolist()
         return spectrum_dict
 
     def metadata_dict(self, export_style: str = "matchms") -> dict:
-        """Convert spectrum metadata to Python dictionary."""
+        """Convert spectrum metadata to Python dictionary.
+
+        Parameters
+        ----------
+        export_style:
+            Converts the keys to the required export style. One of ["matchms", "massbank", "nist", "riken", "gnps"].
+            Default is "matchms"
+        """
         return self._metadata.to_dict(export_style)
 
     @property
diff --git a/matchms/exporting/save_as_json.py b/matchms/exporting/save_as_json.py
index ccf5dc89..8d9ec448 100644
--- a/matchms/exporting/save_as_json.py
+++ b/matchms/exporting/save_as_json.py
@@ -1,11 +1,12 @@
-import copy
 import json
 from typing import List
 from ..Spectrum import Spectrum
 from ..utils import fingerprint_export_warning
 
 
-def save_as_json(spectrums: List[Spectrum], filename: str):
+def save_as_json(spectrums: List[Spectrum],
+                 filename: str,
+                 export_style: str = "matchms"):
     """Save spectrum(s) as json file.
 
     :py:attr:`~matchms.Spectrum.losses` of spectrum will not be saved.
@@ -34,6 +35,9 @@ def save_as_json(spectrums: List[Spectrum], filename: str):
         Expected input is a list of  :py:class:`~matchms.Spectrum.Spectrum` objects.
     filename:
         Provide filename to save spectrum(s).
+    export_style:
+        Converts the keys to the required export style. One of ["matchms", "massbank", "nist", "riken", "gnps"].
+        Default is "matchms"
     """
     if not isinstance(spectrums, list):
         # Assume that input was single Spectrum
@@ -42,28 +46,18 @@ def save_as_json(spectrums: List[Spectrum], filename: str):
     fingerprint_export_warning(spectrums)
 
     # Write to json file
+    encoder_class = create_spectrum_json_encoder(export_style)
     with open(filename, "w", encoding="utf-8") as fout:
-        json.dump(spectrums, fout, cls=SpectrumJSONEncoder)
-
-
-class SpectrumJSONEncoder(json.JSONEncoder):
-    # See https://github.com/PyCQA/pylint/issues/414 for reference
-    def default(self, o):
-        """JSON Encoder which can encode a :py:class:`~matchms.Spectrum.Spectrum` object"""
-        if isinstance(o, Spectrum):
-            spec = o.clone().to_dict()
-            if "fingerprint" in spec.keys():
-                del spec["fingerprint"]
-            return spec
-        return json.JSONEncoder.default(self, o)
-
-
-class ScoresJSONEncoder(json.JSONEncoder):
-    def default(self, o):
-        """JSON Encoder which can encode a :py:class:`~matchms.Scores.Scores` object"""
-        class_name = o.__class__.__name__
-        # do isinstance(o, Scores) without importing matchms.Scores
-        if class_name == "Scores":
-            scores = copy.deepcopy(o)
-            return scores.to_dict()
-        return json.JSONEncoder.default(self, o)
+        json.dump(spectrums, fout, cls=encoder_class)
+
+
+def create_spectrum_json_encoder(export_style):
+    class CustomSpectrumJSONEncoder(json.JSONEncoder):
+        def default(self, o):
+            """JSON Encoder for a matchms.Spectrum.Spectrum object"""
+            if isinstance(o, Spectrum):
+                spec = o.clone().to_dict(export_style)
+                spec.pop("fingerprint", None)
+                return spec
+            return super().default(o)
+    return CustomSpectrumJSONEncoder
diff --git a/matchms/exporting/save_as_mgf.py b/matchms/exporting/save_as_mgf.py
index 0b6eda4c..4e1e5257 100644
--- a/matchms/exporting/save_as_mgf.py
+++ b/matchms/exporting/save_as_mgf.py
@@ -4,7 +4,9 @@ from ..Spectrum import Spectrum
 from ..utils import fingerprint_export_warning
 
 
-def save_as_mgf(spectrums: List[Spectrum], filename: str):
+def save_as_mgf(spectrums: List[Spectrum],
+                filename: str,
+                export_style: str = "matchms"):
     """Save spectrum(s) as mgf file.
 
     :py:attr:`~matchms.Spectrum.losses` of spectrum will not be saved.
@@ -33,6 +35,9 @@ def save_as_mgf(spectrums: List[Spectrum], filename: str):
         Expected input are match.Spectrum.Spectrum() objects.
     filename:
         Provide filename to save spectrum(s).
+    export_style:
+        Converts the keys to the required export style. One of ["matchms", "massbank", "nist", "riken", "gnps"].
+        Default is "matchms"
     """
     if not isinstance(spectrums, list):
         # Assume that input was single Spectrum
@@ -44,7 +49,7 @@ def save_as_mgf(spectrums: List[Spectrum], filename: str):
     for spectrum in spectrums:
         spectrum_dict = {"m/z array": spectrum.peaks.mz,
                          "intensity array": spectrum.peaks.intensities,
-                         "params": spectrum.metadata}
+                         "params": spectrum.metadata_dict(export_style)}
         if 'fingerprint' in spectrum_dict["params"]:
             del spectrum_dict["params"]["fingerprint"]
         # Append spectrum to file


Test patch:
diff --git a/tests/exporting/test_save_as_json_load_from_json.py b/tests/exporting/test_save_as_json_load_from_json.py
index cdf31c42..7392083b 100644
--- a/tests/exporting/test_save_as_json_load_from_json.py
+++ b/tests/exporting/test_save_as_json_load_from_json.py
@@ -60,6 +60,17 @@ def test_save_and_load_json_spectrum_list(metadata_harmonization, tmp_path, buil
     assert spectrum_imports[1] == spectrum2, "Original and saved+loaded spectrum not identical"
 
 
+@pytest.mark.parametrize("style, expected",
+                         [("matchms", "precursor_mz"),
+                          ("nist", "PrecursorMZ")])
+def test_save_as_json_different_export_styles(tmp_path, builder, style, expected):
+    spectrum = builder.with_metadata({"precursor_mz": 123}).build()
+    filename = tmp_path / "test_matchms.json"
+    save_as_json([spectrum], str(filename), export_style=style)
+
+    with open(filename, "r", encoding="utf-8") as f:
+        data = json.load(f)
+        assert expected in data[0]
 
 
 def test_load_from_json_zero_peaks(tmp_path):
diff --git a/tests/exporting/test_save_as_mgf.py b/tests/exporting/test_save_as_mgf.py
index 5ad74700..a5b88ebf 100644
--- a/tests/exporting/test_save_as_mgf.py
+++ b/tests/exporting/test_save_as_mgf.py
@@ -68,6 +68,34 @@ def test_save_as_mgf_spectrum_list():
         assert mgf_content[8].split("=")[1] == "test2\n"
 
 
+@pytest.mark.parametrize("style, expected",
+                         [("matchms", ["PRECURSOR_MZ=100.1\n", "PRECURSOR_MZ=200.2\n"]),
+                          ("nist", ["PRECURSORMZ=100.1\n", "PRECURSORMZ=200.2\n"]),
+                          ("gnps", ["PEPMASS=100.1\n", "PEPMASS=200.2\n"]),
+                          ])
+def test_save_as_mgf_export_style(style, expected):
+    """Test saving spectrum list to .mgf file using differnt export styles.
+    """
+    mz = np.array([100, 200], dtype="float")
+    intensities = np.array([10, 500], dtype="float")
+    builder = SpectrumBuilder().with_mz(mz).with_intensities(intensities)
+    spectrum1 = builder.with_metadata({"precursor_mz": 100.1},
+                                      metadata_harmonization=False).build()
+    spectrum2 = builder.with_metadata({"precursor_mz": 200.2},
+                                      metadata_harmonization=False).build()
+
+    # Write to test file
+    with tempfile.TemporaryDirectory() as d:
+        filename = os.path.join(d, "test.mgf")
+        save_as_mgf([spectrum1, spectrum2], filename, export_style=style)
+
+        # Test if content of mgf file is correct
+        with open(filename, "r", encoding="utf-8") as f:
+            mgf_content = f.readlines()
+        assert mgf_content[1] == expected[0]
+        assert mgf_content[7] == expected[1]
+
+
 @pytest.mark.parametrize("charge, ionmode, parent_mass",
                          [(-1, "negative", 218.5),
                           (2, "positive", "n/a"),
diff --git a/tests/test_scores.py b/tests/test_scores.py
index 4deec710..91ef5c2b 100644
--- a/tests/test_scores.py
+++ b/tests/test_scores.py
@@ -167,6 +167,20 @@ def test_scores_next():
     assert actual == expected, "Expected different scores."
 
 
+def test_scores_to_dict():
+    """Test if export to Python dictionary works as intended"""
+    spectrum_1, spectrum_2, spectrum_3, _ = spectra()
+    spectrum_1.set("precursor_mz", 123.4)
+    references = [spectrum_1, spectrum_2]
+    queries = [spectrum_3]
+    scores = calculate_scores(references, queries, CosineGreedy())
+    scores_dict = scores.to_dict()
+    expected_dict = [{'id': 'spectrum1', 'precursor_mz': 123.4, 'peaks_json': [[100.0, 0.7], [150.0, 0.2], [200.0, 0.1]]},
+                     {'id': 'spectrum2', 'peaks_json': [[100.0, 0.4], [140.0, 0.2], [190.0, 0.1]]}]
+    assert len(scores_dict["references"]) == 2
+    assert scores_dict["references"] == expected_dict
+
+
 def test_scores_by_referencey():
     "Test scores_by_reference method."
     spectrum_1, spectrum_2, spectrum_3, spectrum_4 = spectra()
diff --git a/tests/test_spectrum.py b/tests/test_spectrum.py
index c7ffaf9b..b6874122 100644
--- a/tests/test_spectrum.py
+++ b/tests/test_spectrum.py
@@ -32,7 +32,7 @@ def _create_test_spectrum_with_intensities(intensities):
 def spectrum() -> Spectrum:
     mz = np.array([100.00003, 110.2, 200.581], dtype='float')
     intensities = np.array([0.51, 1.0, 0.011], dtype='float')
-    metadata = {"pepmass": (444.0, 11), "charge": -1}
+    metadata = {"precursor_mz": 444.0, "charge": -1}
     builder = SpectrumBuilder().with_mz(mz).with_intensities(intensities).with_metadata(metadata)
     return builder.build()
 
@@ -121,9 +121,29 @@ def test_comparing_spectra_with_arrays():
     assert spectrum0 != spectrum1, "Expected spectra to not be equal"
 
 
+def test_spectrum_to_dict(spectrum: Spectrum):
+    """Test if export to Python dictionary works as intended"""
+    spectrum_dict = spectrum.to_dict()
+    expected_dict = {
+        "charge": -1,
+        "peaks_json": [[100.00003, 0.51], [110.2, 1.0], [200.581, 0.011]],
+        "precursor_mz": 444.0}
+    assert spectrum_dict == expected_dict
+
+
+def test_spectrum_to_dict_matchms_style(spectrum: Spectrum):
+    """Test if export to Python dictionary works as intended"""
+    spectrum_dict = spectrum.to_dict(export_style="nist")
+    expected_dict = {
+        "Charge": -1,
+        "peaks_json": [[100.00003, 0.51], [110.2, 1.0], [200.581, 0.011]],
+        "PrecursorMZ": 444.0}
+    assert spectrum_dict == expected_dict
+
+
 def test_spectrum_hash(spectrum: Spectrum):
-    assert hash(spectrum) == 1516465757675504211, "Expected different hash."
-    assert spectrum.metadata_hash() == "92c0464af949ae56627f", \
+    assert hash(spectrum) == 382278160858921722, "Expected different hash."
+    assert spectrum.metadata_hash() == "78c223faa157cc130390", \
         "Expected different metadata hash."
     assert spectrum.spectrum_hash() == "c79de5a8b333f780c206", \
         "Expected different spectrum hash."
@@ -158,7 +178,7 @@ def test_spectrum_hash_intensity_sensitivity(spectrum: Spectrum):
 def test_spectrum_hash_metadata_sensitivity(spectrum: Spectrum):
     """Test is changes indeed lead to different hashes as expected."""
     spectrum2 = SpectrumBuilder().from_spectrum(spectrum).with_metadata(
-        {"pepmass": (444.1, 11), "charge": -1}).build()
+        {"precursor_mz": 444.1, "charge": -1}).build()
 
     assert hash(spectrum) != hash(spectrum2), "Expected hashes to be different."
     assert spectrum.metadata_hash() != spectrum2.metadata_hash(), \
@@ -170,14 +190,14 @@ def test_spectrum_hash_metadata_sensitivity(spectrum: Spectrum):
 @pytest.mark.parametrize("default_filtering", [True, False])
 def test_spectrum_clone(spectrum, default_filtering):
     spectrum = SpectrumBuilder().from_spectrum(spectrum).with_metadata(
-        {"pepmass": (444.1, 11), "TEST FIELD": "Some Text"},
+        {"precursor_mz": 444.1, "TEST FIELD": "Some Text"},
         metadata_harmonization=default_filtering).build()
     spectrum_clone = spectrum.clone()
 
     assert spectrum_clone == spectrum.clone(), "Spectra should be equal"
 
     # Check if no shallow copy was made
-    spectrum_clone.metadata = {"pepmass": (424.1, 11), "TEST FIELD": "Some Text"}
+    spectrum_clone.metadata = {"precursor_mz": 424.1, "TEST FIELD": "Some Text"}
     assert spectrum_clone != spectrum.clone(), "Only cloned spectrum should have changed"