Repository: plotly/plotly.py

Issue:
Missing clipping in n_colors
Hello!

I'm using the `n_colors` function to generate a list of colors in RGB space and I got the following error message trying to use the generated colors in a plot: 
```Received value: 'rgb(-2.842170943040401e-14, 255.00000000000003, 0.0)'```

Looking at the code for `n_colors`, I noticed that the computed float values are used directly, however there should be a clipping applied to make sure the values are between 0 and 255. 

You should be able to reproduce this issue like this:
```
from plotly.colors import n_colors
n_colors(lowcolor="rgb(255,0,0)", highcolor="rgb(0,255,0)", n_colors=14, colortype="rgb")
```


Discussion:

Resolving patch:
diff --git a/packages/python/plotly/_plotly_utils/colors/__init__.py b/packages/python/plotly/_plotly_utils/colors/__init__.py
--- a/packages/python/plotly/_plotly_utils/colors/__init__.py
+++ b/packages/python/plotly/_plotly_utils/colors/__init__.py
@@ -694,11 +694,19 @@ def n_colors(lowcolor, highcolor, n_colors, colortype="tuple"):
     incr_2 = diff_2 / (n_colors - 1)
     list_of_colors = []
 
+    def _constrain_color(c):
+        if c > 255.0:
+            return 255.0
+        elif c < 0.0:
+            return 0.0
+        else:
+            return c
+
     for index in range(n_colors):
         new_tuple = (
-            lowcolor[0] + (index * incr_0),
-            lowcolor[1] + (index * incr_1),
-            lowcolor[2] + (index * incr_2),
+            _constrain_color(lowcolor[0] + (index * incr_0)),
+            _constrain_color(lowcolor[1] + (index * incr_1)),
+            _constrain_color(lowcolor[2] + (index * incr_2)),
         )
         list_of_colors.append(new_tuple)
 


Test patch:
diff --git a/packages/python/plotly/plotly/tests/test_core/test_colors/test_colors.py b/packages/python/plotly/plotly/tests/test_core/test_colors/test_colors.py
--- a/packages/python/plotly/plotly/tests/test_core/test_colors/test_colors.py
+++ b/packages/python/plotly/plotly/tests/test_core/test_colors/test_colors.py
@@ -210,3 +210,30 @@ def test_sample_colorscale(self):
             colors.sample_colorscale("TuRbId_r", 12),
             colors.sequential.turbid_r,
         )
+
+    def test_n_colors(self):
+        # test that n_colors constrains values to between 0 and 255
+        generated_colorscale = colors.n_colors(
+            lowcolor="rgb(255,0,0)",
+            highcolor="rgb(0,255,0)",
+            n_colors=14,
+            colortype="rgb",
+        )
+        expected_colorscale = [
+            "rgb(255.0, 0.0, 0.0)",
+            "rgb(235.3846153846154, 19.615384615384617, 0.0)",
+            "rgb(215.76923076923077, 39.23076923076923, 0.0)",
+            "rgb(196.15384615384613, 58.846153846153854, 0.0)",
+            "rgb(176.53846153846155, 78.46153846153847, 0.0)",
+            "rgb(156.9230769230769, 98.07692307692308, 0.0)",
+            "rgb(137.3076923076923, 117.69230769230771, 0.0)",
+            "rgb(117.69230769230768, 137.30769230769232, 0.0)",
+            "rgb(98.07692307692307, 156.92307692307693, 0.0)",
+            "rgb(78.46153846153845, 176.53846153846155, 0.0)",
+            "rgb(58.84615384615384, 196.15384615384616, 0.0)",
+            "rgb(39.230769230769226, 215.76923076923077, 0.0)",
+            "rgb(19.615384615384585, 235.38461538461542, 0.0)",
+            "rgb(0.0, 255.0, 0.0)",
+        ]
+
+        self.assertEqual(generated_colorscale, expected_colorscale)