From 4ec6b8c20dbfa99cf3fbb23f56077e3bd9232d1f Mon Sep 17 00:00:00 2001 From: SHADDY Date: Sun, 12 Apr 2026 15:19:12 +0530 Subject: [PATCH 1/4] 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. --- plotly/basedatatypes.py | 77 +++++++++++++ .../test_get_computed_values.py | 109 ++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 tests/test_core/test_update_objects/test_get_computed_values.py diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index 6821eeb8d09..47de8caf1db 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -3481,6 +3481,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 diff --git a/tests/test_core/test_update_objects/test_get_computed_values.py b/tests/test_core/test_update_objects/test_get_computed_values.py new file mode 100644 index 00000000000..0eed49b7a22 --- /dev/null +++ b/tests/test_core/test_update_objects/test_get_computed_values.py @@ -0,0 +1,109 @@ +import sys +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) From ee7a7e4f2a8d887ef4ba6ce75401c977ecdb6bea Mon Sep 17 00:00:00 2001 From: SHADDY Date: Sun, 12 Apr 2026 15:26:15 +0530 Subject: [PATCH 2/4] Added a new entry to the Unreleased section of CHANGELOG.md to document the new feature. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d04f16552c..f3c002888d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 From b8b4889bc9d82ebf6016571baa922d3e2e2ba30e Mon Sep 17 00:00:00 2001 From: SHADDY Date: Sun, 12 Apr 2026 15:58:56 +0530 Subject: [PATCH 3/4] Trigger CI From 0072ad05b10d44e72000dbeadfcde9b0b4d01651 Mon Sep 17 00:00:00 2001 From: SHADDY Date: Sun, 12 Apr 2026 16:06:20 +0530 Subject: [PATCH 4/4] Fix formatting for get_computed_values implementation --- plotly/basedatatypes.py | 10 ++++++---- .../test_get_computed_values.py | 16 +++++++--------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index 47de8caf1db..ca3c6488858 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -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 diff --git a/tests/test_core/test_update_objects/test_get_computed_values.py b/tests/test_core/test_update_objects/test_get_computed_values.py index 0eed49b7a22..7a4169f36ff 100644 --- a/tests/test_core/test_update_objects/test_get_computed_values.py +++ b/tests/test_core/test_update_objects/test_get_computed_values.py @@ -1,4 +1,3 @@ -import sys from unittest.mock import MagicMock import importlib.metadata @@ -29,11 +28,11 @@ def test_get_computed_axis_ranges_basic(self): computed = fig.get_computed_values(include=["axis_ranges"]) # Verify results - expected = { - "axis_ranges": {"xaxis": [0.8, 3.2], "yaxis": [8.0, 32.0]} - } + 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) + 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 @@ -85,7 +84,8 @@ def test_invalid_include_parameter(self): # Test unsupported key and deterministic error message with self.assertRaisesRegex( - ValueError, r"Unsupported key 'invalid'.*Supported keys are: \['axis_ranges'\]" + ValueError, + r"Unsupported key 'invalid'.*Supported keys are: \['axis_ranges'\]", ): fig.get_computed_values(include=["invalid"]) @@ -103,7 +103,5 @@ def test_safe_extraction_handling(self): computed = fig.get_computed_values(include=["axis_ranges"]) - expected = { - "axis_ranges": {"xaxis2": [1, 2]} - } + expected = {"axis_ranges": {"xaxis2": [1, 2]}} self.assertEqual(computed, expected)