Repository: plotly/plotly.py

Issue:
Unicode symbols in jupyter notebook plots (plotly3)
Hello!

I am using plotly3 for plots in Jupyter notebook. If I want to add an unicode symbol in an axis title it doesn't work, however, it worked in the plotly2.

In the following code
```
import plotly.graph_objs as go
layout=go.Layout()
layout.xaxis.title = u"μ"
fig = go.FigureWidget(layout=layout)
fig
```
I get the error " 'ascii' codec can't encode character u'\u03bc' in position 0: ordinal not in range(128)"


I use anaconda environment on OpenSuse Leap 42.3
conda=4.5.11
ipykernel=4.9.0
ipython=5.8.0
plotly=3.4.1
plotly-orca=1.1.1
python=2.7.15


Discussion:
Thanks for taking the time to report this @Gorfar, this looks like a problem on Python 2.

On Python 3.6:
```python
import plotly.graph_objs as go
layout=go.Layout()
layout.xaxis.title = u"μ"
fig = go.FigureWidget(layout=layout)
fig
```
![newplot 2](https://user-images.githubusercontent.com/15064365/49219366-02f9a700-f3a1-11e8-88b9-a2050647e6c1.png)

On Python 2.7:
```
---------------------------------------------------------------------------
UnicodeEncodeError                        Traceback (most recent call last)
<ipython-input-1-e0358ce99901> in <module>()
      1 import plotly.graph_objs as go
      2 layout=go.Layout()
----> 3 layout.xaxis.title = u"μ"
      4 fig = go.FigureWidget(layout=layout)
      5 fig

/Users/jmmease/Plotly/repos/plotly.py/plotly/basedatatypes.py in __setattr__(self, prop, value)
   2836                 prop in self._validators):
   2837             # Let known properties and private properties through
-> 2838             super(BasePlotlyType, self).__setattr__(prop, value)
   2839         else:
   2840             # Raise error on unknown public properties

/Users/jmmease/Plotly/repos/plotly.py/plotly/graph_objs/layout/_xaxis.py in title(self, val)
   1798     @title.setter
   1799     def title(self, val):
-> 1800         self['title'] = val
   1801 
   1802     # titlefont

/Users/jmmease/Plotly/repos/plotly.py/plotly/basedatatypes.py in __setitem__(self, prop, value)
   2808             # ### Handle simple property ###
   2809             else:
-> 2810                 self._set_prop(prop, value)
   2811 
   2812         # Handle non-scalar case

/Users/jmmease/Plotly/repos/plotly.py/plotly/basedatatypes.py in _set_prop(self, prop, val)
   3044                 return
   3045             else:
-> 3046                 raise err
   3047 
   3048         # val is None

UnicodeEncodeError: 'ascii' codec can't encode character u'\u03bc' in position 0: ordinal not in range(128)
```

There is some exception handling obscuring the strack trace, but I think the issue is with the use of `str` in the `StringValidator` class.

https://github.com/plotly/plotly.py/blob/618ee2d1cbd3eb37272ea6bb1f29c5625892c1d6/_plotly_utils/basevalidators.py#L968-L969

https://github.com/plotly/plotly.py/blob/618ee2d1cbd3eb37272ea6bb1f29c5625892c1d6/_plotly_utils/basevalidators.py#L941-L944

We should only really do these `str` conversions if the input value isn't alreadyof type `str` or `unicode`.

Resolving patch:
diff --git a/_plotly_utils/basevalidators.py b/_plotly_utils/basevalidators.py
--- a/_plotly_utils/basevalidators.py
+++ b/_plotly_utils/basevalidators.py
@@ -874,6 +874,17 @@ def __init__(self,
         self.array_ok = array_ok
         self.values = values
 
+    @staticmethod
+    def to_str_or_unicode_or_none(v):
+        """
+        Convert a value to a string if it's not None, a string,
+        or a unicode (on Python 2).
+        """
+        if v is None or isinstance(v, string_types):
+            return v
+        else:
+            return str(v)
+
     def description(self):
         desc = ("""\
     The '{plotly_name}' property is a string and must be specified as:"""
@@ -938,10 +949,8 @@ def validate_coerce(self, v):
 
             elif is_simple_array(v):
                 if not self.strict:
-                    # Convert all elements other than None to strings
-                    # Leave None as is, Plotly.js will decide how to handle
-                    # these null values.
-                    v = [str(e) if e is not None else None for e in v]
+                    v = [StringValidator.to_str_or_unicode_or_none(e)
+                         for e in v]
 
                 # Check no_blank
                 if self.no_blank:
@@ -962,12 +971,14 @@ def validate_coerce(self, v):
                 if not isinstance(v, string_types):
                     self.raise_invalid_val(v)
             else:
-                if not isinstance(v, string_types + (int, float)):
+                if isinstance(v, string_types):
+                    pass
+                elif isinstance(v, (int, float)):
+                    # Convert value to a string
+                    v = str(v)
+                else:
                     self.raise_invalid_val(v)
 
-                # Convert value to a string
-                v = str(v)
-
             if self.no_blank and len(v) == 0:
                 self.raise_invalid_val(v)
 


Test patch:
diff --git a/_plotly_utils/tests/validators/test_string_validator.py b/_plotly_utils/tests/validators/test_string_validator.py
--- a/_plotly_utils/tests/validators/test_string_validator.py
+++ b/_plotly_utils/tests/validators/test_string_validator.py
@@ -1,4 +1,6 @@
 import pytest
+from six import string_types
+
 from _plotly_utils.basevalidators import StringValidator
 import numpy as np
 
@@ -50,9 +52,10 @@ def validator_no_blanks_aok():
 # ### Acceptance ###
 @pytest.mark.parametrize('val',
                          ['bar', 234, np.nan,
-                          'HELLO!!!', 'world!@#$%^&*()', ''])
+                          'HELLO!!!', 'world!@#$%^&*()', '', u'\u03BC'])
 def test_acceptance(val, validator):
-    assert validator.validate_coerce(val) == str(val)
+    expected = str(val) if not isinstance(val, string_types) else val
+    assert validator.validate_coerce(val) == expected
 
 
 # ### Rejection by value ###
@@ -85,7 +88,7 @@ def test_rejection_values(val, validator_values):
 
 # ### No blanks ###
 @pytest.mark.parametrize('val',
-                         ['bar', 'HELLO!!!', 'world!@#$%^&*()'])
+                         ['bar', 'HELLO!!!', 'world!@#$%^&*()',  u'\u03BC'])
 def test_acceptance_no_blanks(val, validator_no_blanks):
     assert validator_no_blanks.validate_coerce(val) == val
 
@@ -103,7 +106,7 @@ def test_rejection_no_blanks(val, validator_no_blanks):
 # ------
 # ### Acceptance ###
 @pytest.mark.parametrize('val',
-                         ['bar', 'HELLO!!!', 'world!@#$%^&*()', ''])
+                         ['bar', 'HELLO!!!', 'world!@#$%^&*()', '', u'\u03BC'])
 def test_acceptance_strict(val, validator_strict):
     assert validator_strict.validate_coerce(val) == val
 
@@ -122,7 +125,7 @@ def test_rejection_strict(val, validator_strict):
 # --------
 # ### Acceptance ###
 @pytest.mark.parametrize('val',
-                         ['foo', 'BAR', '', 'baz'])
+                         ['foo', 'BAR', '', 'baz', u'\u03BC'])
 def test_acceptance_aok_scalars(val, validator_aok):
     assert validator_aok.validate_coerce(val) == val
 
@@ -130,9 +133,9 @@ def test_acceptance_aok_scalars(val, validator_aok):
 @pytest.mark.parametrize('val',
                          ['foo',
                           ['foo'],
-                          np.array(['BAR', ''], dtype='object'),
+                          np.array(['BAR', '', u'\u03BC'], dtype='object'),
                           ['baz', 'baz', 'baz'],
-                          ['foo', None, 'bar']])
+                          ['foo', None, 'bar', u'\u03BC']])
 def test_acceptance_aok_list(val, validator_aok):
     coerce_val = validator_aok.validate_coerce(val)
     if isinstance(val, np.ndarray):
@@ -173,7 +176,7 @@ def test_rejection_aok_values(val, validator_aok_values):
                          ['123',
                           ['bar', 'HELLO!!!'],
                           np.array(['bar', 'HELLO!!!'], dtype='object'),
-                          ['world!@#$%^&*()']])
+                          ['world!@#$%^&*()', u'\u03BC']])
 def test_acceptance_no_blanks_aok(val, validator_no_blanks_aok):
     coerce_val = validator_no_blanks_aok.validate_coerce(val)
     if isinstance(val, np.ndarray):