Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased

### Added
- Add `get_computed_values()` method to `BaseFigure` for programmatically accessing values calculated by Plotly.js (starting with computed axis ranges) [[#5552](https://github.com/plotly/plotly.py/issues/5552)]


## [6.7.0] - 2026-04-09

Expand Down
87 changes: 83 additions & 4 deletions plotly/basedatatypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1447,10 +1447,12 @@ def _select_layout_subplots_by_prefix(

layout_keys_filters = [
lambda k: k.startswith(prefix) and self.layout[k] is not None,
lambda k: row is None
or container_to_row_col.get(k, (None, None, None))[0] == row,
lambda k: col is None
or container_to_row_col.get(k, (None, None, None))[1] == col,
lambda k: (
row is None or container_to_row_col.get(k, (None, None, None))[0] == row
),
lambda k: (
col is None or container_to_row_col.get(k, (None, None, None))[1] == col
),
lambda k: (
secondary_y is None
or container_to_row_col.get(k, (None, None, None))[2] == secondary_y
Expand Down Expand Up @@ -3481,6 +3483,83 @@ def full_figure_for_development(self, warn=True, as_dict=False):

return pio.full_figure_for_development(self, warn, as_dict)

def get_computed_values(self, include=None):
"""
Retrieve values calculated or derived by Plotly.js during plotting.

This method provides a lightweight interface to access information that is
not explicitly defined in the source figure but is computed by the
rendering engine (e.g., autoranged axis limits).

Note: This initial implementation relies on full_figure_for_development()
(via Kaleido) to extract computed values. While the returned object is
standard and lightweight, the underlying process triggers a full background
render.

Parameters
----------
include: list or tuple of str
The calculated values to retrieve. Supported keys include:
- 'axis_ranges': The final [min, max] range for each axis.
If None, defaults to ['axis_ranges'].

Returns
-------
dict
A dictionary containing the requested computed values.

Examples
--------
>>> import plotly.graph_objects as go
>>> fig = go.Figure(go.Scatter(x=[1, 2, 3], y=[10, 20, 30]))
>>> fig.get_computed_values(include=['axis_ranges'])
{'axis_ranges': {'xaxis': [0.8, 3.2], 'yaxis': [8.0, 32.0]}}
"""
# Validate input
# --------------
if include is None:
include = ["axis_ranges"]

if not isinstance(include, (list, tuple)):
raise ValueError(
"The 'include' parameter must be a list or tuple of strings."
)

# Early exit for empty include
if not include:
return {}

supported_keys = ["axis_ranges"]
for key in include:
if key not in supported_keys:
raise ValueError(
f"Unsupported key '{key}' in 'include' parameter. "
f"Supported keys are: {supported_keys}"
)

# Retrieve full figure state
# --------------------------
# We use as_dict=True for efficient traversal of the layout
full_fig_dict = self.full_figure_for_development(warn=False, as_dict=True)
full_layout = full_fig_dict.get("layout", {})

result = {}

# Extract axis ranges
# -------------------
if "axis_ranges" in include:
axis_ranges = {}
for key, val in full_layout.items():
if key.startswith(("xaxis", "yaxis")):
# Safety checks for axis object and range property
if isinstance(val, dict) and "range" in val:
# Explicit conversion to list for JSON serialization consistency
axis_ranges[key] = list(val["range"])

result["axis_ranges"] = axis_ranges

return result

def write_json(self, *args, **kwargs):
"""
Convert a figure to JSON and write it to a file or writeable
Expand Down
107 changes: 107 additions & 0 deletions tests/test_core/test_update_objects/test_get_computed_values.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
from unittest.mock import MagicMock
import importlib.metadata

# Mock importlib.metadata.version BEFORE importing plotly to avoid PackageNotFoundError
if not hasattr(importlib.metadata.version, "assert_called"):
importlib.metadata.version = MagicMock(return_value="6.7.0")

from unittest import TestCase
import plotly.graph_objects as go


class TestGetComputedValues(TestCase):
def test_get_computed_axis_ranges_basic(self):
# Create a simple figure
fig = go.Figure(go.Scatter(x=[1, 2, 3], y=[10, 20, 30]))

# Mock full_figure_for_development to return a dict with computed ranges
mock_full_fig = {
"layout": {
"xaxis": {"range": [0.8, 3.2], "type": "linear"},
"yaxis": {"range": [8.0, 32.0], "type": "linear"},
"template": {},
}
}
fig.full_figure_for_development = MagicMock(return_value=mock_full_fig)

# Call get_computed_values
computed = fig.get_computed_values(include=["axis_ranges"])

# Verify results
expected = {"axis_ranges": {"xaxis": [0.8, 3.2], "yaxis": [8.0, 32.0]}}
self.assertEqual(computed, expected)
fig.full_figure_for_development.assert_called_once_with(
warn=False, as_dict=True
)

def test_get_computed_axis_ranges_multi_axis(self):
# Create a figure with multiple axes
fig = go.Figure()

# Mock full_figure_for_development (returning tuples to test conversion)
mock_full_fig = {
"layout": {
"xaxis": {"range": (0, 1)},
"yaxis": {"range": (0, 10)},
"xaxis2": {"range": (0, 100)},
"yaxis2": {"range": (50, 60)},
}
}
fig.full_figure_for_development = MagicMock(return_value=mock_full_fig)

computed = fig.get_computed_values(include=["axis_ranges"])

# Ranges should be converted to lists
expected = {
"axis_ranges": {
"xaxis": [0, 1],
"yaxis": [0, 10],
"xaxis2": [0, 100],
"yaxis2": [50, 60],
}
}
self.assertEqual(computed, expected)
# Verify result values are indeed lists
for val in computed["axis_ranges"].values():
self.assertIsInstance(val, list)

def test_empty_include(self):
fig = go.Figure()
fig.full_figure_for_development = MagicMock()

# Should return empty dict early without calling full_figure
computed = fig.get_computed_values(include=[])

self.assertEqual(computed, {})
fig.full_figure_for_development.assert_not_called()

def test_invalid_include_parameter(self):
fig = go.Figure()

# Test non-list/tuple input
with self.assertRaisesRegex(ValueError, "must be a list or tuple of strings"):
fig.get_computed_values(include="axis_ranges")

# Test unsupported key and deterministic error message
with self.assertRaisesRegex(
ValueError,
r"Unsupported key 'invalid'.*Supported keys are: \['axis_ranges'\]",
):
fig.get_computed_values(include=["invalid"])

def test_safe_extraction_handling(self):
# Test that non-dict or missing 'range' values are skipped
fig = go.Figure()
mock_full_fig = {
"layout": {
"xaxis": "not-a-dict",
"yaxis": {"no-range": True},
"xaxis2": {"range": [1, 2]},
}
}
fig.full_figure_for_development = MagicMock(return_value=mock_full_fig)

computed = fig.get_computed_values(include=["axis_ranges"])

expected = {"axis_ranges": {"xaxis2": [1, 2]}}
self.assertEqual(computed, expected)