plotly / plotly.py

The interactive graphing library for Python :sparkles: This project now includes Plotly Express!
https://plotly.com/python/
MIT License
15.9k stars 2.53k forks source link

Add support for internationalization of multilingual figures #2475

Open merwok opened 4 years ago

merwok commented 4 years ago

Hello! This feature request comes from a project that uses Dash, but the problems/changes are in plotly modules so I opened the ticket here.

Context: The goal is to render a dashboard with various figures (graphs and maps) in different languages. At the moment, it is required to build 4 Dash layouts to serve 4 languages, making development less convenient (can’t use standard translation tools) and requiring 4 times the memory for hosting (this was profiled). I am not the author of the project, but for a hackathon I looked at using the Flask i18n extension, and found out that Dash is not directly compatible; then after finding a compat trick, I found out that it wasn‘t enough.

Details: This is a Dash app https://github.com/jeremymoreau/covid19mtl/blob/master/app/__init__.py viewable at https://covid19mtl.ca/en . (It’s not using latest plotly or dash for stability or familiarity I suppose.)

1) I looked for Flask i18n guides and tried to add Flask-i18n which uses a classic gettext approach with a LazyString class that is rendered to a localized string at request time.

https://github.com/jeremymoreau/covid19mtl/pull/21/files#diff-1f9f743421417fb2794c88447083a031R12-R27

LazyString was rejected during JSON encoding. I looked at this method https://github.com/plotly/plotly.py/blob/v4.6.0/packages/python/plotly/_plotly_utils/utils.py#L100 which led me to define as_plotly_json https://github.com/jeremymoreau/covid19mtl/pull/21/files#diff-828aeb2697b4cf1e4ba7120b38810cccR5-R9 to be compatible, and it worked for Dash HTML components (example https://github.com/jeremymoreau/covid19mtl/pull/21/files#diff-4599052b1ffcd344c72d72ce59a64186R399 — this is in template.py file that github hides by default even if there is a line link :frowning_face:)

Now, how to upstream this? plotly doesn’t know about flask, but dash itself doesn’t transform to JSON, that code is in _plotly_utils and is not extensible, so I couldn’t send a quick PR. «Not extensible» I mean that a list of conversion functions is hard-coded and tried in order. Other approaches like Pyramid’s adapters or https://pypi.org/project/json-encoder/ based on single-dispatch can be extended from downstream code. But these rely on types (object class), whereas the existing approach checks for the presence of attributes (special method like tolist) or special values (pandas funky NaN and such).

Would you be open to reworking this? I think a hybrid approach could work (detect special methods like as_plotly_json or fancy scipy stack objects first, then delegate to a single-dispatched function that checks types to handle datetime, decimal, etc, and expose that function so that Dash can register a conversion function for the LazyString type.

2) The LazyString class is rejected by checks in plotly figure classes, so we can’t translate all the labels. This blocked my efforts. I didn’t try to make a str subclass because built-in classes often have shortcuts in codebases (starting with CPython itself!) so your overriden methods are not getting called when you expect and it’s a dead end. We didn’t try applying translations using JavaScript because it would require to write more gettext integration, and some of the labels are inside SVG sub-documents which wouldn’t be easy to get.

How can we satisfy plotly type checks when passing a custom class that should be transformed (i.e. call str) at render time?

Thanks for reading and considering this! I am sure more than one person would like to make multilingual plots and dashboards, so it would be great if I could contribute some useful code upstream.

merwok commented 2 years ago

Ping for i18n

olejorgenb commented 11 months ago

Thanks! To make the solution a bit more discoverable:

from flask_babel import gettext, LazyString

class CustomLazyString(LazyString):
    """Subclass compatible with Dash views."""

    def to_plotly_json(self):
        return str(self)

def lazy_gettext(string, **variables):
    """Use this for strings in dash components."""
    return CustomLazyString(gettext, string, **variables)

Unfortunately it does not work for dash callbacks since the output is explicitly validated in dash/_validate.py:271 (_validate_value) (which does not accept arbitrary values with a to_plotly_json)