Repository: scipy/scipy

Issue:
BUG: [mmread] Error while reading mtx file with spaces before %%MatrixMarket in the first line
### Describe your issue.

I am handling matrix market files from an external software. For some reasons, this software is adding some spaces at the beginning of the first line just before "%%MatrixMarket". These spaces cause the reader mmread to crash for the following reason:

Line 389 in _mmio.py checks if the first character of the line is a '%' (or its binary code).
 https://github.com/scipy/scipy/blob/7eea3caaa62bdf45ec98422a06dfd3d49293d1dc/scipy/io/_mmio.py#L389 
In my case, `line[0]` is a space this cause the error message in a later line. 

I suggest to use `line.lstrip()[0]` instead of `line[0]` to check on the first non empty character.

What do you think?

Thanks.
Best regards

### Reproducing Code Example

```python
from scipy.io import mmread


lines = [" %%MatrixMarket matrix coordinate real general\n","5 5 3\n","2 3 1.0\n","3 4 2.0\n","3 5 3.0\n"]
with open("test.mtx", mode="w") as f :
  f.writelines(lines)

m = mmread("test.mtx")
print(m)
```


### Error message

```shell
Traceback (most recent call last):
  File "bugscipy.py", line 8, in <module>
    m = mmread("test.mtx")
  File "myPythonEnv/lib/python3.8/site-packages/scipy/io/_mmio.py", line 129, in mmread
    return MMFile().read(source)
  File "myPythonEnv/lib/python3.8/site-packages/scipy/io/_mmio.py", line 578, in read
    self._parse_header(stream)
  File "myPythonEnv/lib/python3.8/site-packages/scipy/io/_mmio.py", line 642, in _parse_header
    self.__class__.info(stream)
  File "myPythonEnv/lib/python3.8/site-packages/scipy/io/_mmio.py", line 405, in info
    raise ValueError("Header line not of length 3: " +
ValueError: Header line not of length 3:  %%MatrixMarket matrix coordinate real general
```


### SciPy/NumPy/Python version and system information

```shell
1.10.1 1.24.1 sys.version_info(major=3, minor=8, micro=9, releaselevel='final', serial=0)
Build Dependencies:
  blas:
    detection method: cmake
    found: true
    include directory: unknown
    lib directory: unknown
    name: OpenBLAS
    openblas configuration: unknown
    pc file directory: unknown
    version: 0.3.18
  lapack:
    detection method: cmake
    found: true
    include directory: unknown
    lib directory: unknown
    name: OpenBLAS
    openblas configuration: unknown
    pc file directory: unknown
    version: 0.3.18
Compilers:
  c:
    commands: cc
    linker: ld.bfd
    name: gcc
    version: 10.2.1
  c++:
    commands: c++
    linker: ld.bfd
    name: gcc
    version: 10.2.1
  cython:
    commands: cython
    linker: cython
    name: cython
    version: 0.29.33
  fortran:
    commands: gfortran
    linker: ld.bfd
    name: gcc
    version: 10.2.1
  pythran:
    include directory: /tmp/pip-build-env-q2fwe5jt/overlay/lib/python3.8/site-packages/pythran
    version: 0.12.1
Machine Information:
  build:
    cpu: x86_64
    endian: little
    family: x86_64
    system: linux
  cross-compiled: false
  host:
    cpu: x86_64
    endian: little
    family: x86_64
    system: linux
Python Information:
  path: /opt/python/cp38-cp38/bin/python
  version: '3.8'
```



Discussion:
I don't regularly use this format, but a quick check of the "standard paper" (https://nvlpubs.nist.gov/nistpubs/Legacy/IR/nistir5935.pdf) section 2 indicates:

> The first 15 characters must be ‘%%MatrixMarket followed by at least one blank.

I don't see any allowance for a leading space. Whether SciPy should "hold the line" on this is something I'm not sure about, but if it is a genuine bug with the other software you are using, I suppose this may be something we could argue we shouldn't really support?

Is that external software extremely popular/heavily used? And do they accept patches, etc.? I don't think you mentioned what that other package was.
That quote seems unambiguous, and my initial reaction was "hold the line", but then I checked the source code that is linked to in https://math.nist.gov/MatrixMarket/formats.html:

* In the C code [mmio.c](https://math.nist.gov/MatrixMarket/mmio/c/mmio.c), `sscanf` is used to read the tokens from the first line of the file;  `sscanf` will skip leading spaces.
* In the Fortran code [mmio.f](https://math.nist.gov/MatrixMarket/mmio/f/mmio.f), there is a subroutine called `getwd` that is used to split the first line into tokens.  The first thing that function does is skip initial spaces to find the start of the token.
* In the Matlab code [mmread.m](https://math.nist.gov/MatrixMarket/mmio/matlab/mmread.m), the function [`strtok`](https://www.mathworks.com/help/matlab/ref/strtok.html) is used to split the first line into tokens, and `strtok` skips initial spaces.

The handling of the leading spaces of the first line by these programs could be interpreted as incidental--that is, the authors might not have considered the possibility of leading spaces, and the code just happens to skip leading spaces because the utility function that was used happens to skip leading spaces.  But if we consider these to be reference implementations that demonstrate correct behavior, then it appears that leading spaces in the first line should be allowed.

Thank you for your answers !

What is the conclusion then ? "hold the line" or not ? Since it is a very simple patch I'd advocate for it.

The software is widely used in the sparse linear algebra community (MUMPS).

There's no file that will be incorrectly interpreted, by allowing the spaces, so I'd say this was an excellent example where being permissive is the right choice, maybe with a warning 'File not fully conformant' or similar.
The spec is pretty clear on this subject. I suspect the permissive loading by the reference code is indeed incidental. They use standard parsing routines that happen to be lenient, and avoiding this leniency would require extra checks. Have a look at Example 1 in that spec document. It's a long list of whitespace leniency throughout the format, but the banner is left strict.

Indeed quite a few libraries do depend this strictness. A quick glance suggests that the leading Java (EJML), Rust (sprs), and Julia parsers will all fail on files with leading spaces before the banner. So please submit a bug report to the library that emits these invalid files.

That said, this is a good point:
> There's no file that will be incorrectly interpreted, by allowing the spaces

For this reason I made the [`fast_matrix_market`](https://pypi.org/project/fast-matrix-market) parser allow leading whitespace on the banner. SciPy probably should too.

@VincentDarrigrand  if you're blocked by this, then maybe try fast_matrix_market. It's a direct scipy.io.mmio replacement (but faster) so you can easily switch back and forth. Just swap imports.

```python
# from scipy.io import mmread
from fast_matrix_market import mmread
```

and your reproducing code works.

Resolving patch:
diff --git a/scipy/io/_mmio.py b/scipy/io/_mmio.py
--- a/scipy/io/_mmio.py
+++ b/scipy/io/_mmio.py
@@ -386,8 +386,11 @@ def info(self, source):
 
             # skip comments
             # line.startswith('%')
-            while line and line[0] in ['%', 37]:
-                line = stream.readline()
+            while line:
+                if line.lstrip() and line.lstrip()[0] in ['%', 37]:
+                    line = stream.readline()
+                else:
+                    break
 
             # skip empty lines
             while not line.strip():


Test patch:
diff --git a/scipy/io/tests/test_mmio.py b/scipy/io/tests/test_mmio.py
--- a/scipy/io/tests/test_mmio.py
+++ b/scipy/io/tests/test_mmio.py
@@ -757,3 +757,15 @@ def test_gh11389():
     mmread(io.StringIO("%%MatrixMarket matrix coordinate complex symmetric\n"
                        " 1 1 1\n"
                        "1 1 -2.1846000000000e+02  0.0000000000000e+00"))
+
+
+def test_gh18123(tmp_path):
+    lines = [" %%MatrixMarket matrix coordinate real general\n",
+             "5 5 3\n",
+             "2 3 1.0\n",
+             "3 4 2.0\n",
+             "3 5 3.0\n"]
+    test_file = tmp_path / "test.mtx"
+    with open(test_file, "w") as f:
+        f.writelines(lines)
+    mmread(test_file)