Skip to content

Commit 01fe2b8

Browse files
authored
Merge pull request #5691 from hb1915/fix-template-thread-safety
Fix thread safety of lazy graph object properties
2 parents 05579b0 + 1ba2769 commit 01fe2b8

3 files changed

Lines changed: 101 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
44

55
## Unreleased
66

7+
### Fixed
8+
- 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)], with thanks to @hb1915 for the contribution!
9+
710

811
## [7.1.0] - 2026-09-15
912

plotly/basedatatypes.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4694,24 +4694,27 @@ def __getitem__(self, prop):
46944694
if isinstance(validator, CompoundValidator):
46954695
if self._compound_props.get(prop, None) is None:
46964696
# Init compound objects
4697-
self._compound_props[prop] = validator.data_class(
4698-
_parent=self, plotly_name=prop
4699-
)
4697+
child = validator.data_class(_parent=self, plotly_name=prop)
47004698
# Update plotly_name value in case the validator applies
47014699
# non-standard name (e.g. imagedefaults instead of image)
4702-
self._compound_props[prop]._plotly_name = prop
4700+
child._plotly_name = prop
4701+
# Concurrent readers may both construct a child, but they
4702+
# must use the first child published to the cache.
4703+
self._compound_props.setdefault(prop, child)
47034704

47044705
return validator.present(self._compound_props[prop])
47054706
elif isinstance(validator, (CompoundArrayValidator, BaseDataValidator)):
47064707
if self._compound_array_props.get(prop, None) is None:
47074708
# Init list of compound objects
47084709
if self._props is not None:
4709-
self._compound_array_props[prop] = [
4710+
children = [
47104711
validator.data_class(_parent=self)
47114712
for _ in self._props.get(prop, [])
47124713
]
47134714
else:
4714-
self._compound_array_props[prop] = []
4715+
children = []
4716+
# Keep every concurrent reader attached to the same list.
4717+
self._compound_array_props.setdefault(prop, children)
47154718

47164719
return validator.present(self._compound_array_props[prop])
47174720
elif self._props is not None and prop in self._props:
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import threading
2+
3+
import pytest
4+
5+
import plotly.graph_objs as go
6+
7+
8+
@pytest.mark.parametrize(
9+
("target_type", "target_kwargs", "property_name", "child_property", "expected"),
10+
[
11+
pytest.param(
12+
go.Layout,
13+
{"font": {"family": "Arial"}},
14+
"font",
15+
"family",
16+
"Arial",
17+
id="compound-property",
18+
),
19+
pytest.param(
20+
go.layout.template.Data,
21+
{"bar": [{"name": "template bar"}]},
22+
"bar",
23+
"name",
24+
"template bar",
25+
id="compound-array-property",
26+
),
27+
],
28+
)
29+
def test_concurrent_first_read_keeps_children_attached(
30+
monkeypatch,
31+
target_type,
32+
target_kwargs,
33+
property_name,
34+
child_property,
35+
expected,
36+
):
37+
target = target_type(**target_kwargs)
38+
target._compound_props.pop(property_name, None)
39+
target._compound_array_props.pop(property_name, None)
40+
validator = target._get_validator(property_name)
41+
data_class = validator.data_class
42+
constructors_ready = threading.Barrier(2)
43+
first_read_complete = threading.Event()
44+
second_read_complete = threading.Event()
45+
children = []
46+
results = []
47+
errors = []
48+
49+
def build_child(*args, **kwargs):
50+
child = data_class(*args, **kwargs)
51+
constructors_ready.wait(timeout=5)
52+
if threading.current_thread().name == "second-reader":
53+
if not first_read_complete.wait(timeout=5):
54+
raise TimeoutError("First reader did not receive its child")
55+
return child
56+
57+
monkeypatch.setattr(validator, "_data_class", build_child)
58+
59+
def read_child():
60+
try:
61+
value = target[property_name]
62+
if threading.current_thread().name == "first-reader":
63+
first_read_complete.set()
64+
if not second_read_complete.wait(timeout=5):
65+
raise TimeoutError("Second reader did not receive its child")
66+
else:
67+
second_read_complete.set()
68+
69+
child = value[0] if isinstance(value, tuple) else value
70+
children.append(child)
71+
results.append(child[child_property])
72+
except Exception as error:
73+
errors.append(error)
74+
first_read_complete.set()
75+
second_read_complete.set()
76+
77+
workers = [
78+
threading.Thread(target=read_child, name="first-reader"),
79+
threading.Thread(target=read_child, name="second-reader"),
80+
]
81+
for worker in workers:
82+
worker.start()
83+
for worker in workers:
84+
worker.join(timeout=5)
85+
86+
assert all(not worker.is_alive() for worker in workers)
87+
assert not errors
88+
assert children[0] is children[1]
89+
assert results == [expected, expected]

0 commit comments

Comments
 (0)