Repository: burnash/gspread

Issue:
Ambiguity in the `numericise` function depending on Python version.
https://github.com/burnash/gspread/blob/217b43073e1abe9ceb1d65d2d0719d56f0a14642/gspread/utils.py#L58

I have run into some issues when fetching data from spreadsheets using the `get_all_records()` method when using Python 3.6+.

If a worksheet has data in the format `12_34`, which should be interpreted as a string (or numeric if _explicitly_ desired) the `numericise` function will convert it to an `int` because of https://docs.python.org/3/whatsnew/3.6.html#whatsnew36-pep515 introduced in Python3.6, using Python3.5.4 it parses it as the string `12_34`, as expected.

I don't think following PEP515 should be the default behaviour in this case.

Example

Python3.6
```
>>> from gspread.utils import numericise
>>> numericise('18_29')
1829
```

Python3.5.4
```
>>> from gspread.utils import numericise
>>> numericise('18_29')
'18_29'
```

Discussion:
danthelion: @burnash What do you think about this implementation? Details are in the referenced issue.
burnash: @danthelion thank you for the issue and the PR.

As I said in the referenced issue I agree. I'm sorry for the delay in responding. Since you submitted the PR the code of gspread has significantly changed. If you have time, could you rebase your changes to resolve the conflicts? Also please don't forget to add a test to cover this fix.
danthelion: Sure, I'll get on it as soon as I can. 👍 

Patch:
diff --git a/gspread/models.py b/gspread/models.py
index db592c7..30b36ea 100644
--- a/gspread/models.py
+++ b/gspread/models.py
@@ -545,11 +545,11 @@ class Worksheet(object):
         except KeyError:
             return []
 
-    def get_all_records(self, empty2zero=False, head=1, default_blank=""):
-        """Returns a list of dictionaries, all of them having the contents
-        of the spreadsheet with the head row as keys and each of these
-        dictionaries holding the contents of subsequent rows of cells
-        as values.
+    def get_all_records(self, empty2zero=False, head=1, default_blank="", allow_underscores_in_numeric_literals=False):
+        """Returns a list of dictionaries, all of them having:
+            - the contents of the spreadsheet's with the head row as keys,
+            And each of these dictionaries holding
+            - the contents of subsequent rows of cells as values.
 
         Cell values are numericised (strings that can be read as ints
         or floats are converted).
@@ -564,13 +564,17 @@ class Worksheet(object):
                               converted to something else except empty string
                               or zero.
         :type default_blank: str
+        :param allow_underscores_in_numeric_literals: (optional) Allow underscores 
+                                                      in numeric literals,
+                                                      as introduced in PEP 515
+        :type allow_underscores_in_numeric_literals: bool
         """
 
         idx = head - 1
 
         data = self.get_all_values()
         keys = data[idx]
-        values = [numericise_all(row, empty2zero, default_blank)
+        values = [numericise_all(row, empty2zero, default_blank, allow_underscores_in_numeric_literals)
                   for row in data[idx + 1:]]
 
         return [dict(zip(keys, row)) for row in values]
diff --git a/gspread/utils.py b/gspread/utils.py
index 0854611..a966820 100644
--- a/gspread/utils.py
+++ b/gspread/utils.py
@@ -29,7 +29,7 @@ def finditem(func, seq):
     return next((item for item in seq if func(item)))
 
 
-def numericise(value, empty2zero=False, default_blank=""):
+def numericise(value, empty2zero=False, default_blank="", allow_underscores_in_numeric_literals=False):
     """Returns a value that depends on the input string:
         - Float if input can be converted to Float
         - Integer if input can be converted to integer
@@ -42,6 +42,10 @@ def numericise(value, empty2zero=False, default_blank=""):
     'faa'
     >>> numericise("3")
     3
+    >>> numericise("3_2", allow_underscores_in_numeric_literals=False)
+    '3_2'
+    >>> numericise("3_2", allow_underscores_in_numeric_literals=True)
+    '32'
     >>> numericise("3.1")
     3.1
     >>> numericise("", empty2zero=True)
@@ -58,6 +62,8 @@ def numericise(value, empty2zero=False, default_blank=""):
     >>>
     """
     if value is not None:
+        if "_" in value and not allow_underscores_in_numeric_literals:
+            return value
         try:
             value = int(value)
         except ValueError:
@@ -73,9 +79,9 @@ def numericise(value, empty2zero=False, default_blank=""):
     return value
 
 
-def numericise_all(input, empty2zero=False, default_blank=""):
+def numericise_all(input, empty2zero=False, default_blank="", allow_underscores_in_numeric_literals=False):
     """Returns a list of numericised values from strings"""
-    return [numericise(s, empty2zero, default_blank) for s in input]
+    return [numericise(s, empty2zero, default_blank, allow_underscores_in_numeric_literals) for s in input]
 
 
 def rowcol_to_a1(row, col):


Test patch:
diff --git a/tests/test.py b/tests/test.py
index 431c0f7..ee42246 100644
--- a/tests/test.py
+++ b/tests/test.py
@@ -163,6 +163,20 @@ class UtilsTest(unittest.TestCase):
         gid = 'ogsrar0'
         self.assertEqual(utils.wid_to_gid(gid), '1015761654')
 
+    def test_numericise(self):
+        self.assertEqual(utils.numericise('faa'), 'faa')
+        self.assertEqual(utils.numericise('3'), 3)
+        self.assertEqual(utils.numericise('3_2'), '3_2')
+        self.assertEqual(utils.numericise('3_2', allow_underscores_in_numeric_literals=False), '3_2')
+        self.assertEqual(utils.numericise('3_2', allow_underscores_in_numeric_literals=True), 32)
+        self.assertEqual(utils.numericise('3.1'), 3.1)
+        self.assertEqual(utils.numericise('', empty2zero=True), 0)
+        self.assertEqual(utils.numericise('', empty2zero=False), '')
+        self.assertEqual(utils.numericise('', default_blank=None), None)
+        self.assertEqual(utils.numericise('', default_blank='foo'), 'foo')
+        self.assertEqual(utils.numericise(''), '')
+        self.assertEqual(utils.numericise(None), None)
+
 
 class GspreadTest(BetamaxGspreadTest):
     def _sequence_generator(self):