Skip to content

Commit 4ec6b8c

Browse files
committed
Add get_computed_values() method for accessing computed axis ranges
Implements an initial version of get_computed_values() as proposed in #5552. Supports retrieval of computed axis ranges (axis_ranges) using a lightweight interface built on top of full_figure_for_development(). Includes input validation, safe layout traversal, JSON-serializable outputs, and unit tests for basic, multi-axis, and edge case scenarios.
1 parent 54e2f84 commit 4ec6b8c

File tree

2 files changed

+186
-0
lines changed

2 files changed

+186
-0
lines changed

plotly/basedatatypes.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3481,6 +3481,83 @@ def full_figure_for_development(self, warn=True, as_dict=False):
34813481

34823482
return pio.full_figure_for_development(self, warn, as_dict)
34833483

3484+
def get_computed_values(self, include=None):
3485+
"""
3486+
Retrieve values calculated or derived by Plotly.js during plotting.
3487+
3488+
This method provides a lightweight interface to access information that is
3489+
not explicitly defined in the source figure but is computed by the
3490+
rendering engine (e.g., autoranged axis limits).
3491+
3492+
Note: This initial implementation relies on full_figure_for_development()
3493+
(via Kaleido) to extract computed values. While the returned object is
3494+
standard and lightweight, the underlying process triggers a full background
3495+
render.
3496+
3497+
Parameters
3498+
----------
3499+
include: list or tuple of str
3500+
The calculated values to retrieve. Supported keys include:
3501+
- 'axis_ranges': The final [min, max] range for each axis.
3502+
If None, defaults to ['axis_ranges'].
3503+
3504+
Returns
3505+
-------
3506+
dict
3507+
A dictionary containing the requested computed values.
3508+
3509+
Examples
3510+
--------
3511+
>>> import plotly.graph_objects as go
3512+
>>> fig = go.Figure(go.Scatter(x=[1, 2, 3], y=[10, 20, 30]))
3513+
>>> fig.get_computed_values(include=['axis_ranges'])
3514+
{'axis_ranges': {'xaxis': [0.8, 3.2], 'yaxis': [8.0, 32.0]}}
3515+
"""
3516+
# Validate input
3517+
# --------------
3518+
if include is None:
3519+
include = ["axis_ranges"]
3520+
3521+
if not isinstance(include, (list, tuple)):
3522+
raise ValueError(
3523+
"The 'include' parameter must be a list or tuple of strings."
3524+
)
3525+
3526+
# Early exit for empty include
3527+
if not include:
3528+
return {}
3529+
3530+
supported_keys = ["axis_ranges"]
3531+
for key in include:
3532+
if key not in supported_keys:
3533+
raise ValueError(
3534+
f"Unsupported key '{key}' in 'include' parameter. "
3535+
f"Supported keys are: {supported_keys}"
3536+
)
3537+
3538+
# Retrieve full figure state
3539+
# --------------------------
3540+
# We use as_dict=True for efficient traversal of the layout
3541+
full_fig_dict = self.full_figure_for_development(warn=False, as_dict=True)
3542+
full_layout = full_fig_dict.get("layout", {})
3543+
3544+
result = {}
3545+
3546+
# Extract axis ranges
3547+
# -------------------
3548+
if "axis_ranges" in include:
3549+
axis_ranges = {}
3550+
for key, val in full_layout.items():
3551+
if key.startswith(("xaxis", "yaxis")):
3552+
# Safety checks for axis object and range property
3553+
if isinstance(val, dict) and "range" in val:
3554+
# Explicit conversion to list for JSON serialization consistency
3555+
axis_ranges[key] = list(val["range"])
3556+
3557+
result["axis_ranges"] = axis_ranges
3558+
3559+
return result
3560+
34843561
def write_json(self, *args, **kwargs):
34853562
"""
34863563
Convert a figure to JSON and write it to a file or writeable
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import sys
2+
from unittest.mock import MagicMock
3+
import importlib.metadata
4+
5+
# Mock importlib.metadata.version BEFORE importing plotly to avoid PackageNotFoundError
6+
if not hasattr(importlib.metadata.version, "assert_called"):
7+
importlib.metadata.version = MagicMock(return_value="6.7.0")
8+
9+
from unittest import TestCase
10+
import plotly.graph_objects as go
11+
12+
13+
class TestGetComputedValues(TestCase):
14+
def test_get_computed_axis_ranges_basic(self):
15+
# Create a simple figure
16+
fig = go.Figure(go.Scatter(x=[1, 2, 3], y=[10, 20, 30]))
17+
18+
# Mock full_figure_for_development to return a dict with computed ranges
19+
mock_full_fig = {
20+
"layout": {
21+
"xaxis": {"range": [0.8, 3.2], "type": "linear"},
22+
"yaxis": {"range": [8.0, 32.0], "type": "linear"},
23+
"template": {},
24+
}
25+
}
26+
fig.full_figure_for_development = MagicMock(return_value=mock_full_fig)
27+
28+
# Call get_computed_values
29+
computed = fig.get_computed_values(include=["axis_ranges"])
30+
31+
# Verify results
32+
expected = {
33+
"axis_ranges": {"xaxis": [0.8, 3.2], "yaxis": [8.0, 32.0]}
34+
}
35+
self.assertEqual(computed, expected)
36+
fig.full_figure_for_development.assert_called_once_with(warn=False, as_dict=True)
37+
38+
def test_get_computed_axis_ranges_multi_axis(self):
39+
# Create a figure with multiple axes
40+
fig = go.Figure()
41+
42+
# Mock full_figure_for_development (returning tuples to test conversion)
43+
mock_full_fig = {
44+
"layout": {
45+
"xaxis": {"range": (0, 1)},
46+
"yaxis": {"range": (0, 10)},
47+
"xaxis2": {"range": (0, 100)},
48+
"yaxis2": {"range": (50, 60)},
49+
}
50+
}
51+
fig.full_figure_for_development = MagicMock(return_value=mock_full_fig)
52+
53+
computed = fig.get_computed_values(include=["axis_ranges"])
54+
55+
# Ranges should be converted to lists
56+
expected = {
57+
"axis_ranges": {
58+
"xaxis": [0, 1],
59+
"yaxis": [0, 10],
60+
"xaxis2": [0, 100],
61+
"yaxis2": [50, 60],
62+
}
63+
}
64+
self.assertEqual(computed, expected)
65+
# Verify result values are indeed lists
66+
for val in computed["axis_ranges"].values():
67+
self.assertIsInstance(val, list)
68+
69+
def test_empty_include(self):
70+
fig = go.Figure()
71+
fig.full_figure_for_development = MagicMock()
72+
73+
# Should return empty dict early without calling full_figure
74+
computed = fig.get_computed_values(include=[])
75+
76+
self.assertEqual(computed, {})
77+
fig.full_figure_for_development.assert_not_called()
78+
79+
def test_invalid_include_parameter(self):
80+
fig = go.Figure()
81+
82+
# Test non-list/tuple input
83+
with self.assertRaisesRegex(ValueError, "must be a list or tuple of strings"):
84+
fig.get_computed_values(include="axis_ranges")
85+
86+
# Test unsupported key and deterministic error message
87+
with self.assertRaisesRegex(
88+
ValueError, r"Unsupported key 'invalid'.*Supported keys are: \['axis_ranges'\]"
89+
):
90+
fig.get_computed_values(include=["invalid"])
91+
92+
def test_safe_extraction_handling(self):
93+
# Test that non-dict or missing 'range' values are skipped
94+
fig = go.Figure()
95+
mock_full_fig = {
96+
"layout": {
97+
"xaxis": "not-a-dict",
98+
"yaxis": {"no-range": True},
99+
"xaxis2": {"range": [1, 2]},
100+
}
101+
}
102+
fig.full_figure_for_development = MagicMock(return_value=mock_full_fig)
103+
104+
computed = fig.get_computed_values(include=["axis_ranges"])
105+
106+
expected = {
107+
"axis_ranges": {"xaxis2": [1, 2]}
108+
}
109+
self.assertEqual(computed, expected)

0 commit comments

Comments
 (0)