Repository: levitsky/pyteomics

Issue:
proforma.set_unimod_path()
Hello,

If I undestand correctly from the documentation the method `proforma.set_unimod_path("path/to/unimod.xml")` shoud load a local copy of unimod to avoid download it every time. However, if I specify the path to the local copy I get `ValueError: unknown url type: 'unimod_tables.xml'`. If I specify instead the url to the unimod.xml on the unimod website it works properly.

Thanks for your help.
Alessandro

Discussion:

Patch:
diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml
index 9407f37..a04eeb6 100644
--- a/.github/workflows/pythonpackage.yml
+++ b/.github/workflows/pythonpackage.yml
@@ -35,3 +35,24 @@ jobs:
     - name: Run the tests
       run: |
         cd tests; find . -name 'test_*.py' -print0 | xargs -0 -n1 python
+
+  tests-multiplatform:
+    strategy:
+      matrix:
+        python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
+        os: ['macos-latest', 'windows-latest']
+
+    runs-on: ${{ matrix.os }}
+    steps:
+      - uses: actions/checkout@v4
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v5
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install .
+      - name: Run the tests
+        run: |
+          cd tests; python -m unittest test_util.UtilTest
diff --git a/CHANGELOG b/CHANGELOG
index c2a56c4..2bcac61 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -2,6 +2,7 @@
 -----
 
  - **Drop Python 2 support.** See `#167 <https://github.com/levitsky/pyteomics/discussions/167>`_ for the announcement.
+ - Ensure URL prefix when creating a :py:class:`mass.Unimod` object (`#169 <https://github.com/levitsky/pyteomics/pull/169>`_).
  - Improve :py:mod:`spectrum_utils` integration.
  - Retain StPeter output in :py:func:`pyteomics.protxml.DataFrame`.
  - Fix `#163 <https://github.com/levitsky/pyteomics/issues/163>`_
diff --git a/pyteomics/auxiliary/__init__.py b/pyteomics/auxiliary/__init__.py
index 14551fc..b4873c6 100644
--- a/pyteomics/auxiliary/__init__.py
+++ b/pyteomics/auxiliary/__init__.py
@@ -27,4 +27,4 @@ from .target_decoy import (
 
 from .utils import (
     print_tree, memoize, BinaryDataArrayTransformer, ArrayConversionMixin, BinaryArrayConversionMixin,
-    MaskedArrayConversionMixin, _decode_base64_data_array)
+    MaskedArrayConversionMixin, _decode_base64_data_array, ensure_url_prefix)
diff --git a/pyteomics/auxiliary/utils.py b/pyteomics/auxiliary/utils.py
index d6f865e..6eb9ff3 100644
--- a/pyteomics/auxiliary/utils.py
+++ b/pyteomics/auxiliary/utils.py
@@ -2,6 +2,8 @@ import base64
 import zlib
 from functools import wraps
 from collections import namedtuple
+from urllib.parse import urlparse
+import pathlib
 
 try:
     import numpy as np
@@ -280,7 +282,6 @@ if np is not None:
             array = self._transform_buffer(binary, dtype)
             return array
 
-
     class BinaryArrayConversionMixin(ArrayConversionMixin, BinaryDataArrayTransformer):
         def _finalize_record_conversion(self, array, record):
             key = record.key
@@ -308,3 +309,32 @@ def add_metaclass(metaclass):
             orig_vars['__qualname__'] = cls.__qualname__
         return metaclass(cls.__name__, cls.__bases__, orig_vars)
     return wrapper
+
+
+_supported_url_schemes = {'file', 'ftp', 'http', 'https'}
+
+
+def ensure_url_prefix(path_or_url):
+    """
+    Ensure that a string path has a URL schema prefix, add "file://" if it is missing.
+    Only expects a subset of URL schemes that can be urlopen-ed for reading.
+
+    Parameters
+    ----------
+    path_or_url : str or Path
+
+    Returns
+    -------
+    out : str
+    """
+    if isinstance(path_or_url, pathlib.Path):
+        path = path_or_url
+    else:
+        parsed = urlparse(path_or_url)
+        if parsed.scheme in _supported_url_schemes:
+            return path_or_url
+        if parsed.params or parsed.query or parsed.fragment:
+            return path_or_url
+        # for all other cases assume it's a local path
+        path = pathlib.Path(path_or_url)
+    return path.absolute().as_uri()
diff --git a/pyteomics/mass/mass.py b/pyteomics/mass/mass.py
index 01dca2b..af5053b 100644
--- a/pyteomics/mass/mass.py
+++ b/pyteomics/mass/mass.py
@@ -82,7 +82,7 @@ Data
 
 import math
 from .. import parser
-from ..auxiliary import PyteomicsError, _nist_mass, BasicComposition
+from ..auxiliary import PyteomicsError, _nist_mass, BasicComposition, ensure_url_prefix
 from itertools import chain, product, combinations_with_replacement
 from collections import defaultdict
 try:
@@ -93,6 +93,7 @@ from datetime import datetime
 import re
 import operator
 import warnings
+from pathlib import Path
 
 nist_mass = _nist_mass
 """
@@ -1049,6 +1050,9 @@ def fast_mass2(sequence, ion_type=None, charge=None, **kwargs):
     return mass
 
 
+UNIMOD_DEFAULT_URL = 'http://www.unimod.org/xml/unimod.xml'
+
+
 class Unimod():
     """A class for Unimod database of modifications.
     The list of all modifications can be retrieved via `mods` attribute.
@@ -1060,15 +1064,14 @@ class Unimod():
         more features.
     """
 
-    def __init__(self, source='http://www.unimod.org/xml/unimod.xml'):
+    def __init__(self, source=UNIMOD_DEFAULT_URL):
         """Create a database and fill it from XML file retrieved from `source`.
 
         Parameters
         ----------
 
-        source : str or file, optional
-            A file-like object or a URL to read from. Don't forget the ``'file://'``
-            prefix when pointing to local files.
+        source : str or Path or file, optional
+            A file-like object, file name or a URL to read from.
         """
         from lxml import etree
         from ..xml import _local_name
@@ -1130,8 +1133,9 @@ class Unimod():
             new_d['refs'] = refs
             return new_d
 
-        if isinstance(source, str):
-            self._tree = etree.parse(urlopen(source))
+        if isinstance(source, (str, Path)):
+            url = ensure_url_prefix(source)
+            self._tree = etree.parse(urlopen(url))
         else:
             self._tree = etree.parse(source)
         self._massdata = self._mass_data()


Test patch:
diff --git a/tests/test_auxiliary.py b/tests/test_auxiliary.py
index 18a18b3..9b482b8 100644
--- a/tests/test_auxiliary.py
+++ b/tests/test_auxiliary.py
@@ -5,10 +5,9 @@ import operator as op
 import numpy as np
 import pandas as pd
 import tempfile
-
-from os import path
+import os
 import pyteomics
-pyteomics.__path__ = [path.abspath(path.join(path.dirname(__file__), path.pardir, 'pyteomics'))]
+pyteomics.__path__ = [os.path.abspath(os.path.join(os.path.dirname(__file__), os.path.pardir, 'pyteomics'))]
 from pyteomics import auxiliary as aux
 from pyteomics import tandem
 from pyteomics import version
@@ -37,10 +36,8 @@ class QvalueTest(unittest.TestCase):
         self.assertTrue(np.allclose(q['score'], np.arange(52)))
         self.setUp()
         spsms = sorted(self.psms, key=self.key)
-        self.assertTrue(np.allclose([self.is_decoy(x) for x in spsms],
-            q['is decoy']))
-        self.assertTrue(np.allclose([self.key(x) for x in spsms],
-            q['score']))
+        self.assertTrue(np.allclose([self.is_decoy(x) for x in spsms], q['is decoy']))
+        self.assertTrue(np.allclose([self.key(x) for x in spsms], q['score']))
         self.setUp()
 
     def _run_check_pep(self, q):
@@ -80,8 +77,7 @@ class QvalueTest(unittest.TestCase):
         psms = np.array(list(self.psms), dtype=dtype)
         q = aux.qvalues(psms, key=self.key, is_decoy=self.is_decoy, remove_decoy=False, formula=1)
         self._run_check(q, 1)
-        q = aux.qvalues(psms, key=self.key, is_decoy=self.is_decoy, remove_decoy=False, formula=1,
-            full_output=True)
+        q = aux.qvalues(psms, key=self.key, is_decoy=self.is_decoy, remove_decoy=False, formula=1, full_output=True)
         self._run_check(q, 1)
         self.assertTrue(q['psm'].dtype == dtype)
 
@@ -123,8 +119,7 @@ class QvalueTest(unittest.TestCase):
         psms = np.array(list(self.psms), dtype=dtype)
         q = aux.qvalues(psms, key='score', is_decoy=self.is_decoy, remove_decoy=False, formula=1)
         self._run_check(q, 1)
-        q = aux.qvalues(psms, key='score', is_decoy=self.is_decoy, remove_decoy=False, formula=1,
-            full_output=True)
+        q = aux.qvalues(psms, key='score', is_decoy=self.is_decoy, remove_decoy=False, formula=1, full_output=True)
         self._run_check(q, 1)
         self.assertTrue(q['psm'].dtype == dtype)
 
@@ -143,8 +138,7 @@ class QvalueTest(unittest.TestCase):
         psms = pd.DataFrame(np.array(list(self.psms), dtype=dtype))
         q = aux.qvalues(psms, key='score', is_decoy=self.is_decoy, remove_decoy=False, formula=1)
         self._run_check(q, 1)
-        q = aux.qvalues(psms, key='score', is_decoy=self.is_decoy, remove_decoy=False, formula=1,
-            full_output=True)
+        q = aux.qvalues(psms, key='score', is_decoy=self.is_decoy, remove_decoy=False, formula=1, full_output=True)
         self._run_check(q, 1)
 
     def test_qvalues_pep_from_dataframe_string_pep(self):
@@ -163,8 +157,7 @@ class QvalueTest(unittest.TestCase):
         psms['is decoy'] = [self.is_decoy(row) for _, row in psms.iterrows()]
         q = aux.qvalues(psms, key='score', is_decoy='is decoy', remove_decoy=False, formula=1)
         self._run_check(q, 1)
-        q = aux.qvalues(psms, key='score', is_decoy='is decoy', remove_decoy=False, formula=1,
-            full_output=True)
+        q = aux.qvalues(psms, key='score', is_decoy='is decoy', remove_decoy=False, formula=1, full_output=True)
         self._run_check(q, 1)
 
     def test_qvalues_pep_from_dataframe_string_key_and_pep(self):
@@ -176,12 +169,9 @@ class QvalueTest(unittest.TestCase):
         self._run_check_pep(q)
 
     def test_qvalues_pep_exceptions(self):
-        self.assertRaises(aux.PyteomicsError, aux.qvalues,
-            self.psms, pep='pep', is_decoy=self.is_decoy)
-        self.assertRaises(aux.PyteomicsError, aux.qvalues,
-            self.psms, pep='pep', remove_decoy=False)
-        self.assertRaises(aux.PyteomicsError, aux.qvalues,
-            self.psms, pep='pep', correction=0)
+        self.assertRaises(aux.PyteomicsError, aux.qvalues, self.psms, pep='pep', is_decoy=self.is_decoy)
+        self.assertRaises(aux.PyteomicsError, aux.qvalues, self.psms, pep='pep', remove_decoy=False)
+        self.assertRaises(aux.PyteomicsError, aux.qvalues, self.psms, pep='pep', correction=0)
 
     def test_qvalues_from_tandem(self):
         psms = tandem.TandemXML('test.t.xml')
@@ -259,13 +249,13 @@ class FilterTest(unittest.TestCase):
 
     def test_filter_chain_with(self):
         with aux.filter.chain(self.psms, self.psms, key=self.key, is_decoy=self.is_decoy,
-            fdr=0.5, full_output=False) as f:
+                              fdr=0.5, full_output=False) as f:
             f1 = list(f)
         self.assertEqual(len(f1), 52)
 
     def test_filter_pep_chain_with(self):
         with aux.filter.chain(self.psms, self.psms, pep=self.pep,
-            fdr=0.02, full_output=False) as f:
+                              fdr=0.02, full_output=False) as f:
             f1 = list(f)
         self.assertEqual(len(f1), 42)
 
@@ -304,14 +294,12 @@ class FilterTest(unittest.TestCase):
         self.assertEqual(f.shape[0], 42)
 
     def test_filter_chain_from_iterable_with(self):
-        with aux.filter.chain.from_iterable([self.psms, self.psms],
-            key=self.key, is_decoy=self.is_decoy, fdr=0.5, full_output=False) as f:
+        with aux.filter.chain.from_iterable([self.psms, self.psms], key=self.key, is_decoy=self.is_decoy, fdr=0.5, full_output=False) as f:
             f11 = list(f)
         self.assertEqual(len(f11), 52)
 
     def test_filter_pep_chain_from_iterable_with(self):
-        with aux.filter.chain.from_iterable([self.psms, self.psms],
-            key=self.key, pep=self.pep, fdr=0.02, full_output=False) as f:
+        with aux.filter.chain.from_iterable([self.psms, self.psms], key=self.key, pep=self.pep, fdr=0.02, full_output=False) as f:
             f1 = list(f)
         self.assertEqual(len(f1), 42)
 
@@ -745,7 +733,6 @@ class FilterTest(unittest.TestCase):
 
 
 class FDRTest(unittest.TestCase):
-
     is_decoy = staticmethod(lambda x: x[1].islower())
     pep = staticmethod(op.itemgetter(2))
 
diff --git a/tests/test_util.py b/tests/test_util.py
new file mode 100644
index 0000000..b0c8751
--- /dev/null
+++ b/tests/test_util.py
@@ -0,0 +1,37 @@
+import unittest
+import platform
+import os
+import pyteomics
+pyteomics.__path__ = [os.path.abspath(os.path.join(os.path.dirname(__file__), os.path.pardir, 'pyteomics'))]
+from pyteomics import auxiliary as aux
+
+
+class UtilTest(unittest.TestCase):
+    def test_ensure_prefix(self):
+        pairs = [
+            ('file:///home/test/unimod.xml', 'file:///home/test/unimod.xml'),
+            ('https://example.org/test/unimod.xml', 'https://example.org/test/unimod.xml'),
+            ('ftp://example.org/test/unimod.xml', 'ftp://example.org/test/unimod.xml'),
+            ('http://example.org/test/unimod.xml', 'http://example.org/test/unimod.xml'),
+        ]
+        pairs_windows = [
+            ('C:/Data folder/unimod.xml', 'file:///C:/Data%20folder/unimod.xml'),
+            ('file:///C:/Data folder/unimod.xml', 'file:///C:/Data folder/unimod.xml'),
+        ]
+        pairs_other = [('/home/test/unimod.xml', 'file:///home/test/unimod.xml'),]
+        system = platform.system()
+        print('Testing on', system)
+        if system == 'Windows':
+            pairs.extend(pairs_windows)
+        else:
+            pairs.extend(pairs_other)
+        for inp, out in pairs:
+            try:
+                self.assertEqual(aux.ensure_url_prefix(inp), out)
+            except Exception:
+                print('Failed with:', inp, out)
+                raise
+
+
+if __name__ == '__main__':
+    unittest.main()