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 diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index 6821eeb8d09..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 @@ -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 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..7a4169f36ff --- /dev/null +++ b/tests/test_core/test_update_objects/test_get_computed_values.py @@ -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)