diff --git a/CHANGELOG.md b/CHANGELOG.md index c92c3ea6d64..e6b12e0e0f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Fix Plotly Express mutating lists passed to the `x` or `y` arguments in wide mode [[#5727](https://github.com/plotly/plotly.py/pull/5727)], with thanks to @cpruijsen for the contribution! - Fix `px.sunburst`, `px.treemap` and `px.icicle` listing sectors in a different order on every run when `path` is used with a Polars DataFrame; sectors now follow their order of first appearance for all dataframe backends [[#5766](https://github.com/plotly/plotly.py/pull/5766)], with thanks to @Irahan2 for the contribution! - Fix `mpl_to_plotly` conversion of matplotlib contour lines and line collections: close contour rings ending with `Z` codes, support dash styles, convert date x-axes, separate disjoint subpaths with `None` separators, hide line collection traces from the legend by default, and group consecutive same-style lines into single traces [[#5770](https://github.com/plotly/plotly.py/pull/5770)], with thanks to @robertoffmoura for the contribution! +- Fix axis and plot titles disappearing when set with a plain string (e.g. `{"xaxis.title": "Time"}` or `{"xaxis": {"title": "Time"}}`) in the `args` of `updatemenus` buttons or `sliders` steps; such titles are now converted to `title.text` as they are elsewhere in a figure [[#5032](https://github.com/plotly/plotly.py/issues/5032)] ## [7.1.0] - 2026-09-15 diff --git a/_plotly_utils/basevalidators.py b/_plotly_utils/basevalidators.py index 1ffc63e5add..ebde592d1d0 100644 --- a/_plotly_utils/basevalidators.py +++ b/_plotly_utils/basevalidators.py @@ -2232,6 +2232,57 @@ def present(self, v): return tuple(v) +class MethodArgsValidator(InfoArrayValidator): + """ + Validator for the `args` / `args2` properties of updatemenu buttons and + slider steps, which hold the arguments passed to a Plotly.js method + (e.g. `restyle`, `relayout`, `update`) when the control is activated. + + Plotly.js no longer accepts a string (or number) for a `title` attribute, + so, as `TitleValidator` does for figure properties, such values are mapped + to the `text` property of the title: + + - `{"xaxis.title": "Time"}` -> `{"xaxis.title.text": "Time"}` + - `{"xaxis": {"title": "Time"}}` -> `{"xaxis": {"title": {"text": "Time"}}}` + """ + + def validate_coerce(self, v): + v = super(MethodArgsValidator, self).validate_coerce(v) + if v is not None: + v = [ + MethodArgsValidator._coerce_title_strings(el, top_level=True) + for el in v + ] + return v + + @staticmethod + def _coerce_title_strings(v, top_level=False): + # Only dicts (i.e. attribute/value objects) are inspected, so strings + # in other positions (e.g. animation frame names) are left alone. + # A new dict is returned so that the user's input is not mutated. + if not isinstance(v, dict): + return v + + res = {} + for key, val in v.items(): + if ( + isinstance(key, str) + and key.split(".")[-1] == "title" + and isinstance(val, (str, int, float)) + ): + if top_level and key + ".text" not in v: + # Top-level keys are attribute paths, so only update the + # title text and keep the other title properties + # (e.g. font), as Plotly.js did before version 3. + res[key + ".text"] = val + continue + val = {"text": val} + else: + val = MethodArgsValidator._coerce_title_strings(val) + res[key] = val + return res + + class LiteralValidator(BaseValidator): """ Validator for readonly literal values diff --git a/codegen/utils.py b/codegen/utils.py index e62c5533795..bd238ff0cd3 100644 --- a/codegen/utils.py +++ b/codegen/utils.py @@ -179,6 +179,9 @@ def format_description(desc): "layout.template": "_plotly_utils.basevalidators.BaseTemplateValidator", "frame.data": "plotly.validators.DataValidator", "frame.layout": "plotly.validators.LayoutValidator", + "layout.updatemenu.button.args": "_plotly_utils.basevalidators.MethodArgsValidator", + "layout.updatemenu.button.args2": "_plotly_utils.basevalidators.MethodArgsValidator", + "layout.slider.step.args": "_plotly_utils.basevalidators.MethodArgsValidator", } diff --git a/plotly/validators/_validators.json b/plotly/validators/_validators.json index c923bc67e6a..921abaa1199 100644 --- a/plotly/validators/_validators.json +++ b/plotly/validators/_validators.json @@ -4110,7 +4110,7 @@ } ] }, - "superclass": "InfoArrayValidator" + "superclass": "MethodArgsValidator" }, "layout.updatemenu.button.args": { "params": { @@ -4133,7 +4133,7 @@ } ] }, - "superclass": "InfoArrayValidator" + "superclass": "MethodArgsValidator" }, "layout.updatemenu.borderwidth": { "params": { @@ -8327,7 +8327,7 @@ } ] }, - "superclass": "InfoArrayValidator" + "superclass": "MethodArgsValidator" }, "layout.slider.pad": { "params": { diff --git a/tests/test_core/test_graph_objs/test_method_args.py b/tests/test_core/test_graph_objs/test_method_args.py new file mode 100644 index 00000000000..2ba1db2e030 --- /dev/null +++ b/tests/test_core/test_graph_objs/test_method_args.py @@ -0,0 +1,88 @@ +import json + +import pytest + +import plotly.graph_objects as go + + +# Plotly.js no longer accepts strings for title attributes, so plain string +# titles in updatemenu button and slider step args must be sent as title.text +# (https://github.com/plotly/plotly.py/issues/5032) +TITLE_ARGS = [ + ({"title": "T"}, {"title.text": "T"}), + ({"xaxis.title": "T"}, {"xaxis.title.text": "T"}), + ({"yaxis2.title": "T"}, {"yaxis2.title.text": "T"}), + ({"scene.xaxis.title": "T"}, {"scene.xaxis.title.text": "T"}), + ({"coloraxis.colorbar.title": "T"}, {"coloraxis.colorbar.title.text": "T"}), + ({"xaxis": {"title": "T"}}, {"xaxis": {"title": {"text": "T"}}}), + ({"title.text": "T"}, {"title.text": "T"}), + ({"xaxis.title.text": "T"}, {"xaxis.title.text": "T"}), + ({"xaxis": {"title": {"text": "T"}}}, {"xaxis": {"title": {"text": "T"}}}), +] + + +def serialized_layout(fig): + return json.loads(fig.to_json())["layout"] + + +@pytest.mark.parametrize("arg,expected", TITLE_ARGS) +def test_updatemenu_button_args_title(arg, expected): + fig = go.Figure( + layout=dict( + updatemenus=[ + dict( + buttons=[ + dict(method="relayout", args=[arg], args2=[arg]), + dict(method="update", args=[{"visible": [True]}, arg]), + ] + ) + ] + ) + ) + buttons = serialized_layout(fig)["updatemenus"][0]["buttons"] + assert buttons[0]["args"] == [expected] + assert buttons[0]["args2"] == [expected] + assert buttons[1]["args"] == [{"visible": [True]}, expected] + + +@pytest.mark.parametrize("arg,expected", TITLE_ARGS) +def test_slider_step_args_title(arg, expected): + fig = go.Figure() + fig.update_layout( + sliders=[ + dict( + steps=[ + dict(method="relayout", args=[arg]), + dict(method="update", args=[{"visible": [True]}, arg]), + ] + ) + ] + ) + steps = serialized_layout(fig)["sliders"][0]["steps"] + assert steps[0]["args"] == [expected] + assert steps[1]["args"] == [{"visible": [True]}, expected] + + +def test_button_args_title_property_assignment(): + button = go.layout.updatemenu.Button(method="relayout") + button.args = [{"xaxis.title": "T"}] + assert button.to_plotly_json()["args"] == [{"xaxis.title.text": "T"}] + + +def test_restyle_args_trace_title(): + fig = go.Figure( + layout=dict( + updatemenus=[ + dict( + buttons=[ + dict( + method="restyle", + args=[{"marker.colorbar.title": "T", "name": "N"}], + ) + ] + ) + ] + ) + ) + button = serialized_layout(fig)["updatemenus"][0]["buttons"][0] + assert button["args"] == [{"marker.colorbar.title.text": "T", "name": "N"}] diff --git a/tests/test_plotly_utils/validators/test_methodargs_validator.py b/tests/test_plotly_utils/validators/test_methodargs_validator.py new file mode 100644 index 00000000000..16a0127fb36 --- /dev/null +++ b/tests/test_plotly_utils/validators/test_methodargs_validator.py @@ -0,0 +1,100 @@ +import pytest +from _plotly_utils.basevalidators import MethodArgsValidator + + +# Fixtures + + +@pytest.fixture() +def validator(): + return MethodArgsValidator( + "args", + "layout.updatemenu.button", + items=[{"valType": "any"}, {"valType": "any"}, {"valType": "any"}], + free_length=True, + ) + + +# Tests + + +# Title strings are mapped to title.text +@pytest.mark.parametrize( + "val,expected", + [ + ({"title": "T"}, {"title.text": "T"}), + ({"xaxis.title": "T"}, {"xaxis.title.text": "T"}), + ({"yaxis2.title": 2}, {"yaxis2.title.text": 2}), + ({"scene.xaxis.title": "T"}, {"scene.xaxis.title.text": "T"}), + ( + {"coloraxis.colorbar.title": "T"}, + {"coloraxis.colorbar.title.text": "T"}, + ), + ({"xaxis": {"title": "T"}}, {"xaxis": {"title": {"text": "T"}}}), + ( + {"scene": {"xaxis": {"title": "T"}}}, + {"scene": {"xaxis": {"title": {"text": "T"}}}}, + ), + ( + {"marker": {"colorbar": {"title": "T"}}}, + {"marker": {"colorbar": {"title": {"text": "T"}}}}, + ), + ], +) +def test_title_strings_coerced(val, expected, validator): + assert validator.validate_coerce([val]) == [expected] + + +# Already valid title values are left alone +@pytest.mark.parametrize( + "val", + [ + {"title.text": "T"}, + {"xaxis.title.text": "T"}, + {"title": {"text": "T"}}, + {"xaxis.title": {"text": "T", "font": {"color": "red"}}}, + {"xaxis": {"title": {"text": "T"}}}, + {"xaxis.title": None}, + {"xaxis.title.font.color": "red"}, + ], +) +def test_title_objects_unchanged(val, validator): + assert validator.validate_coerce([val]) == [val] + + +# Strings for other properties are left alone +@pytest.mark.parametrize( + "val", + [ + [{"visible": [True, False]}, {"annotations[0].text": "T"}], + [{"name": "title"}, {"subtitle": "T", "xaxis.titlefont": "T"}], + [["frame1", "frame2"], {"mode": "immediate"}], + ["title", {"title": {"text": "T"}}], + [None, {"xaxis.range": [0, 1]}], + ], +) +def test_other_values_unchanged(val, validator): + assert validator.validate_coerce(val) == val + + +def test_title_string_in_each_arg(validator): + assert validator.validate_coerce( + [{"marker.colorbar.title": "A"}, {"xaxis.title": "B"}, [0]] + ) == [{"marker.colorbar.title.text": "A"}, {"xaxis.title.text": "B"}, [0]] + + +def test_existing_title_text_key_not_overwritten(validator): + val = {"xaxis.title": "A", "xaxis.title.text": "B"} + assert validator.validate_coerce([val]) == [ + {"xaxis.title": {"text": "A"}, "xaxis.title.text": "B"} + ] + + +def test_input_not_mutated(validator): + val = [{"xaxis.title": "A", "yaxis": {"title": "B"}}] + validator.validate_coerce(val) + assert val == [{"xaxis.title": "A", "yaxis": {"title": "B"}}] + + +def test_none(validator): + assert validator.validate_coerce(None) is None