Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
51 changes: 51 additions & 0 deletions _plotly_utils/basevalidators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions codegen/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}


Expand Down
6 changes: 3 additions & 3 deletions plotly/validators/_validators.json
Original file line number Diff line number Diff line change
Expand Up @@ -4110,7 +4110,7 @@
}
]
},
"superclass": "InfoArrayValidator"
"superclass": "MethodArgsValidator"
},
"layout.updatemenu.button.args": {
"params": {
Expand All @@ -4133,7 +4133,7 @@
}
]
},
"superclass": "InfoArrayValidator"
"superclass": "MethodArgsValidator"
},
"layout.updatemenu.borderwidth": {
"params": {
Expand Down Expand Up @@ -8327,7 +8327,7 @@
}
]
},
"superclass": "InfoArrayValidator"
"superclass": "MethodArgsValidator"
},
"layout.slider.pad": {
"params": {
Expand Down
88 changes: 88 additions & 0 deletions tests/test_core/test_graph_objs/test_method_args.py
Original file line number Diff line number Diff line change
@@ -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"}]
100 changes: 100 additions & 0 deletions tests/test_plotly_utils/validators/test_methodargs_validator.py
Original file line number Diff line number Diff line change
@@ -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