From b31ae3d8912c588b70115ca9bff582271e3a3d01 Mon Sep 17 00:00:00 2001 From: Ahmet Faruk Ilhan Date: Mon, 6 Jul 2026 01:33:18 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + plotly/basedatatypes.py | 51 +++++++++++++++++++ .../test_plotly_restyle.py | 31 +++++++++++ 3 files changed, 83 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 222706d86b4..c160d01e844 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Fixed - 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! +- 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)] ## [6.8.0] - 2026-06-03 diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index ec4038b7fa4..55280adb086 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -1904,21 +1904,32 @@ def _set_in(d, key_path_str, v): # This variable will be assigned to the parent of the next key path # element currently being processed val_parent = d + parents = [] # Initialize parent dict or list of value to be assigned # ----------------------------------------------------- for kp, key_path_el in enumerate(key_path[:-1]): # Extend val_parent list if needed if isinstance(val_parent, list) and isinstance(key_path_el, int): + if v is None and not 0 <= key_path_el < len(val_parent): + return False + while len(val_parent) <= key_path_el: val_parent.append(None) elif isinstance(val_parent, dict) and key_path_el not in val_parent: + if v is None: + return False + if isinstance(key_path[kp + 1], int): val_parent[key_path_el] = [] else: val_parent[key_path_el] = {} + elif not isinstance(val_parent, (dict, list)) and v is None: + return False + + parents.append((val_parent, key_path_el)) val_parent = val_parent[key_path_el] # Assign value to final parent dict or list @@ -1945,6 +1956,16 @@ def _set_in(d, key_path_str, v): # we can pop the key, which alters parent val_parent.pop(last_key) val_changed = True + + for parent, key in reversed(parents): + child = parent[key] + if isinstance(child, dict) and not child: + if isinstance(parent, dict): + parent.pop(key) + else: + break + else: + break elif isinstance(val_parent, list): if isinstance(last_key, int) and 0 <= last_key < len(val_parent): # Parent is a list and last_key is a valid index so we @@ -4607,6 +4628,28 @@ def _init_child_props(self, child): else: raise ValueError("Invalid child with name: %s" % child.plotly_name) + def _prune_empty_child_props(self, child): + """ + Remove a compound child's properties dict if it is empty. + + Compound array elements can rely on empty dict placeholders for index + position, so this only prunes scalar compound properties. + """ + if ( + child.plotly_name in self._compound_props + and self._compound_props[child.plotly_name] is child + and self._props is not None + and self._props.get(child.plotly_name) == {} + ): + self._props.pop(child.plotly_name) + + if ( + not self._props + and self.parent is not None + and isinstance(self.parent, BasePlotlyType) + ): + self.parent._prune_empty_child_props(self) + def _get_child_prop_defaults(self, child): """ Return default properties dict for child @@ -5287,6 +5330,14 @@ def _set_prop(self, prop, val): # Send property update message self._send_prop_set(prop, val) + if ( + not self._in_batch_mode + and not self._props + and self.parent is not None + and isinstance(self.parent, BasePlotlyType) + ): + self.parent._prune_empty_child_props(self) + # val is valid value # ------------------ else: diff --git a/tests/test_core/test_figure_messages/test_plotly_restyle.py b/tests/test_core/test_figure_messages/test_plotly_restyle.py index c90df7e7b57..4bd458b0e76 100644 --- a/tests/test_core/test_figure_messages/test_plotly_restyle.py +++ b/tests/test_core/test_figure_messages/test_plotly_restyle.py @@ -82,3 +82,34 @@ def test_plotly_restyle_multi_trace(self): self.figure._send_restyle_msg.assert_called_once_with( {"marker": {"color": "green"}, "name": "MARKER 1"}, trace_indexes=[0, 1] ) + + def test_plotly_restyle_nested_none_does_not_create_empty_parents(self): + figure = go.Figure(data=[go.Splom()]) + figure._send_restyle_msg = MagicMock() + expected = figure.to_plotly_json() + + figure.plotly_restyle({"marker.colorbar.thicknessmode": None}, trace_indexes=0) + + assert figure.to_plotly_json() == expected + figure._send_restyle_msg.assert_not_called() + + def test_property_assignment_nested_none_prunes_empty_parents(self): + figure = go.Figure( + data=[ + go.Splom( + dimensions=[{"values": [1, 2]}, {"values": [3, 4]}], + marker={ + "color": [1, 2], + "colorbar": {"thicknessmode": "pixels"}, + }, + ) + ] + ) + figure._send_restyle_msg = MagicMock() + + figure.data[0].marker.colorbar.thicknessmode = None + + assert figure.to_plotly_json()["data"][0]["marker"] == {"color": [1, 2]} + figure._send_restyle_msg.assert_called_once_with( + {"marker.colorbar.thicknessmode": [None]}, trace_indexes=0 + )