Repository: pyqtgraph/pyqtgraph

Issue:
Regression: Spinbox siPrefix with dimensionless quantities impossible
### Short description
`siPrefix` is very useful when working with very high or low numbers, and I appreciate this a lot, sometimes having every prefix range from `n` to `k` in one UI. Sometimes the quantities are dimensionless, sometimes the units are not known to the program or tracked otherwise. 

Since pyqtgraph 0.11, spinboxes need an SI `suffix` when using `siPrefix`. This has the apparent consequence that one cannot work with dimensionless numbers (or unitless numbers) anymore.
I could not find an issue explaining the reasoning behind this change, only commit 6b798ffed856d8b2f7d53544ca4c480b2f7911d5 in #410 by @campagnola.

I have not found a way to work around this issue. I've tried passing `''` or a space `' '` into `suffix`, but that did not help.

Can you please fix this regression and re-enable using prefix-only with the Spinbox?
Should it be important for users to specifically switch on this behaviour, maybe an approach is to have `None` as a default for  `suffix`, and supply `suffix=''` to enable working with dimensionless numbers, which would be fine by me.

### Code to reproduce
```python
import pyqtgraph
from pyqtgraph.Qt import QtGui

app = QtGui.QApplication([])

w = pyqtgraph.QtGui.QMainWindow()
g = pyqtgraph.QtGui.QWidget()
l = pyqtgraph.QtGui.QHBoxLayout()
label = pyqtgraph.QtGui.QLabel()
label.setText('Reynolds number')
s = pyqtgraph.SpinBox(siPrefix=True)
display = pyqtgraph.QtGui.QLabel()
display.setText('value')
s.valueChanged.connect(lambda v: display.setText(str(v)))

g.setLayout(l)
w.setCentralWidget(g)

l.addWidget(label)
l.addWidget(s)
l.addWidget(display)

w.show()
app.exec()
```

### Expected behavior
The old behaviour behaves as expected, enabling use of prefixes with dimensionless quantities:
![image](https://user-images.githubusercontent.com/327442/98345859-81433700-2015-11eb-9753-efa704f3a075.png)

In another application, I am heavily using the siPrefix to enable easy entry and display of high/low numbers (with unity only being implicit due to device interface), and it works very well with 0.10:
![image](https://user-images.githubusercontent.com/327442/98347653-f9aaf780-2017-11eb-8c67-9995826adb15.png)

### Real behavior
The new behaviour sinc 0.11 does not accept prefix-only.
Also (I suspect that this is another bug), sometimes it selects the value when pressing enter, leaving the prefix in place but not in the value
![image](https://user-images.githubusercontent.com/327442/98345666-42ad7c80-2015-11eb-9384-7c791bedc0bf.png)
sometimes strips an entered prefix without warning
![image](https://user-images.githubusercontent.com/327442/98345765-6375d200-2015-11eb-8ac7-7234be718ab4.png)

### Tested environment(s)

 * PyQtGraph version: 0.10 and 0.11.0. respectively
 * Qt Python binding: PyQt5 5.9.2 Qt 5.9.6
 * Python version: 3.7
 * NumPy version: 1.19.2
 * Operating system: Windows
 * Installation method: conda


Discussion:

Resolving patch:
diff --git a/examples/SpinBox.py b/examples/SpinBox.py
--- a/examples/SpinBox.py
+++ b/examples/SpinBox.py
@@ -31,6 +31,8 @@
      pg.SpinBox(value=1.0, suffix='V', siPrefix=True, dec=True, step=0.5, minStep=0.01)),
     ("Float with SI-prefixed units,<br>dec step=1.0, minStep=0.001", 
      pg.SpinBox(value=1.0, suffix='V', siPrefix=True, dec=True, step=1.0, minStep=0.001)),
+    ("Float with SI prefix but no suffix",
+     pg.SpinBox(value=1e9, siPrefix=True)),
     ("Float with custom formatting", 
      pg.SpinBox(value=23.07, format='${value:0.02f}',
                 regex='\$?(?P<number>(-?\d+(\.\d+)?)|(-?\.\d+))$')),
diff --git a/pyqtgraph/functions.py b/pyqtgraph/functions.py
--- a/pyqtgraph/functions.py
+++ b/pyqtgraph/functions.py
@@ -119,10 +119,28 @@ def siFormat(x, precision=3, suffix='', space=True, error=None, minVal=1e-25, al
 
 def siParse(s, regex=FLOAT_REGEX, suffix=None):
     """Convert a value written in SI notation to a tuple (number, si_prefix, suffix).
-    
+
     Example::
-    
-        siParse('100 μV")  # returns ('100', 'μ', 'V')
+
+        siParse('100 µV")  # returns ('100', 'µ', 'V')
+
+    Note that in the above example, the µ symbol is the "micro sign" (UTF-8
+    0xC2B5), as opposed to the Greek letter mu (UTF-8 0xCEBC).
+
+    Parameters
+    ----------
+    s : str
+        The string to parse.
+    regex : re.Pattern, optional
+        Compiled regular expression object for parsing. The default is a
+        general-purpose regex for parsing floating point expressions,
+        potentially containing an SI prefix and a suffix.
+    suffix : str, optional
+        Suffix to check for in ``s``. The default (None) indicates there may or
+        may not be a suffix contained in the string and it is returned if
+        found. An empty string ``""`` is handled differently: if the string
+        contains a suffix, it is discarded. This enables interpreting
+        characters following the numerical value as an SI prefix.
     """
     s = asUnicode(s)
     s = s.strip()
@@ -130,15 +148,20 @@ def siParse(s, regex=FLOAT_REGEX, suffix=None):
         if s[-len(suffix):] != suffix:
             raise ValueError("String '%s' does not have the expected suffix '%s'" % (s, suffix))
         s = s[:-len(suffix)] + 'X'  # add a fake suffix so the regex still picks up the si prefix
-        
+
+    # special case: discard any extra characters if suffix is explicitly empty
+    if suffix == "":
+        s += 'X'
+
     m = regex.match(s)
     if m is None:
         raise ValueError('Cannot parse number "%s"' % s)
+
     try:
         sip = m.group('siPrefix')
     except IndexError:
         sip = ''
-    
+
     if suffix is None:
         try:
             suf = m.group('suffix')
@@ -146,8 +169,8 @@ def siParse(s, regex=FLOAT_REGEX, suffix=None):
             suf = ''
     else:
         suf = suffix
-    
-    return m.group('number'), '' if sip is None else sip, '' if suf is None else suf 
+
+    return m.group('number'), '' if sip is None else sip, '' if suf is None else suf
 
 
 def siEval(s, typ=float, regex=FLOAT_REGEX, suffix=None):
diff --git a/pyqtgraph/widgets/SpinBox.py b/pyqtgraph/widgets/SpinBox.py
--- a/pyqtgraph/widgets/SpinBox.py
+++ b/pyqtgraph/widgets/SpinBox.py
@@ -132,8 +132,10 @@ def setOpts(self, **opts):
         siPrefix       (bool) If True, then an SI prefix is automatically prepended
                        to the units and the value is scaled accordingly. For example,
                        if value=0.003 and suffix='V', then the SpinBox will display
-                       "300 mV" (but a call to SpinBox.value will still return 0.003). Default
-                       is False.
+                       "300 mV" (but a call to SpinBox.value will still return 0.003). In case
+                       the value represents a dimensionless quantity that might span many
+                       orders of magnitude, such as a Reynold's number, an SI
+                       prefix is allowed with no suffix. Default is False.
         step           (float) The size of a single step. This is used when clicking the up/
                        down arrows, when rolling the mouse wheel, or when pressing 
                        keyboard arrows while the widget has keyboard focus. Note that
@@ -466,7 +468,7 @@ def formatText(self, prev=None):
 
         # format the string 
         val = self.value()
-        if self.opts['siPrefix'] is True and len(self.opts['suffix']) > 0:
+        if self.opts['siPrefix'] is True:
             # SI prefix was requested, so scale the value accordingly
 
             if self.val == 0 and prev is not None:
@@ -545,7 +547,7 @@ def interpret(self):
             return False
             
         # check suffix
-        if suffix != self.opts['suffix'] or (suffix == '' and siprefix != ''):
+        if suffix != self.opts['suffix']:
             return False
            
         # generate value


Test patch:
diff --git a/pyqtgraph/tests/test_functions.py b/pyqtgraph/tests/test_functions.py
--- a/pyqtgraph/tests/test_functions.py
+++ b/pyqtgraph/tests/test_functions.py
@@ -423,6 +423,30 @@ def __eq__(self, x):
     assert eq(set(range(10)), set(range(10)))
     assert not eq(set(range(10)), set(range(9)))
 
-    
-if __name__ == '__main__':
-    test_interpolateArray()
+
+@pytest.mark.parametrize("s,suffix,expected", [
+    # usual cases
+    ("100 uV", "V", ("100", "u", "V")),
+    ("100 µV", "V", ("100", "µ", "V")),
+    ("4.2 nV", None, ("4.2", "n", "V")),
+    ("1.2 m", "m", ("1.2", "", "m")),
+    ("1.2 m", None, ("1.2", "", "m")),
+    ("5.0e9", None, ("5.0e9", "", "")),
+    ("2 units", "units", ("2", "", "units")),
+    # siPrefix with explicit empty suffix
+    ("1.2 m", "", ("1.2", "m", "")),
+    ("5.0e-9 M", "", ("5.0e-9", "M", "")),
+    # weirder cases that should return the reasonable thing
+    ("4.2 nV", "nV", ("4.2", "", "nV")),
+    ("4.2 nV", "", ("4.2", "n", "")),
+    ("1.2 j", "", ("1.2", "", "")),
+    ("1.2 j", None, ("1.2", "", "j")),
+    # expected error cases
+    ("100 uV", "v", ValueError),
+])
+def test_siParse(s, suffix, expected):
+    if isinstance(expected, tuple):
+        assert pg.siParse(s, suffix=suffix) == expected
+    else:
+        with pytest.raises(expected):
+            pg.siParse(s, suffix=suffix)
diff --git a/pyqtgraph/widgets/tests/test_spinbox.py b/pyqtgraph/widgets/tests/test_spinbox.py
--- a/pyqtgraph/widgets/tests/test_spinbox.py
+++ b/pyqtgraph/widgets/tests/test_spinbox.py
@@ -23,6 +23,7 @@ def test_SpinBox_defaults():
     (1.45, '1.45 PSI', dict(int=False, decimals=6, suffix='PSI', siPrefix=True)),
     (1.45e-3, '1.45 mPSI', dict(int=False, decimals=6, suffix='PSI', siPrefix=True)),
     (-2500.3427, '$-2500.34', dict(int=False, format='${value:0.02f}')),
+    (1000, '1 k', dict(siPrefix=True, suffix="")),
 ])
 def test_SpinBox_formatting(value, expected_text, opts):
     sb = pg.SpinBox(**opts)
@@ -35,10 +36,11 @@ def test_SpinBox_formatting(value, expected_text, opts):
 @pytest.mark.parametrize("suffix", ["", "V"])
 def test_SpinBox_gui_set_value(suffix):
     sb = pg.SpinBox(suffix=suffix)
+
     sb.lineEdit().setText('0.1' + suffix)
     sb.editingFinishedEvent()
     assert sb.value() == 0.1
-    if suffix != '':
-        sb.lineEdit().setText('0.1 m' + suffix)
-        sb.editingFinishedEvent()
-        assert sb.value() == 0.1e-3
+
+    sb.lineEdit().setText('0.1 m' + suffix)
+    sb.editingFinishedEvent()
+    assert sb.value() == 0.1e-3