diff --git a/CHANGELOG.md b/CHANGELOG.md index adcd44308d..b64611477c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Fixed - Fix `hex_to_rgb` parsing of 3-digit shorthand hexadecimal colors such as `#FFF` [[#5662](https://github.com/plotly/plotly.py/pull/5662)], with thanks to @genrichez for the contribution! +- Fix concurrent first access to lazily initialized graph object properties, which could raise `ValueError("Invalid value")` [[#3441](https://github.com/plotly/plotly.py/issues/3441)] ## [6.9.0] - 2026-07-09 diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index ec4038b7fa..4cf298338e 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -4784,24 +4784,27 @@ def __getitem__(self, prop): if isinstance(validator, CompoundValidator): if self._compound_props.get(prop, None) is None: # Init compound objects - self._compound_props[prop] = validator.data_class( - _parent=self, plotly_name=prop - ) + child = validator.data_class(_parent=self, plotly_name=prop) # Update plotly_name value in case the validator applies # non-standard name (e.g. imagedefaults instead of image) - self._compound_props[prop]._plotly_name = prop + child._plotly_name = prop + # Concurrent readers may both construct a child, but they + # must use the first child published to the cache. + self._compound_props.setdefault(prop, child) return validator.present(self._compound_props[prop]) elif isinstance(validator, (CompoundArrayValidator, BaseDataValidator)): if self._compound_array_props.get(prop, None) is None: # Init list of compound objects if self._props is not None: - self._compound_array_props[prop] = [ + children = [ validator.data_class(_parent=self) for _ in self._props.get(prop, []) ] else: - self._compound_array_props[prop] = [] + children = [] + # Keep every concurrent reader attached to the same list. + self._compound_array_props.setdefault(prop, children) return validator.present(self._compound_array_props[prop]) elif self._props is not None and prop in self._props: diff --git a/tests/test_core/test_graph_objs/test_thread_safety.py b/tests/test_core/test_graph_objs/test_thread_safety.py new file mode 100644 index 0000000000..c84f4499b8 --- /dev/null +++ b/tests/test_core/test_graph_objs/test_thread_safety.py @@ -0,0 +1,89 @@ +import threading + +import pytest + +import plotly.graph_objs as go + + +@pytest.mark.parametrize( + ("target_type", "target_kwargs", "property_name", "child_property", "expected"), + [ + pytest.param( + go.Layout, + {"font": {"family": "Arial"}}, + "font", + "family", + "Arial", + id="compound-property", + ), + pytest.param( + go.layout.template.Data, + {"bar": [{"name": "template bar"}]}, + "bar", + "name", + "template bar", + id="compound-array-property", + ), + ], +) +def test_concurrent_first_read_keeps_children_attached( + monkeypatch, + target_type, + target_kwargs, + property_name, + child_property, + expected, +): + target = target_type(**target_kwargs) + target._compound_props.pop(property_name, None) + target._compound_array_props.pop(property_name, None) + validator = target._get_validator(property_name) + data_class = validator.data_class + constructors_ready = threading.Barrier(2) + first_read_complete = threading.Event() + second_read_complete = threading.Event() + children = [] + results = [] + errors = [] + + def build_child(*args, **kwargs): + child = data_class(*args, **kwargs) + constructors_ready.wait(timeout=5) + if threading.current_thread().name == "second-reader": + if not first_read_complete.wait(timeout=5): + raise TimeoutError("First reader did not receive its child") + return child + + monkeypatch.setattr(validator, "_data_class", build_child) + + def read_child(): + try: + value = target[property_name] + if threading.current_thread().name == "first-reader": + first_read_complete.set() + if not second_read_complete.wait(timeout=5): + raise TimeoutError("Second reader did not receive its child") + else: + second_read_complete.set() + + child = value[0] if isinstance(value, tuple) else value + children.append(child) + results.append(child[child_property]) + except Exception as error: + errors.append(error) + first_read_complete.set() + second_read_complete.set() + + workers = [ + threading.Thread(target=read_child, name="first-reader"), + threading.Thread(target=read_child, name="second-reader"), + ] + for worker in workers: + worker.start() + for worker in workers: + worker.join(timeout=5) + + assert all(not worker.is_alive() for worker in workers) + assert not errors + assert children[0] is children[1] + assert results == [expected, expected]