diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f31a86d2d3..b3a5144b48b 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, supporting `axis_ranges` and `axis_types` retrieval via a single-pass layout traversal [[#5552](https://github.com/plotly/plotly.py/issues/5552)] + ### Fixed - Update tests to be compatible with numpy 2.4 [[#5522](https://github.com/plotly/plotly.py/pull/5522)], with thanks to @thunze for the contribution! diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index 6821eeb8d09..08b252be226 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -3481,6 +3481,91 @@ 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, detected axis types). + + 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. + - 'axis_types': The detected type for each axis (e.g. 'linear', + 'log', 'date', 'category'). + 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]}} + + >>> fig.get_computed_values(include=['axis_ranges', 'axis_types']) + {'axis_ranges': {'xaxis': [0.8, 3.2], 'yaxis': [8.0, 32.0]}, + 'axis_types': {'xaxis': 'linear', 'yaxis': 'linear'}} + """ + # 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", "axis_types"] + 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", {}) + + # Initialize result buckets for each requested key + result = {} + if "axis_ranges" in include: + result["axis_ranges"] = {} + if "axis_types" in include: + result["axis_types"] = {} + + # Single-pass traversal: extract all requested properties in one iteration + # ------------------------------------------------------------------------- + for key, val in full_layout.items(): + if key.startswith(("xaxis", "yaxis")) and isinstance(val, dict): + if "axis_ranges" in include and "range" in val: + # Explicit conversion to list for JSON serialization consistency + result["axis_ranges"][key] = list(val["range"]) + if "axis_types" in include and "type" in val: + result["axis_types"][key] = val["type"] + + 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..9b926ae512f --- /dev/null +++ b/tests/test_core/test_update_objects/test_get_computed_values.py @@ -0,0 +1,152 @@ +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', 'axis_types'\]", + ): + 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) + + def test_get_computed_axis_types(self): + # Verify standalone extraction of axis types + fig = go.Figure() + mock_full_fig = { + "layout": { + "xaxis": {"range": [0, 10], "type": "linear"}, + "yaxis": {"range": [0, 100], "type": "log"}, + "xaxis2": {"type": "date"}, + } + } + fig.full_figure_for_development = MagicMock(return_value=mock_full_fig) + + computed = fig.get_computed_values(include=["axis_types"]) + + expected = {"axis_types": {"xaxis": "linear", "yaxis": "log", "xaxis2": "date"}} + self.assertEqual(computed, expected) + # axis_ranges should not be present when not requested + self.assertNotIn("axis_ranges", computed) + + def test_get_computed_values_combined(self): + # Verify that requesting multiple keys returns all of them correctly + fig = go.Figure() + mock_full_fig = { + "layout": { + "xaxis": {"range": [0, 1], "type": "linear"}, + "yaxis": {"range": [0, 10], "type": "log"}, + # axis with no range — should appear in axis_types but not axis_ranges + "xaxis2": {"type": "date"}, + } + } + fig.full_figure_for_development = MagicMock(return_value=mock_full_fig) + + computed = fig.get_computed_values(include=["axis_ranges", "axis_types"]) + + self.assertEqual( + computed["axis_ranges"], + {"xaxis": [0, 1], "yaxis": [0, 10]}, + ) + self.assertEqual( + computed["axis_types"], + {"xaxis": "linear", "yaxis": "log", "xaxis2": "date"}, + ) + # Verify single call to the rendering engine despite two keys + fig.full_figure_for_development.assert_called_once()