Repository: biopython/biopython

Issue:
SeqIO.index raises an error with `Path` objects as the *filename* argument
### Setup

I am reporting a problem with Biopython version, Python version, and operating
system as follows:

3.11.3 | packaged by conda-forge | (main, Apr  6 2023, 08:50:54) [MSC v.1934 64 bit (AMD64)]
CPython
Windows-10-10.0.19045-SP0
1.81

### Expected behaviour

Like `SeqIO.parse`, `.read`, & `.write`, to take `Path`-objects of `pathlib` as the *filename* argument.

### Actual behaviour

It checks *filename* for the `str` instantiation.

### Steps to reproduce

`SeqIO.index(Path("seqs.fas"), "fasta")`



Discussion:
This seems a sensible extension to the code - do you feel up to submitting
a pull request? It wouldn’t need many additional unit tests, maybe just one
or two would be fine.

Peter


Resolving patch:
diff --git a/Bio/SeqIO/__init__.py b/Bio/SeqIO/__init__.py
--- a/Bio/SeqIO/__init__.py
+++ b/Bio/SeqIO/__init__.py
@@ -844,8 +844,6 @@ def index(filename, format, alphabet=None, key_function=None):
 
     """
     # Try and give helpful error messages:
-    if not isinstance(filename, str):
-        raise TypeError("Need a filename (not a handle)")
     if not isinstance(format, str):
         raise TypeError("Need a string for the file format (lower case)")
     if not format:
@@ -869,9 +867,15 @@ def index(filename, format, alphabet=None, key_function=None):
         alphabet,
         key_function,
     )
-    return _IndexedSeqFileDict(
-        proxy_class(filename, format), key_function, repr, "SeqRecord"
-    )
+
+    try:
+        random_access_proxy = proxy_class(filename, format)
+    except TypeError:
+        raise TypeError(
+            "Need a string or path-like object for the filename (not a handle)"
+        ) from None
+
+    return _IndexedSeqFileDict(random_access_proxy, key_function, repr, "SeqRecord")
 
 
 def index_db(
@@ -923,15 +927,28 @@ def index_db(
     glob which is useful for building lists of files.
 
     """
+    from os import fspath
+
+    def is_pathlike(obj):
+        """Test if the given object can be accepted as a path."""
+        try:
+            fspath(obj)
+            return True
+        except TypeError:
+            return False
+
     # Try and give helpful error messages:
-    if not isinstance(index_filename, str):
-        raise TypeError("Need a string for the index filename")
-    if isinstance(filenames, str):
+    if not is_pathlike(index_filename):
+        raise TypeError("Need a string or path-like object for filename (not a handle)")
+    if is_pathlike(filenames):
         # Make the API a little more friendly, and more similar
         # to Bio.SeqIO.index(...) for indexing just one file.
         filenames = [filenames]
     if filenames is not None and not isinstance(filenames, list):
-        raise TypeError("Need a list of filenames (as strings), or one filename")
+        raise TypeError(
+            "Need a list of filenames (as strings or path-like "
+            "objects), or one filename"
+        )
     if format is not None and not isinstance(format, str):
         raise TypeError("Need a string for the file format (lower case)")
     if format and not format.islower():


Test patch:
diff --git a/Tests/test_SeqIO_index.py b/Tests/test_SeqIO_index.py
--- a/Tests/test_SeqIO_index.py
+++ b/Tests/test_SeqIO_index.py
@@ -18,6 +18,7 @@
 import warnings
 from io import BytesIO
 from io import StringIO
+from pathlib import Path
 
 from Bio.SeqRecord import SeqRecord
 from Bio import SeqIO
@@ -83,6 +84,11 @@ def test_old(self):
             self.assertEqual(54, len(d))
             self.assertRaises(FileNotFoundError, d.get_raw, "alpha")
 
+        def test_pathobj(self):
+            """Load existing index from a pathlib.Path object."""
+            d = SeqIO.index_db(Path("Roche/triple_sff.idx"))
+            self.assertEqual(54, len(d))
+
         def test_old_check_same_thread(self):
             """Setting check_same_thread to False doesn't raise an exception."""
             d = SeqIO.index_db("Roche/triple_sff_rel_paths.idx")
@@ -441,7 +447,7 @@ def simple_check(self, filename, fmt, comp):
             id_list = [rec.id for rec in SeqIO.parse(filename, fmt)]
 
         with warnings.catch_warnings():
-            if "_alt_index_" in filename:
+            if "_alt_index_" in str(filename):
                 # BiopythonParserWarning: Could not parse the SFF index:
                 # Unknown magic number b'.diy' in SFF index header:
                 # b'.diy1.00'
@@ -516,7 +522,7 @@ def key_check(self, filename, fmt, comp):
         key_list = [self.add_prefix(id) for id in id_list]
 
         with warnings.catch_warnings():
-            if "_alt_index_" in filename:
+            if "_alt_index_" in str(filename):
                 # BiopythonParserWarning: Could not parse the SFF index:
                 # Unknown magic number b'.diy' in SFF index header:
                 # b'.diy1.00'
@@ -544,9 +550,14 @@ def key_check(self, filename, fmt, comp):
             rec_dict.close()
 
             # Saving to file...
-            index_tmp = filename + ".key.idx"
-            if os.path.isfile(index_tmp):
-                os.remove(index_tmp)
+            index_tmp = None
+            if isinstance(filename, str):
+                index_tmp = filename + ".key.idx"
+            elif isinstance(filename, Path):
+                index_tmp = filename.with_suffix(".key.idx")
+
+            if os.path.isfile(str(index_tmp)):
+                os.remove(str(index_tmp))
             rec_dict = SeqIO.index_db(
                 index_tmp, [filename], fmt, key_function=self.add_prefix
             )
@@ -567,7 +578,7 @@ def key_check(self, filename, fmt, comp):
             self.check_dict_methods(rec_dict, key_list, id_list, msg=msg)
             rec_dict.close()
             rec_dict._con.close()  # hack for PyPy
-            os.remove(index_tmp)
+            os.remove(str(index_tmp))
             # Done
 
     def get_raw_check(self, filename, fmt, comp):
@@ -715,6 +726,15 @@ def test_simple_checks(self):
             for filename2, comp in tasks:
                 self.simple_check(filename2, fmt, comp)
 
+    def test_simple_checks_with_pathobj(self):
+        for filename1, fmt in self.tests:
+            assert fmt in _FormatToRandomAccess
+            tasks = [(filename1, None)]
+            if os.path.isfile(filename1 + ".bgz"):
+                tasks.append((filename1 + ".bgz", "bgzf"))
+            for filename2, comp in tasks:
+                self.simple_check(Path(filename2), fmt, comp)
+
     def test_key_checks(self):
         for filename1, fmt in self.tests:
             assert fmt in _FormatToRandomAccess
@@ -724,6 +744,15 @@ def test_key_checks(self):
             for filename2, comp in tasks:
                 self.key_check(filename2, fmt, comp)
 
+    def test_key_checks_with_pathobj(self):
+        for filename1, fmt in self.tests:
+            assert fmt in _FormatToRandomAccess
+            tasks = [(filename1, None)]
+            if os.path.isfile(filename1 + ".bgz"):
+                tasks.append((filename1 + ".bgz", "bgzf"))
+            for filename2, comp in tasks:
+                self.key_check(Path(filename2), fmt, comp)
+
     def test_raw_checks(self):
         for filename1, fmt in self.tests:
             assert fmt in _FormatToRandomAccess
@@ -733,6 +762,15 @@ def test_raw_checks(self):
             for filename2, comp in tasks:
                 self.get_raw_check(filename2, fmt, comp)
 
+    def test_raw_checks_with_pathobj(self):
+        for filename1, fmt in self.tests:
+            assert fmt in _FormatToRandomAccess
+            tasks = [(filename1, None)]
+            if os.path.isfile(filename1 + ".bgz"):
+                tasks.append((filename1 + ".bgz", "bgzf"))
+            for filename2, comp in tasks:
+                self.get_raw_check(Path(filename2), fmt, comp)
+
 
 class IndexOrderingSingleFile(unittest.TestCase):
     f = "GenBank/NC_000932.faa"