Repository: streamlit/streamlit

Issue:
Separate int and float data in the Slider widget
- separate int and float data so we maintain precision for large numbers
- use sint64 and double instead of int32 and float


Discussion:

Resolving patch:
diff --git a/lib/streamlit/DeltaGenerator.py b/lib/streamlit/DeltaGenerator.py
--- a/lib/streamlit/DeltaGenerator.py
+++ b/lib/streamlit/DeltaGenerator.py
@@ -35,14 +35,15 @@
 from streamlit import metrics
 from streamlit import type_util
 from streamlit.ReportThread import get_report_ctx
-from streamlit.UploadedFileManager import UploadedFileManager
 from streamlit.errors import DuplicateWidgetID
 from streamlit.errors import StreamlitAPIException
+from streamlit.js_number import JSNumber
+from streamlit.js_number import JSNumberBoundsException
 from streamlit.proto import Alert_pb2
 from streamlit.proto import Balloons_pb2
 from streamlit.proto import BlockPath_pb2
 from streamlit.proto import ForwardMsg_pb2
-from streamlit.proto import Text_pb2
+from streamlit.proto.NumberInput_pb2 import NumberInput
 
 
 # setup logging
@@ -1990,6 +1991,20 @@ def slider(
                     "The value and/or arguments are out of range."
                 )
 
+        # Bounds checks. JSNumber produces human-readable exceptions that
+        # we simply re-package as StreamlitAPIExceptions.
+        # (We check `min_value` and `max_value` here; `value` and `step` are
+        # already known to be in the [min_value, max_value] range.)
+        try:
+            if all_ints:
+                JSNumber.validate_int_bounds(min_value, "`min_value`")
+                JSNumber.validate_int_bounds(max_value, "`max_value`")
+            else:
+                JSNumber.validate_float_bounds(min_value, "`min_value`")
+                JSNumber.validate_float_bounds(max_value, "`max_value`")
+        except JSNumberBoundsException as e:
+            raise StreamlitAPIException(str(e))
+
         # Set format default.
         if format is None:
             if all_ints:
@@ -1998,16 +2013,16 @@ def slider(
                 format = "%0.2f"
 
         # It would be great if we could guess the number of decimal places from
-        # the step argument, but this would only be meaningful if step were a decimal.
-        # As a possible improvement we could make this function accept decimals
-        # and/or use some heuristics for floats.
+        # the `step` argument, but this would only be meaningful if step were a
+        # decimal. As a possible improvement we could make this function accept
+        # decimals and/or use some heuristics for floats.
 
         element.slider.label = label
+        element.slider.format = format
         element.slider.default[:] = [value] if single_value else value
         element.slider.min = min_value
         element.slider.max = max_value
         element.slider.step = step
-        element.slider.format = format
 
         ui_value = _get_widget_ui_value("slider", element, user_key=key)
         # Convert the current value to the appropriate type.
@@ -2026,7 +2041,6 @@ def slider(
 
     @_with_element
     def file_uploader(self, element, label, type=None, encoding="auto", key=None):
-
         """Display a file uploader widget.
 
         By default, uploaded files are limited to 50MB but you can configure that using the `server.maxUploadSize` config option.
@@ -2313,7 +2327,7 @@ def number_input(
             If the value is not specified, the format parameter will be used.
         format : str or None
             A printf-style format string controlling how the interface should
-            display numbers. Output must be purely numeric. This does not impact 
+            display numbers. Output must be purely numeric. This does not impact
             the return value. Valid formatters: %d %e %f %g %i
         key : str
             An optional string to use as the unique key for the widget.
@@ -2417,25 +2431,43 @@ def number_input(
                 % {"value": value, "min": min_value, "max": max_value}
             )
 
-        number_input = element.number_input
-        if all_ints:
-            data = number_input.int_data
-        else:
-            data = number_input.float_data
+        # Bounds checks. JSNumber produces human-readable exceptions that
+        # we simply re-package as StreamlitAPIExceptions.
+        try:
+            if all_ints:
+                if min_value is not None:
+                    JSNumber.validate_int_bounds(min_value, "`min_value`")
+                if max_value is not None:
+                    JSNumber.validate_int_bounds(max_value, "`max_value`")
+                if step is not None:
+                    JSNumber.validate_int_bounds(step, "`step`")
+                JSNumber.validate_int_bounds(value, "`value`")
+            else:
+                if min_value is not None:
+                    JSNumber.validate_float_bounds(min_value, "`min_value`")
+                if max_value is not None:
+                    JSNumber.validate_float_bounds(max_value, "`max_value`")
+                if step is not None:
+                    JSNumber.validate_float_bounds(step, "`step`")
+                JSNumber.validate_float_bounds(value, "`value`")
+        except JSNumberBoundsException as e:
+            raise StreamlitAPIException(str(e))
 
+        number_input = element.number_input
+        number_input.data_type = NumberInput.INT if all_ints else NumberInput.FLOAT
         number_input.label = label
-        data.default = value
+        number_input.default = value
 
         if min_value is not None:
-            data.min = min_value
+            number_input.min = min_value
             number_input.has_min = True
 
         if max_value is not None:
-            data.max = max_value
+            number_input.max = max_value
             number_input.has_max = True
 
         if step is not None:
-            data.step = step
+            number_input.step = step
 
         if format is not None:
             number_input.format = format
@@ -2657,11 +2689,13 @@ def deck_gl_chart(self, element, spec=None, **kwargs):
         if not suppress_deprecation_warning:
             import streamlit as st
 
-            st.warning("""
+            st.warning(
+                """
                 The `deck_gl_chart` widget is deprecated and will be removed on
 
                 2020-03-04. To render a map, you should use `st.pyDeckChart` widget.
-            """)
+            """
+            )
 
         import streamlit.elements.deck_gl as deck_gl
 
@@ -2690,7 +2724,7 @@ def pydeck_chart(self, element, pydeck_obj=None):
         >>> df = pd.DataFrame(
         ...    np.random.randn(1000, 2) / [50, 50] + [37.76, -122.4],
         ...    columns=['lat', 'lon'])
-        
+
         >>> st.pydeck_chart(pdk.Deck(
         ...     map_style='mapbox://styles/mapbox/light-v9',
         ...     initial_view_state=pdk.ViewState(
diff --git a/lib/streamlit/js_number.py b/lib/streamlit/js_number.py
new file mode 100644
--- /dev/null
+++ b/lib/streamlit/js_number.py
@@ -0,0 +1,114 @@
+# -*- coding: utf-8 -*-
+# Copyright 2018-2020 Streamlit Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Python 2/3 compatibility
+from __future__ import print_function, division, unicode_literals, absolute_import
+from streamlit.compatibility import setup_2_3_shims
+
+setup_2_3_shims(globals())
+
+
+class JSNumberBoundsException(Exception):
+    pass
+
+
+class JSNumber(object):
+    """
+    Utility class. Exposes JavaScript Number constants.
+    """
+
+    # The largest int that can be represented with perfect precision
+    # in JavaScript.
+    MAX_SAFE_INTEGER = (1 << 53) - 1
+
+    # The smallest int that can be represented with perfect precision
+    # in JavaScript.
+    MIN_SAFE_INTEGER = -((1 << 53) - 1)
+
+    # The largest float that can be represented in JavaScript.
+    MAX_VALUE = 1.7976931348623157e308
+
+    # The closest number to zero that can be represented in JavaScript.
+    MIN_VALUE = 5e-324
+
+    # The largest negative float that can be represented in JavaScript.
+    MIN_NEGATIVE_VALUE = -MAX_VALUE
+
+    @classmethod
+    def validate_int_bounds(cls, value, value_name=None):
+        """Validate that an int value can be represented with perfect precision
+        by a JavaScript Number.
+
+        Parameters
+        ----------
+        value : int
+        value_name : str or None
+            The name of the value parameter. If specified, this will be used
+            in any exception that is thrown.
+
+        Raises
+        -------
+        JSNumberBoundsException
+            Raised with a human-readable explanation if the value falls outside
+            JavaScript int bounds.
+
+        """
+        if value_name is None:
+            value_name = "value"
+
+        if not isinstance(value, int):
+            raise JSNumberBoundsException("%s (%s) is not an int" % (value_name, value))
+        elif value < cls.MIN_SAFE_INTEGER:
+            raise JSNumberBoundsException(
+                "%s (%s) must be >= -((1 << 53) - 1)" % (value_name, value)
+            )
+        elif value > cls.MAX_SAFE_INTEGER:
+            raise JSNumberBoundsException(
+                "%s (%s) must be <= (1 << 53) - 1" % (value_name, value)
+            )
+
+    @classmethod
+    def validate_float_bounds(cls, value, value_name):
+        """Validate that a float value can be represented by a JavaScript Number.
+
+        Parameters
+        ----------
+        value : float
+        value_name : str or None
+            The name of the value parameter. If specified, this will be used
+            in any exception that is thrown.
+
+        Raises
+        -------
+        JSNumberBoundsException
+            Raised with a human-readable explanation if the value falls outside
+            JavaScript float bounds.
+
+        """
+        if value_name is None:
+            value_name = "value"
+
+        if not isinstance(value, (int, float)):
+            raise JSNumberBoundsException(
+                "%s (%s) is not a float" % (value_name, value)
+            )
+        elif value < cls.MIN_NEGATIVE_VALUE:
+            raise JSNumberBoundsException(
+                "%s (%s) must be >= -1.797e+308" % (value_name, value)
+            )
+        elif value > cls.MAX_VALUE:
+            raise JSNumberBoundsException(
+                "%s (%s) must be <= 1.797e+308" % (value_name, value)
+            )


Test patch:
diff --git a/frontend/src/components/widgets/NumberInput/NumberInput.test.tsx b/frontend/src/components/widgets/NumberInput/NumberInput.test.tsx
--- a/frontend/src/components/widgets/NumberInput/NumberInput.test.tsx
+++ b/frontend/src/components/widgets/NumberInput/NumberInput.test.tsx
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 
+import { NumberInput as NumberInputProto } from "autogen/proto"
 import React from "react"
 import { shallow } from "enzyme"
 import { Input as UIInput } from "baseui/input"
@@ -41,22 +42,20 @@ const getProps = (elementProps: object = {}): Props => ({
 
 const getIntProps = (elementProps: object = {}): Props => {
   return getProps({
-    intData: {
-      default: 10,
-      min: 0,
-      max: 0,
-    },
+    dataType: NumberInputProto.DataType.INT,
+    default: 10,
+    min: 0,
+    max: 0,
     ...elementProps,
   })
 }
 
 const getFloatProps = (elementProps: object = {}): Props => {
   return getProps({
-    floatData: {
-      default: 10.0,
-      min: 0.0,
-      max: 0.0,
-    },
+    dataType: NumberInputProto.DataType.FLOAT,
+    default: 10.0,
+    min: 0.0,
+    max: 0.0,
     ...elementProps,
   })
 }
@@ -72,7 +71,7 @@ describe("NumberInput widget", () => {
   it("handles malformed format strings without crashing", () => {
     // This format string is malformed (it should be %0.2f)
     const props = getFloatProps({
-      floatData: { default: 5.0 },
+      default: 5.0,
       format: "%0.2",
     })
     const wrapper = shallow(<NumberInput {...props} />)
@@ -102,11 +101,9 @@ describe("NumberInput widget", () => {
     const props = getIntProps({
       hasMin: true,
       hasMax: true,
-      intData: {
-        default: 10,
-        min: 0,
-        max: 10,
-      },
+      default: 10,
+      min: 0,
+      max: 10,
     })
     const wrapper = shallow(<NumberInput {...props} />)
 
@@ -120,10 +117,8 @@ describe("NumberInput widget", () => {
     it("should change the state when ArrowDown", () => {
       const props = getFloatProps({
         format: "%0.2f",
-        floatData: {
-          default: 11.0,
-          step: 0.1,
-        },
+        default: 11.0,
+        step: 0.1,
       })
       const wrapper = shallow(<NumberInput {...props} />)
       const InputWrapper = wrapper.find(UIInput)
@@ -142,11 +137,7 @@ describe("NumberInput widget", () => {
 
   describe("Value", () => {
     it("should pass a default value", () => {
-      const props = getIntProps({
-        intData: {
-          default: 10,
-        },
-      })
+      const props = getIntProps({ default: 10 })
       const wrapper = shallow(<NumberInput {...props} />)
 
       expect(wrapper.find(UIInput).props().value).toBe("10")
diff --git a/lib/tests/streamlit/number_input_test.py b/lib/tests/streamlit/number_input_test.py
--- a/lib/tests/streamlit/number_input_test.py
+++ b/lib/tests/streamlit/number_input_test.py
@@ -15,22 +15,35 @@
 
 """number_input unit test."""
 
-from tests import testutil
+import pytest
+
 import streamlit as st
 from streamlit.errors import StreamlitAPIException
-
-from parameterized import parameterized
-import pytest
+from streamlit.js_number import JSNumber
+from streamlit.proto.NumberInput_pb2 import NumberInput
+from tests import testutil
 
 
 class NumberInputTest(testutil.DeltaGeneratorTestCase):
+    def test_data_type(self):
+        """Test that NumberInput.type is set to the proper
+        NumberInput.DataType value
+        """
+        st.number_input("Label", value=0)
+        c = self.get_delta_from_queue().new_element.number_input
+        self.assertEqual(NumberInput.INT, c.data_type)
+
+        st.number_input("Label", value=0.5)
+        c = self.get_delta_from_queue().new_element.number_input
+        self.assertEqual(NumberInput.FLOAT, c.data_type)
+
     def test_just_label(self):
         """Test that it can be called with no value."""
         st.number_input("the label")
 
         c = self.get_delta_from_queue().new_element.number_input
         self.assertEqual(c.label, "the label")
-        self.assertEqual(c.float_data.default, 0.0)
+        self.assertEqual(c.default, 0.0)
         self.assertEqual(c.has_min, False)
         self.assertEqual(c.has_max, False)
 
@@ -39,16 +52,16 @@ def test_default_value_when_min_is_passed(self):
 
         c = self.get_delta_from_queue().new_element.number_input
         self.assertEqual(c.label, "the label")
-        self.assertEqual(c.int_data.default, 1)
+        self.assertEqual(c.default, 1)
 
     def test_value_between_range(self):
         st.number_input("the label", 0, 11, 10)
 
         c = self.get_delta_from_queue().new_element.number_input
         self.assertEqual(c.label, "the label")
-        self.assertEqual(c.int_data.default, 10)
-        self.assertEqual(c.int_data.min, 0)
-        self.assertEqual(c.int_data.max, 11)
+        self.assertEqual(c.default, 10)
+        self.assertEqual(c.min, 0)
+        self.assertEqual(c.max, 11)
         self.assertEqual(c.has_min, True)
         self.assertEqual(c.has_max, True)
 
@@ -56,13 +69,13 @@ def test_default_step_when_a_value_is_int(self):
         st.number_input("the label", value=10)
 
         c = self.get_delta_from_queue().new_element.number_input
-        self.assertEqual(c.int_data.step, 1.0)
+        self.assertEqual(c.step, 1.0)
 
     def test_default_step_when_a_value_is_float(self):
         st.number_input("the label", value=10.5)
 
         c = self.get_delta_from_queue().new_element.number_input
-        self.assertEqual("%0.2f" % c.float_data.step, "0.01")
+        self.assertEqual("%0.2f" % c.step, "0.01")
 
     def test_default_format_int(self):
         st.number_input("the label", value=10)
@@ -81,14 +94,14 @@ def test_format_int_and_default_step(self):
 
         c = self.get_delta_from_queue().new_element.number_input
         self.assertEqual(c.format, "%d")
-        self.assertEqual(c.int_data.step, 1)
+        self.assertEqual(c.step, 1)
 
     def test_format_float_and_default_step(self):
         st.number_input("the label", value=10.0, format="%f")
 
         c = self.get_delta_from_queue().new_element.number_input
         self.assertEqual(c.format, "%f")
-        self.assertEqual("%0.2f" % c.float_data.step, "0.01")
+        self.assertEqual("%0.2f" % c.step, "0.01")
 
     def test_value_outrange(self):
         with pytest.raises(StreamlitAPIException) as exc_message:
@@ -127,3 +140,36 @@ def test_error_on_invalid_formats(self):
         for fmt in BAD_FORMATS:
             with pytest.raises(StreamlitAPIException) as exc_message:
                 st.number_input("any label", value=3.14, format=fmt)
+
+    def test_value_out_of_bounds(self):
+        # Max int
+        with pytest.raises(StreamlitAPIException) as exc:
+            value = JSNumber.MAX_SAFE_INTEGER + 1
+            st.number_input("Label", value=value)
+        self.assertEqual(
+            "`value` (%s) must be <= (1 << 53) - 1" % str(value), str(exc.value)
+        )
+
+        # Min int
+        with pytest.raises(StreamlitAPIException) as exc:
+            value = JSNumber.MIN_SAFE_INTEGER - 1
+            st.number_input("Label", value=value)
+        self.assertEqual(
+            "`value` (%s) must be >= -((1 << 53) - 1)" % str(value), str(exc.value)
+        )
+
+        # Max float
+        with pytest.raises(StreamlitAPIException) as exc:
+            value = 2e308
+            st.number_input("Label", value=value)
+        self.assertEqual(
+            "`value` (%s) must be <= 1.797e+308" % str(value), str(exc.value)
+        )
+
+        # Min float
+        with pytest.raises(StreamlitAPIException) as exc:
+            value = -2e308
+            st.number_input("Label", value=value)
+        self.assertEqual(
+            "`value` (%s) must be >= -1.797e+308" % str(value), str(exc.value)
+        )
diff --git a/lib/tests/streamlit/slider_test.py b/lib/tests/streamlit/slider_test.py
--- a/lib/tests/streamlit/slider_test.py
+++ b/lib/tests/streamlit/slider_test.py
@@ -15,11 +15,13 @@
 
 """slider unit test."""
 
+import pytest
+from parameterized import parameterized
+
+import streamlit as st
 from streamlit.errors import StreamlitAPIException
+from streamlit.js_number import JSNumber
 from tests import testutil
-import streamlit as st
-from parameterized import parameterized
-import pytest
 
 
 class SliderTest(testutil.DeltaGeneratorTestCase):
@@ -31,21 +33,21 @@ def test_just_label(self):
 
         c = self.get_delta_from_queue().new_element.slider
         self.assertEqual(c.label, "the label")
-        self.assertEqual(c.default, [0.0])
+        self.assertEqual(c.default, [0])
 
     @parameterized.expand(
         [
-            (0, [0.0], 0),
-            (0.0, [0.0], 0.0),
-            ((0, 1), [0.0, 1.0], (0, 1)),
-            ([0, 1], [0.0, 1.0], (0, 1)),
-            ((0.0, 1.0), [0.0, 1.0], (0.0, 1.0)),
-            ([0.0, 1.0], [0.0, 1.0], (0.0, 1.0)),
+            (1, [1], 1),  # int
+            ((0, 1), [0, 1], (0, 1)),  # int tuple
+            ([0, 1], [0, 1], (0, 1)),  # int list
+            (0.5, [0.5], 0.5),  # float
+            ((0.2, 0.5), [0.2, 0.5], (0.2, 0.5)),  # float tuple
+            ([0.2, 0.5], [0.2, 0.5], (0.2, 0.5)),  # float list
         ]
     )
-    def test_value_types(self, arg_value, proto_value, return_value):
+    def test_value_types(self, value, proto_value, return_value):
         """Test that it supports different types of values."""
-        ret = st.slider("the label", value=arg_value)
+        ret = st.slider("the label", value=value)
 
         self.assertEqual(ret, return_value)
 
@@ -56,25 +58,60 @@ def test_value_types(self, arg_value, proto_value, return_value):
     def test_value_greater_than_min(self):
         with pytest.raises(StreamlitAPIException) as exc_slider:
             st.slider("Slider label", 10, 100, 0)
-        assert (
-            "The default `value` of 0 must lie between the `min_value` of "
-            "10 and the `max_value` of 100, inclusively." == str(exc_slider.value)
+        self.assertEqual(
+            "The default `value` of 0 must lie between the `min_value` of 10 "
+            "and the `max_value` of 100, inclusively.",
+            str(exc_slider.value),
         )
 
     def test_value_smaller_than_max(self):
         with pytest.raises(StreamlitAPIException) as exc_slider:
             st.slider("Slider label", 10, 100, 101)
-        assert (
-            "The default `value` of 101 "
-            "must lie between the `min_value` of 10 "
-            "and the `max_value` of 100, inclusively." == str(exc_slider.value)
+        self.assertEqual(
+            "The default `value` of 101 must lie between the `min_value` of "
+            "10 and the `max_value` of 100, inclusively.",
+            str(exc_slider.value),
         )
 
     def test_max_min(self):
         with pytest.raises(StreamlitAPIException) as exc_slider:
             st.slider("Slider label", 101, 100, 101)
-        assert (
-            "The default `value` of 101 "
-            "must lie between the `min_value` of 101 "
-            "and the `max_value` of 100, inclusively." == str(exc_slider.value)
+        self.assertEqual(
+            "The default `value` of 101 must lie between the `min_value` of "
+            "101 and the `max_value` of 100, inclusively.",
+            str(exc_slider.value),
+        )
+
+    def test_value_out_of_bounds(self):
+        # Max int
+        with pytest.raises(StreamlitAPIException) as exc:
+            max_value = JSNumber.MAX_SAFE_INTEGER + 1
+            st.slider("Label", max_value=max_value)
+        self.assertEqual(
+            "`max_value` (%s) must be <= (1 << 53) - 1" % str(max_value), str(exc.value)
+        )
+
+        # Min int
+        with pytest.raises(StreamlitAPIException) as exc:
+            min_value = JSNumber.MIN_SAFE_INTEGER - 1
+            st.slider("Label", min_value=min_value)
+        self.assertEqual(
+            "`min_value` (%s) must be >= -((1 << 53) - 1)" % str(min_value),
+            str(exc.value),
+        )
+
+        # Max float
+        with pytest.raises(StreamlitAPIException) as exc:
+            max_value = 2e308
+            st.slider("Label", value=0.5, max_value=max_value)
+        self.assertEqual(
+            "`max_value` (%s) must be <= 1.797e+308" % str(max_value), str(exc.value)
+        )
+
+        # Min float
+        with pytest.raises(StreamlitAPIException) as exc:
+            min_value = -2e308
+            st.slider("Label", value=0.5, min_value=min_value)
+        self.assertEqual(
+            "`min_value` (%s) must be >= -1.797e+308" % str(min_value), str(exc.value)
         )
diff --git a/lib/tests/streamlit/vega_lite_test.py b/lib/tests/streamlit/vega_lite_test.py
--- a/lib/tests/streamlit/vega_lite_test.py
+++ b/lib/tests/streamlit/vega_lite_test.py
@@ -177,7 +177,8 @@ def test_width_inside_spec(self):
 
         c = self.get_delta_from_queue().new_element.vega_lite_chart
         self.assertDictEqual(
-            json.loads(c.spec), merge_dicts(autosize_spec, {"mark": "rect", "width": 200})
+            json.loads(c.spec),
+            merge_dicts(autosize_spec, {"mark": "rect", "width": 200}),
         )