Skip to content

Commit b31ae3d

Browse files
committed
Don't leave empty dict containers behind when nested properties are set to None
Setting a nested property such as marker.colorbar.thicknessmode to None left a residual empty {} container in the figure's props, which was then emitted in the figure JSON. For splom traces this residual marker.colorbar {} made a later Plotly.restyle of colorbar attributes misconvert the colorbar thickness and collapse the scatter-matrix layout. _set_in now treats removal of a non-existent path as a no-op and prunes emptied dict parents, and property assignment to None prunes emptied compound-child dicts (lists are preserved as positional placeholders). Fixes #5615
1 parent 5cdb606 commit b31ae3d

3 files changed

Lines changed: 83 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
66

77
### Fixed
88
- Raise a clear `ValueError` when an unsupported marginal plot type is passed to Plotly Express, instead of failing later with a cryptic `'NoneType' object has no attribute 'constructor'` message [[#5625](https://github.com/plotly/plotly.py/pull/5625)], with thanks to @eugen-goebel for the contribution!
9+
- Stop emitting leftover empty `{}` containers when a nested property is set to `None`, which could make a later `Plotly.restyle` of `marker.colorbar` attributes collapse the scatter-matrix layout [[#5615](https://github.com/plotly/plotly.py/issues/5615)]
910

1011

1112
## [6.8.0] - 2026-06-03

plotly/basedatatypes.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1904,21 +1904,32 @@ def _set_in(d, key_path_str, v):
19041904
# This variable will be assigned to the parent of the next key path
19051905
# element currently being processed
19061906
val_parent = d
1907+
parents = []
19071908

19081909
# Initialize parent dict or list of value to be assigned
19091910
# -----------------------------------------------------
19101911
for kp, key_path_el in enumerate(key_path[:-1]):
19111912
# Extend val_parent list if needed
19121913
if isinstance(val_parent, list) and isinstance(key_path_el, int):
1914+
if v is None and not 0 <= key_path_el < len(val_parent):
1915+
return False
1916+
19131917
while len(val_parent) <= key_path_el:
19141918
val_parent.append(None)
19151919

19161920
elif isinstance(val_parent, dict) and key_path_el not in val_parent:
1921+
if v is None:
1922+
return False
1923+
19171924
if isinstance(key_path[kp + 1], int):
19181925
val_parent[key_path_el] = []
19191926
else:
19201927
val_parent[key_path_el] = {}
19211928

1929+
elif not isinstance(val_parent, (dict, list)) and v is None:
1930+
return False
1931+
1932+
parents.append((val_parent, key_path_el))
19221933
val_parent = val_parent[key_path_el]
19231934

19241935
# Assign value to final parent dict or list
@@ -1945,6 +1956,16 @@ def _set_in(d, key_path_str, v):
19451956
# we can pop the key, which alters parent
19461957
val_parent.pop(last_key)
19471958
val_changed = True
1959+
1960+
for parent, key in reversed(parents):
1961+
child = parent[key]
1962+
if isinstance(child, dict) and not child:
1963+
if isinstance(parent, dict):
1964+
parent.pop(key)
1965+
else:
1966+
break
1967+
else:
1968+
break
19481969
elif isinstance(val_parent, list):
19491970
if isinstance(last_key, int) and 0 <= last_key < len(val_parent):
19501971
# Parent is a list and last_key is a valid index so we
@@ -4607,6 +4628,28 @@ def _init_child_props(self, child):
46074628
else:
46084629
raise ValueError("Invalid child with name: %s" % child.plotly_name)
46094630

4631+
def _prune_empty_child_props(self, child):
4632+
"""
4633+
Remove a compound child's properties dict if it is empty.
4634+
4635+
Compound array elements can rely on empty dict placeholders for index
4636+
position, so this only prunes scalar compound properties.
4637+
"""
4638+
if (
4639+
child.plotly_name in self._compound_props
4640+
and self._compound_props[child.plotly_name] is child
4641+
and self._props is not None
4642+
and self._props.get(child.plotly_name) == {}
4643+
):
4644+
self._props.pop(child.plotly_name)
4645+
4646+
if (
4647+
not self._props
4648+
and self.parent is not None
4649+
and isinstance(self.parent, BasePlotlyType)
4650+
):
4651+
self.parent._prune_empty_child_props(self)
4652+
46104653
def _get_child_prop_defaults(self, child):
46114654
"""
46124655
Return default properties dict for child
@@ -5287,6 +5330,14 @@ def _set_prop(self, prop, val):
52875330
# Send property update message
52885331
self._send_prop_set(prop, val)
52895332

5333+
if (
5334+
not self._in_batch_mode
5335+
and not self._props
5336+
and self.parent is not None
5337+
and isinstance(self.parent, BasePlotlyType)
5338+
):
5339+
self.parent._prune_empty_child_props(self)
5340+
52905341
# val is valid value
52915342
# ------------------
52925343
else:

tests/test_core/test_figure_messages/test_plotly_restyle.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,34 @@ def test_plotly_restyle_multi_trace(self):
8282
self.figure._send_restyle_msg.assert_called_once_with(
8383
{"marker": {"color": "green"}, "name": "MARKER 1"}, trace_indexes=[0, 1]
8484
)
85+
86+
def test_plotly_restyle_nested_none_does_not_create_empty_parents(self):
87+
figure = go.Figure(data=[go.Splom()])
88+
figure._send_restyle_msg = MagicMock()
89+
expected = figure.to_plotly_json()
90+
91+
figure.plotly_restyle({"marker.colorbar.thicknessmode": None}, trace_indexes=0)
92+
93+
assert figure.to_plotly_json() == expected
94+
figure._send_restyle_msg.assert_not_called()
95+
96+
def test_property_assignment_nested_none_prunes_empty_parents(self):
97+
figure = go.Figure(
98+
data=[
99+
go.Splom(
100+
dimensions=[{"values": [1, 2]}, {"values": [3, 4]}],
101+
marker={
102+
"color": [1, 2],
103+
"colorbar": {"thicknessmode": "pixels"},
104+
},
105+
)
106+
]
107+
)
108+
figure._send_restyle_msg = MagicMock()
109+
110+
figure.data[0].marker.colorbar.thicknessmode = None
111+
112+
assert figure.to_plotly_json()["data"][0]["marker"] == {"color": [1, 2]}
113+
figure._send_restyle_msg.assert_called_once_with(
114+
{"marker.colorbar.thicknessmode": [None]}, trace_indexes=0
115+
)

0 commit comments

Comments
 (0)