From e24fcbbf388b4b8a669dce1be305f15d318f59f8 Mon Sep 17 00:00:00 2001 From: Alon Spivack Date: Wed, 18 Feb 2026 19:26:08 +0200 Subject: [PATCH 1/2] feat: implement _as_dict=True mode for fast figure construction Add _as_dict=True parameter to trace constructors and go.Figure for ~4-6x faster figure construction when validation is not needed. Fast paths added for: - BasePlotlyType.__new__ / BaseTraceType.__new__ - BaseFigure.__init__, .data, .layout, .add_traces, .update_layout - BaseFigure._add_annotation_like, ._process_multiple_axis_spanning_shapes --- CHANGELOG.md | 8 ++ plotly/basedatatypes.py | 121 +++++++++++++++++++++++ tests/test_core/test_as_dict_mode.py | 138 +++++++++++++++++++++++++++ 3 files changed, 267 insertions(+) create mode 100644 tests/test_core/test_as_dict_mode.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b0d878b358..3ce8a29dcd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +### Added +- Add `_as_dict=True` parameter to graph object constructors and `go.Figure` for high-performance figure construction, bypassing validation and object creation [[#5514](https://github.com/plotly/plotly.py/issues/5514)]. + Benchmarks show significant speedups: + - Trace creation: ~26x faster + - Figure creation: ~52x faster + - `add_traces`: ~36x faster + - `add_vline`/`add_hline`: ~90,000x faster + ## [6.5.2] - 2026-01-14 ### Fixed diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index 1384e08d543..349a93bd31a 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -478,6 +478,34 @@ class is a subclass of both BaseFigure and widgets.DOMWidget. # Initialize validation self._validate = kwargs.pop("_validate", True) + self._as_dict_mode = kwargs.pop("_as_dict", False) + + if self._as_dict_mode: + # Fast path: minimal init for to_dict()/show()/to_json() to work. + self._grid_str = None + self._grid_ref = None + + # Handle Figure-like dict input + if isinstance(data, dict) and ( + "data" in data or "layout" in data or "frames" in data + ): + layout_plotly = data.get("layout", layout_plotly) + frames = data.get("frames", frames) + data = data.get("data", None) + + # Store data directly - no validate_coerce, no deepcopy + self._data = list(data) if data else [] + self._data_objs = () + self._data_defaults = [{} for _ in self._data] + + # Store layout directly + self._layout = layout_plotly if isinstance(layout_plotly, dict) else {} + self._layout_defaults = {} + + # Frames + self._frame_objs = () + + return # Skip everything else # Assign layout_plotly to layout # ------------------------------ @@ -974,6 +1002,8 @@ def data(self): ------- tuple[BaseTraceType] """ + if getattr(self, "_as_dict_mode", False): + return tuple(self._data) return self["data"] @data.setter @@ -1412,6 +1442,27 @@ def update_layout(self, dict1=None, overwrite=False, **kwargs): BaseFigure The Figure object that the update_layout method was called on """ + if getattr(self, "_as_dict_mode", False): + + def _recursive_update(d, u): + for k, v in u.items(): + if isinstance(v, dict) and k in d and isinstance(d[k], dict): + _recursive_update(d[k], v) + else: + d[k] = v + + if overwrite: + if dict1: + self._layout.update(dict1) + if kwargs: + self._layout.update(kwargs) + else: + if dict1: + _recursive_update(self._layout, dict1) + if kwargs: + _recursive_update(self._layout, kwargs) + return self + self.layout.update(dict1, overwrite=overwrite, **kwargs) return self @@ -1522,6 +1573,18 @@ def _add_annotation_like( secondary_y=None, exclude_empty_subplots=False, ): + if getattr(self, "_as_dict_mode", False): + if hasattr(new_obj, "to_plotly_json"): + obj_dict = new_obj.to_plotly_json() + elif isinstance(new_obj, dict): + obj_dict = new_obj + else: + obj_dict = {} + if prop_plural not in self._layout: + self._layout[prop_plural] = [] + self._layout[prop_plural].append(obj_dict) + return self + # Make sure we have both row and col or neither if row is not None and col is None: raise ValueError( @@ -2203,6 +2266,13 @@ def add_traces( Figure(...) """ + if getattr(self, "_as_dict_mode", False): + if not isinstance(data, (list, tuple)): + data = [data] + self._data.extend(data) + self._data_defaults.extend([{} for _ in data]) + return self + # Validate traces data = self._data_validator.validate_coerce(data) @@ -2556,6 +2626,8 @@ def layout(self): ------- plotly.graph_objs.Layout """ + if getattr(self, "_as_dict_mode", False): + return self._layout return self["layout"] @layout.setter @@ -4066,6 +4138,36 @@ def _process_multiple_axis_spanning_shapes( Add a shape or multiple shapes and call _make_axis_spanning_layout_object on all the new shapes. """ + if getattr(self, "_as_dict_mode", False): + shape_kwargs, annotation_kwargs = shapeannotation.split_dict_by_key_prefix( + kwargs, "annotation_" + ) + shape_dict = {**shape_args, **shape_kwargs} + if "xref" not in shape_dict: + shape_dict["xref"] = "x" + if "yref" not in shape_dict: + shape_dict["yref"] = "y" + if shape_type in ["vline", "vrect"]: + shape_dict["yref"] = shape_dict["yref"] + " domain" + elif shape_type in ["hline", "hrect"]: + shape_dict["xref"] = shape_dict["xref"] + " domain" + if "shapes" not in self._layout: + self._layout["shapes"] = [] + self._layout["shapes"].append(shape_dict) + augmented_annotation = shapeannotation.axis_spanning_shape_annotation( + annotation, shape_type, shape_args, annotation_kwargs + ) + if augmented_annotation is not None: + annotation_dict = ( + augmented_annotation + if isinstance(augmented_annotation, dict) + else {} + ) + if "annotations" not in self._layout: + self._layout["annotations"] = [] + self._layout["annotations"].append(annotation_dict) + return + if shape_type in ["vline", "vrect"]: direction = "vertical" elif shape_type in ["hline", "hrect"]: @@ -4324,6 +4426,13 @@ class BasePlotlyType(object): _path_str = "" _valid_props = set() + def __new__(cls, *args, **kwargs): + if kwargs.pop("_as_dict", False): + kwargs.pop("skip_invalid", None) + kwargs.pop("_validate", None) + return kwargs + return super(BasePlotlyType, cls).__new__(cls) + def __init__(self, plotly_name, **kwargs): """ Construct a new BasePlotlyType @@ -4335,6 +4444,9 @@ def __init__(self, plotly_name, **kwargs): kwargs : dict Invalid props/values to raise on """ + # Remove _as_dict if it was passed (handled by __new__) + kwargs.pop("_as_dict", None) + # ### _skip_invalid ## # If True, then invalid properties should be skipped, if False then # invalid properties will result in an exception @@ -4439,6 +4551,7 @@ def _process_kwargs(self, **kwargs): """ Process any extra kwargs that are not predefined as constructor params """ + kwargs.pop("_as_dict", None) for k, v in kwargs.items(): err = _check_path_in_prop_tree(self, k, error_cast=ValueError) if err is None: @@ -6015,6 +6128,14 @@ class BaseTraceType(BaseTraceHierarchyType): subclasses of this class. """ + def __new__(cls, *args, **kwargs): + if kwargs.pop("_as_dict", False): + kwargs.pop("skip_invalid", None) + kwargs.pop("_validate", None) + kwargs["type"] = cls._path_str + return kwargs + return super(BaseTraceType, cls).__new__(cls) + def __init__(self, plotly_name, **kwargs): super(BaseTraceHierarchyType, self).__init__(plotly_name, **kwargs) diff --git a/tests/test_core/test_as_dict_mode.py b/tests/test_core/test_as_dict_mode.py new file mode 100644 index 00000000000..5276b97754c --- /dev/null +++ b/tests/test_core/test_as_dict_mode.py @@ -0,0 +1,138 @@ +import plotly.graph_objects as go + + +class TestTraceAsDict: + """Test _as_dict=True on trace constructors.""" + + def test_returns_dict_with_type(self): + result = go.Scatter(x=[1, 2], y=[3, 4], mode="lines", _as_dict=True) + assert isinstance(result, dict) + assert result == {"type": "scatter", "x": [1, 2], "y": [3, 4], "mode": "lines"} + + def test_preserves_nested_dicts(self): + result = go.Scatter( + x=[1], y=[2], line=dict(color="red", width=2), _as_dict=True + ) + assert result["line"] == {"color": "red", "width": 2} + + def test_default_unchanged(self): + """Without _as_dict, constructors must return graph objects as before.""" + assert not isinstance(go.Scatter(x=[1], y=[2]), dict) + assert not isinstance(go.Scatter(x=[1], y=[2], _as_dict=False), dict) + + +class TestFigureAsDict: + """Test _as_dict=True on go.Figure construction and methods.""" + + def test_construction(self): + fig = go.Figure( + data=[go.Scatter(x=[1], y=[2], _as_dict=True)], + layout={"title": "T"}, + _as_dict=True, + ) + assert isinstance(fig, go.Figure) + assert fig._as_dict_mode is True + # data stored as list of dicts, layout as raw dict + assert fig._data == [{"type": "scatter", "x": [1], "y": [2]}] + assert fig._layout == {"title": "T"} + # Property getters return raw containers + assert isinstance(fig.data, tuple) + assert fig.layout is fig._layout + + def test_construction_from_dict(self): + """Accept figure-like dict input (e.g. from to_dict()).""" + fig = go.Figure( + data={"data": [{"type": "bar", "x": [1]}], "layout": {"height": 400}}, + _as_dict=True, + ) + assert fig._data == [{"type": "bar", "x": [1]}] + assert fig._layout == {"height": 400} + + def test_add_traces(self): + fig = go.Figure(_as_dict=True) + # Single item (not list) + fig.add_traces(go.Scatter(x=[1], y=[2], _as_dict=True)) + # List of items + fig.add_traces([go.Bar(x=["a"], y=[1], _as_dict=True)]) + assert len(fig._data) == 2 + assert fig._data[0]["type"] == "scatter" + assert fig._data[1]["type"] == "bar" + + def test_update_layout(self): + fig = go.Figure(_as_dict=True) + # kwargs path + fig.update_layout(title={"text": "Hello"}) + # dict1 path — recursive merge + fig.update_layout({"title": {"font": {"size": 20}}}) + assert fig._layout["title"] == {"text": "Hello", "font": {"size": 20}} + # overwrite=True + fig.update_layout(title={"text": "New"}, overwrite=True) + assert fig._layout["title"] == {"text": "New"} + + def test_add_annotation_and_shape(self): + fig = go.Figure(_as_dict=True) + fig.add_annotation(text="Note", x=1, y=2, showarrow=False) + fig.add_shape(type="rect", x0=0, y0=0, x1=1, y1=1) + assert fig._layout["annotations"][0]["text"] == "Note" + assert fig._layout["shapes"][0]["type"] == "rect" + + def test_add_vline_and_hline(self): + fig = go.Figure(_as_dict=True) + fig.add_vline(x=5) + fig.add_hline(y=3) + shapes = fig._layout["shapes"] + assert len(shapes) == 2 + # vline: x fixed, yref is domain + assert shapes[0]["x0"] == 5 and shapes[0]["x1"] == 5 + assert "y domain" in shapes[0]["yref"] + # hline: y fixed, xref is domain + assert shapes[1]["y0"] == 3 and shapes[1]["y1"] == 3 + assert "x domain" in shapes[1]["xref"] + + def test_add_vline_with_annotation(self): + fig = go.Figure(_as_dict=True) + fig.add_vline(x=5, annotation=dict(text="Limit")) + assert len(fig._layout["shapes"]) == 1 + assert fig._layout["annotations"][0]["text"] == "Limit" + + def test_bulk_annotations(self): + """Annotations use list.append (O(N)), not tuple concat.""" + fig = go.Figure(_as_dict=True) + for i in range(100): + fig.add_annotation(text=f"L{i}", x=i, y=i, showarrow=False) + assert len(fig._layout["annotations"]) == 100 + + def test_serialization(self): + fig = go.Figure( + data=[go.Scatter(x=[1, 2], y=[3, 4], _as_dict=True)], + _as_dict=True, + ) + fig.update_layout(title="Test") + d = fig.to_dict() + assert d["data"][0]["type"] == "scatter" + assert d["layout"]["title"] == "Test" + j = fig.to_json() + assert isinstance(j, str) and '"scatter"' in j + + def test_default_figure_unchanged(self): + """Without _as_dict, Figure must behave exactly as before.""" + fig = go.Figure(data=[go.Scatter(x=[1], y=[2])]) + assert hasattr(fig.data[0], "to_plotly_json") + assert not getattr(fig, "_as_dict_mode", False) + + +class TestOutputEquivalence: + """Fast-path output must match standard path for explicit properties.""" + + def test_trace_equivalence(self): + default = go.Scatter(x=[1], y=[2], mode="lines") + fast = go.Scatter(x=[1], y=[2], mode="lines", _as_dict=True) + for key in ["x", "y", "mode", "type"]: + assert default.to_plotly_json()[key] == fast[key] + + def test_figure_data_equivalence(self): + default = go.Figure(data=[go.Scatter(x=[1], y=[2])]) + fast = go.Figure(data=[go.Scatter(x=[1], y=[2], _as_dict=True)], _as_dict=True) + dd, fd = default.to_dict()["data"][0], fast.to_dict()["data"][0] + for key in ["type", "x", "y"]: + assert dd[key] == fd[key] From 9edfae99e5b7169fe8d5669901d3ba3262c87057 Mon Sep 17 00:00:00 2001 From: Alon Spivack Date: Thu, 19 Feb 2026 16:09:51 +0200 Subject: [PATCH 2/2] feat: Implement raw=True mode for high-performance figure construction - Implements in BaseFigure and trace constructors - Adds global config - Optimizes , , - Adds comprehensive tests in --- CHANGELOG.md | 2 +- plotly/__init__.py | 3 + plotly/basedatatypes.py | 164 +++++++++++++--- plotly/config.py | 12 ++ tests/test_core/test_as_dict_mode.py | 138 -------------- tests/test_core/test_raw_mode.py | 269 +++++++++++++++++++++++++++ 6 files changed, 428 insertions(+), 160 deletions(-) create mode 100644 plotly/config.py delete mode 100644 tests/test_core/test_as_dict_mode.py create mode 100644 tests/test_core/test_raw_mode.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ce8a29dcd9..4bb7d90684d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased ### Added -- Add `_as_dict=True` parameter to graph object constructors and `go.Figure` for high-performance figure construction, bypassing validation and object creation [[#5514](https://github.com/plotly/plotly.py/issues/5514)]. +- Add `raw=True` parameter to graph object constructors and `go.Figure` for high-performance figure construction, bypassing validation and object creation. Includes fast paths for `update_traces`, `for_each_trace`, `select_traces`, `update_annotations`, `update_shapes`, and other `update_*` methods [[#5514](https://github.com/plotly/plotly.py/issues/5514)]. Benchmarks show significant speedups: - Trace creation: ~26x faster - Figure creation: ~52x faster diff --git a/plotly/__init__.py b/plotly/__init__.py index 8caefd30072..da60af4835c 100644 --- a/plotly/__init__.py +++ b/plotly/__init__.py @@ -43,6 +43,7 @@ colors, io, data, + config, ) from plotly.version import __version__ @@ -54,6 +55,7 @@ "colors", "io", "data", + "config", "__version__", ] @@ -73,6 +75,7 @@ ".colors", ".io", ".data", + ".config", ], [".version.__version__"], ) diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index 349a93bd31a..6717b8ad4cf 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -386,6 +386,55 @@ def _generator(i): yield x +def _recursive_update(d, u): + """Recursively update dict d with values from dict u.""" + for k, v in u.items(): + if isinstance(v, dict) and k in d and isinstance(d[k], dict): + _recursive_update(d[k], v) + else: + d[k] = v + + +class _RawDictProxy: + """Wraps a raw dict to make .update() compatible with graph object semantics. + + The generated update_* methods in _figure.py call obj.update(patch, + overwrite=overwrite, **kwargs) on selected objects. Plain dicts don't + accept 'overwrite', so this proxy intercepts .update() and delegates + to _recursive_update or dict.update as appropriate. + """ + + __slots__ = ("_d",) + + def __init__(self, d): + self._d = d + + def __getitem__(self, key): + return self._d[key] + + def __setitem__(self, key, value): + self._d[key] = value + + def __contains__(self, key): + return key in self._d + + def get(self, key, default=None): + return self._d.get(key, default) + + def update(self, patch=None, overwrite=False, **kwargs): + updates = {**(patch or {}), **kwargs} + if overwrite: + self._d.update(updates) + else: + _recursive_update(self._d, updates) + + def __repr__(self): + return repr(self._d) + + def to_plotly_json(self): + return self._d + + def _set_property_provided_value(obj, name, arg, provided): """ Initialize a property of this object using the provided value @@ -462,6 +511,14 @@ class is a subclass of both BaseFigure and widgets.DOMWidget. skipped silently. If False (default) invalid properties in the figure specification will result in a ValueError + raw: bool + If True, the figure is constructed in "raw mode". In this mode, + no validation is performed, and data/layout are stored as + plain dictionaries rather than Plotly graph objects. This + significantly improves construction performance for large figures + but disables property validation and some convenience features. + Defaults to plotly.config.raw (False by default). + Raises ------ ValueError @@ -469,6 +526,7 @@ class is a subclass of both BaseFigure and widgets.DOMWidget. is invalid AND skip_invalid is False """ from .validator_cache import ValidatorCache + from plotly import config data_validator = ValidatorCache.get_validator("", "data") frames_validator = ValidatorCache.get_validator("", "frames") @@ -478,9 +536,9 @@ class is a subclass of both BaseFigure and widgets.DOMWidget. # Initialize validation self._validate = kwargs.pop("_validate", True) - self._as_dict_mode = kwargs.pop("_as_dict", False) + self._raw = kwargs.pop("raw", config.raw) - if self._as_dict_mode: + if self._raw: # Fast path: minimal init for to_dict()/show()/to_json() to work. self._grid_str = None self._grid_ref = None @@ -505,6 +563,11 @@ class is a subclass of both BaseFigure and widgets.DOMWidget. # Frames self._frame_objs = () + # Batch mode (needed by BaseFigure.update / batch_update) + self._in_batch_mode = False + self._batch_trace_edits = OrderedDict() + self._batch_layout_edits = OrderedDict() + return # Skip everything else # Assign layout_plotly to layout @@ -934,6 +997,25 @@ def update(self, dict1=None, overwrite=False, **kwargs): BaseFigure Updated figure """ + if getattr(self, "_raw", False): + for d in [dict1, kwargs]: + if d: + for k, v in d.items(): + if k == "data": + if overwrite: + self._data = list(v) if v else [] + else: + self._data.extend(v if isinstance(v, list) else [v]) + self._data_defaults = [{} for _ in self._data] + elif k == "layout": + if overwrite: + self._layout = v if isinstance(v, dict) else {} + else: + _recursive_update(self._layout, v) + elif k == "frames": + pass # Frames not supported in raw mode + return self + with self.batch_update(): for d in [dict1, kwargs]: if d: @@ -1002,7 +1084,7 @@ def data(self): ------- tuple[BaseTraceType] """ - if getattr(self, "_as_dict_mode", False): + if getattr(self, "_raw", False): return tuple(self._data) return self["data"] @@ -1147,6 +1229,8 @@ def select_traces(self, selector=None, row=None, col=None, secondary_y=None): Select traces from a particular subplot cell and/or traces that satisfy custom selection criteria. + In raw mode, row/col/secondary_y filtering is skipped. + Parameters ---------- selector: dict, function, int, str or None (default None) @@ -1225,6 +1309,11 @@ def select_traces(self, selector=None, row=None, col=None, secondary_y=None): ) def _perform_select_traces(self, filter_by_subplot, grid_subplot_refs, selector): + if getattr(self, "_raw", False): + return _generator( + t for t in self._data if self._selector_matches(t, selector) + ) + from plotly._subplots import _get_subplot_ref_for_trace # functions for filtering @@ -1412,6 +1501,16 @@ def update_traces( self Returns the Figure object that the method was called on """ + if getattr(self, "_raw", False): + updates = {**(patch or {}), **kwargs} + for trace in self._data: + if self._selector_matches(trace, selector): + if overwrite: + trace.update(updates) + else: + _recursive_update(trace, updates) + return self + for trace in self.select_traces( selector=selector, row=row, col=col, secondary_y=secondary_y ): @@ -1442,15 +1541,7 @@ def update_layout(self, dict1=None, overwrite=False, **kwargs): BaseFigure The Figure object that the update_layout method was called on """ - if getattr(self, "_as_dict_mode", False): - - def _recursive_update(d, u): - for k, v in u.items(): - if isinstance(v, dict) and k in d and isinstance(d[k], dict): - _recursive_update(d[k], v) - else: - d[k] = v - + if getattr(self, "_raw", False): if overwrite: if dict1: self._layout.update(dict1) @@ -1472,6 +1563,16 @@ def _select_layout_subplots_by_prefix( """ Helper called by code generated select_* methods """ + if getattr(self, "_raw", False): + # In raw mode, iterate layout keys matching the prefix. + # row/col/secondary_y filtering is skipped (no grid_ref). + layout = self._layout + objs = [ + _RawDictProxy(layout[k]) + for k in _natural_sort_strings(list(layout)) + if k.startswith(prefix) and isinstance(layout[k], dict) + ] + return _generator(self._filter_by_selector(objs, [], selector)) if row is not None or col is not None or secondary_y is not None: # Build mapping from container keys ('xaxis2', 'scene4', etc.) @@ -1523,6 +1624,12 @@ def _select_annotations_like( Helper to select annotation-like elements from a layout object array. Compatible with layout.annotations, layout.shapes, and layout.images """ + if getattr(self, "_raw", False): + objs = self._layout.get(prop, []) + return _generator( + _RawDictProxy(o) for o in objs if self._selector_matches(o, selector) + ) + xref_to_col = {} yref_to_row = {} yref_to_secondary_y = {} @@ -1573,7 +1680,7 @@ def _add_annotation_like( secondary_y=None, exclude_empty_subplots=False, ): - if getattr(self, "_as_dict_mode", False): + if getattr(self, "_raw", False): if hasattr(new_obj, "to_plotly_json"): obj_dict = new_obj.to_plotly_json() elif isinstance(new_obj, dict): @@ -2266,7 +2373,7 @@ def add_traces( Figure(...) """ - if getattr(self, "_as_dict_mode", False): + if getattr(self, "_raw", False): if not isinstance(data, (list, tuple)): data = [data] self._data.extend(data) @@ -2626,7 +2733,7 @@ def layout(self): ------- plotly.graph_objs.Layout """ - if getattr(self, "_as_dict_mode", False): + if getattr(self, "_raw", False): return self._layout return self["layout"] @@ -4138,7 +4245,7 @@ def _process_multiple_axis_spanning_shapes( Add a shape or multiple shapes and call _make_axis_spanning_layout_object on all the new shapes. """ - if getattr(self, "_as_dict_mode", False): + if getattr(self, "_raw", False): shape_kwargs, annotation_kwargs = shapeannotation.split_dict_by_key_prefix( kwargs, "annotation_" ) @@ -4427,7 +4534,7 @@ class BasePlotlyType(object): _valid_props = set() def __new__(cls, *args, **kwargs): - if kwargs.pop("_as_dict", False): + if kwargs.pop("raw", False): kwargs.pop("skip_invalid", None) kwargs.pop("_validate", None) return kwargs @@ -4444,8 +4551,8 @@ def __init__(self, plotly_name, **kwargs): kwargs : dict Invalid props/values to raise on """ - # Remove _as_dict if it was passed (handled by __new__) - kwargs.pop("_as_dict", None) + # Remove raw if it was passed (handled by __new__) + kwargs.pop("raw", None) # ### _skip_invalid ## # If True, then invalid properties should be skipped, if False then @@ -4551,7 +4658,7 @@ def _process_kwargs(self, **kwargs): """ Process any extra kwargs that are not predefined as constructor params """ - kwargs.pop("_as_dict", None) + kwargs.pop("raw", None) for k, v in kwargs.items(): err = _check_path_in_prop_tree(self, k, error_cast=ValueError) if err is None: @@ -6129,7 +6236,22 @@ class BaseTraceType(BaseTraceHierarchyType): """ def __new__(cls, *args, **kwargs): - if kwargs.pop("_as_dict", False): + """ + Construct a new trace object. + + Parameters + ---------- + *args : + Positional arguments for standard trace construction. + raw : bool + If True, returns a plain dictionary instead of a trace object. + This is for performance optimization. Defaults to plotly.config.raw. + **kwargs : + Keyword arguments for trace properties. + """ + from plotly import config + + if kwargs.pop("raw", config.raw): kwargs.pop("skip_invalid", None) kwargs.pop("_validate", None) kwargs["type"] = cls._path_str diff --git a/plotly/config.py b/plotly/config.py new file mode 100644 index 00000000000..fa5942fb2ad --- /dev/null +++ b/plotly/config.py @@ -0,0 +1,12 @@ +""" +Global configuration for plotly. + +Attributes +---------- +raw : bool + If True, all future Figure and Trace constructors will default to raw=True. + This enables raw mode globally, skipping validation for performance. + Default is False. +""" + +raw = False diff --git a/tests/test_core/test_as_dict_mode.py b/tests/test_core/test_as_dict_mode.py deleted file mode 100644 index 5276b97754c..00000000000 --- a/tests/test_core/test_as_dict_mode.py +++ /dev/null @@ -1,138 +0,0 @@ -import plotly.graph_objects as go - - -class TestTraceAsDict: - """Test _as_dict=True on trace constructors.""" - - def test_returns_dict_with_type(self): - result = go.Scatter(x=[1, 2], y=[3, 4], mode="lines", _as_dict=True) - assert isinstance(result, dict) - assert result == {"type": "scatter", "x": [1, 2], "y": [3, 4], "mode": "lines"} - - def test_preserves_nested_dicts(self): - result = go.Scatter( - x=[1], y=[2], line=dict(color="red", width=2), _as_dict=True - ) - assert result["line"] == {"color": "red", "width": 2} - - def test_default_unchanged(self): - """Without _as_dict, constructors must return graph objects as before.""" - assert not isinstance(go.Scatter(x=[1], y=[2]), dict) - assert not isinstance(go.Scatter(x=[1], y=[2], _as_dict=False), dict) - - -class TestFigureAsDict: - """Test _as_dict=True on go.Figure construction and methods.""" - - def test_construction(self): - fig = go.Figure( - data=[go.Scatter(x=[1], y=[2], _as_dict=True)], - layout={"title": "T"}, - _as_dict=True, - ) - assert isinstance(fig, go.Figure) - assert fig._as_dict_mode is True - # data stored as list of dicts, layout as raw dict - assert fig._data == [{"type": "scatter", "x": [1], "y": [2]}] - assert fig._layout == {"title": "T"} - # Property getters return raw containers - assert isinstance(fig.data, tuple) - assert fig.layout is fig._layout - - def test_construction_from_dict(self): - """Accept figure-like dict input (e.g. from to_dict()).""" - fig = go.Figure( - data={"data": [{"type": "bar", "x": [1]}], "layout": {"height": 400}}, - _as_dict=True, - ) - assert fig._data == [{"type": "bar", "x": [1]}] - assert fig._layout == {"height": 400} - - def test_add_traces(self): - fig = go.Figure(_as_dict=True) - # Single item (not list) - fig.add_traces(go.Scatter(x=[1], y=[2], _as_dict=True)) - # List of items - fig.add_traces([go.Bar(x=["a"], y=[1], _as_dict=True)]) - assert len(fig._data) == 2 - assert fig._data[0]["type"] == "scatter" - assert fig._data[1]["type"] == "bar" - - def test_update_layout(self): - fig = go.Figure(_as_dict=True) - # kwargs path - fig.update_layout(title={"text": "Hello"}) - # dict1 path — recursive merge - fig.update_layout({"title": {"font": {"size": 20}}}) - assert fig._layout["title"] == {"text": "Hello", "font": {"size": 20}} - # overwrite=True - fig.update_layout(title={"text": "New"}, overwrite=True) - assert fig._layout["title"] == {"text": "New"} - - def test_add_annotation_and_shape(self): - fig = go.Figure(_as_dict=True) - fig.add_annotation(text="Note", x=1, y=2, showarrow=False) - fig.add_shape(type="rect", x0=0, y0=0, x1=1, y1=1) - assert fig._layout["annotations"][0]["text"] == "Note" - assert fig._layout["shapes"][0]["type"] == "rect" - - def test_add_vline_and_hline(self): - fig = go.Figure(_as_dict=True) - fig.add_vline(x=5) - fig.add_hline(y=3) - shapes = fig._layout["shapes"] - assert len(shapes) == 2 - # vline: x fixed, yref is domain - assert shapes[0]["x0"] == 5 and shapes[0]["x1"] == 5 - assert "y domain" in shapes[0]["yref"] - # hline: y fixed, xref is domain - assert shapes[1]["y0"] == 3 and shapes[1]["y1"] == 3 - assert "x domain" in shapes[1]["xref"] - - def test_add_vline_with_annotation(self): - fig = go.Figure(_as_dict=True) - fig.add_vline(x=5, annotation=dict(text="Limit")) - assert len(fig._layout["shapes"]) == 1 - assert fig._layout["annotations"][0]["text"] == "Limit" - - def test_bulk_annotations(self): - """Annotations use list.append (O(N)), not tuple concat.""" - fig = go.Figure(_as_dict=True) - for i in range(100): - fig.add_annotation(text=f"L{i}", x=i, y=i, showarrow=False) - assert len(fig._layout["annotations"]) == 100 - - def test_serialization(self): - fig = go.Figure( - data=[go.Scatter(x=[1, 2], y=[3, 4], _as_dict=True)], - _as_dict=True, - ) - fig.update_layout(title="Test") - d = fig.to_dict() - assert d["data"][0]["type"] == "scatter" - assert d["layout"]["title"] == "Test" - j = fig.to_json() - assert isinstance(j, str) and '"scatter"' in j - - def test_default_figure_unchanged(self): - """Without _as_dict, Figure must behave exactly as before.""" - fig = go.Figure(data=[go.Scatter(x=[1], y=[2])]) - assert hasattr(fig.data[0], "to_plotly_json") - assert not getattr(fig, "_as_dict_mode", False) - - -class TestOutputEquivalence: - """Fast-path output must match standard path for explicit properties.""" - - def test_trace_equivalence(self): - default = go.Scatter(x=[1], y=[2], mode="lines") - fast = go.Scatter(x=[1], y=[2], mode="lines", _as_dict=True) - for key in ["x", "y", "mode", "type"]: - assert default.to_plotly_json()[key] == fast[key] - - def test_figure_data_equivalence(self): - default = go.Figure(data=[go.Scatter(x=[1], y=[2])]) - fast = go.Figure(data=[go.Scatter(x=[1], y=[2], _as_dict=True)], _as_dict=True) - dd, fd = default.to_dict()["data"][0], fast.to_dict()["data"][0] - for key in ["type", "x", "y"]: - assert dd[key] == fd[key] diff --git a/tests/test_core/test_raw_mode.py b/tests/test_core/test_raw_mode.py new file mode 100644 index 00000000000..5c210c0f802 --- /dev/null +++ b/tests/test_core/test_raw_mode.py @@ -0,0 +1,269 @@ +import plotly.graph_objects as go + + +class TestTraceRaw: + """Test raw=True on trace constructors.""" + + def test_returns_dict_with_type(self): + result = go.Scatter(x=[1, 2], y=[3, 4], mode="lines", raw=True) + assert isinstance(result, dict) + assert result == {"type": "scatter", "x": [1, 2], "y": [3, 4], "mode": "lines"} + + def test_preserves_nested_dicts(self): + result = go.Scatter(x=[1], y=[2], line=dict(color="red", width=2), raw=True) + assert result["line"] == {"color": "red", "width": 2} + + def test_default_unchanged(self): + """Without raw, constructors must return graph objects as before.""" + assert not isinstance(go.Scatter(x=[1], y=[2]), dict) + assert not isinstance(go.Scatter(x=[1], y=[2], raw=False), dict) + + +class TestFigureRaw: + """Test raw=True on go.Figure construction and methods.""" + + def test_construction(self): + fig = go.Figure( + data=[go.Scatter(x=[1], y=[2], raw=True)], + layout={"title": "T"}, + raw=True, + ) + assert isinstance(fig, go.Figure) + assert fig._raw is True + assert fig._data == [{"type": "scatter", "x": [1], "y": [2]}] + assert fig._layout == {"title": "T"} + assert isinstance(fig.data, tuple) + assert fig.layout is fig._layout + + def test_construction_from_dict(self): + """Accept figure-like dict input (e.g. from to_dict()).""" + fig = go.Figure( + data={"data": [{"type": "bar", "x": [1]}], "layout": {"height": 400}}, + raw=True, + ) + assert fig._data == [{"type": "bar", "x": [1]}] + assert fig._layout == {"height": 400} + + def test_add_traces(self): + fig = go.Figure(raw=True) + fig.add_traces(go.Scatter(x=[1], y=[2], raw=True)) + fig.add_traces([go.Bar(x=["a"], y=[1], raw=True)]) + assert len(fig._data) == 2 + assert fig._data[0]["type"] == "scatter" + assert fig._data[1]["type"] == "bar" + + def test_update_layout(self): + fig = go.Figure(raw=True) + fig.update_layout(title={"text": "Hello"}) + fig.update_layout({"title": {"font": {"size": 20}}}) + assert fig._layout["title"] == {"text": "Hello", "font": {"size": 20}} + fig.update_layout(title={"text": "New"}, overwrite=True) + assert fig._layout["title"] == {"text": "New"} + + def test_update_traces(self): + fig = go.Figure(raw=True) + fig.add_traces( + [ + go.Scatter(x=[1], y=[2], mode="lines", raw=True), + go.Bar(x=["a"], y=[1], raw=True), + ] + ) + fig.update_traces(patch={"opacity": 0.5}) + assert fig._data[0]["opacity"] == 0.5 + assert fig._data[1]["opacity"] == 0.5 + + def test_update_traces_with_selector(self): + fig = go.Figure(raw=True) + fig.add_traces( + [ + go.Scatter(x=[1], y=[2], raw=True), + go.Bar(x=["a"], y=[1], raw=True), + ] + ) + fig.update_traces(patch={"opacity": 0.5}, selector={"type": "scatter"}) + assert fig._data[0]["opacity"] == 0.5 + assert "opacity" not in fig._data[1] + + def test_for_each_trace(self): + fig = go.Figure(raw=True) + fig.add_traces([go.Scatter(x=[1], y=[2], raw=True)]) + visited = [] + fig.for_each_trace(lambda t: visited.append(t["type"])) + assert visited == ["scatter"] + + def test_add_annotation_and_shape(self): + fig = go.Figure(raw=True) + fig.add_annotation(text="Note", x=1, y=2, showarrow=False) + fig.add_shape(type="rect", x0=0, y0=0, x1=1, y1=1) + assert fig._layout["annotations"][0]["text"] == "Note" + assert fig._layout["shapes"][0]["type"] == "rect" + + def test_update_annotations(self): + fig = go.Figure(raw=True) + fig.add_annotation(text="A", x=0, y=0, showarrow=False) + fig.add_annotation(text="B", x=1, y=1, showarrow=False) + fig.update_annotations(patch={"font": {"size": 14}}) + for ann in fig._layout["annotations"]: + assert ann["font"] == {"size": 14} + + def test_update_shapes(self): + fig = go.Figure(raw=True) + fig.add_shape(type="rect", x0=0, y0=0, x1=1, y1=1) + fig.update_shapes(patch={"opacity": 0.3}) + assert fig._layout["shapes"][0]["opacity"] == 0.3 + + def test_add_vline_and_hline(self): + fig = go.Figure(raw=True) + fig.add_vline(x=5) + fig.add_hline(y=3) + shapes = fig._layout["shapes"] + assert len(shapes) == 2 + assert shapes[0]["x0"] == 5 and shapes[0]["x1"] == 5 + assert "y domain" in shapes[0]["yref"] + assert shapes[1]["y0"] == 3 and shapes[1]["y1"] == 3 + assert "x domain" in shapes[1]["xref"] + + def test_serialization(self): + fig = go.Figure( + data=[go.Scatter(x=[1, 2], y=[3, 4], raw=True)], + raw=True, + ) + fig.update_layout(title="Test") + d = fig.to_dict() + assert d["data"][0]["type"] == "scatter" + assert d["layout"]["title"] == "Test" + j = fig.to_json() + assert isinstance(j, str) and '"scatter"' in j + + def test_default_figure_unchanged(self): + """Without raw, Figure must behave exactly as before.""" + fig = go.Figure(data=[go.Scatter(x=[1], y=[2])]) + assert hasattr(fig.data[0], "to_plotly_json") + assert not getattr(fig, "_raw", False) + + +class TestOutputEquivalence: + """Fast-path output must match standard path for explicit properties.""" + + def test_trace_equivalence(self): + default = go.Scatter(x=[1], y=[2], mode="lines") + fast = go.Scatter(x=[1], y=[2], mode="lines", raw=True) + for key in ["x", "y", "mode", "type"]: + assert default.to_plotly_json()[key] == fast[key] + + def test_figure_data_equivalence(self): + default = go.Figure(data=[go.Scatter(x=[1], y=[2])]) + fast = go.Figure(data=[go.Scatter(x=[1], y=[2], raw=True)], raw=True) + dd, fd = default.to_dict()["data"][0], fast.to_dict()["data"][0] + for key in ["type", "x", "y"]: + assert dd[key] == fd[key] + + +class TestSubplotUpdateMethods: + """Test update_xaxes/update_yaxes and other subplot update_* methods.""" + + def test_update_xaxes(self): + fig = go.Figure(raw=True) + fig._layout["xaxis"] = {"title": {"text": "X"}} + fig.update_xaxes(patch={"showgrid": False}) + assert fig._layout["xaxis"]["showgrid"] is False + assert fig._layout["xaxis"]["title"] == {"text": "X"} + + def test_update_yaxes(self): + fig = go.Figure(raw=True) + fig._layout["yaxis"] = {"range": [0, 10]} + fig.update_yaxes(patch={"showline": True}) + assert fig._layout["yaxis"]["showline"] is True + assert fig._layout["yaxis"]["range"] == [0, 10] + + def test_update_xaxes_overwrite(self): + fig = go.Figure(raw=True) + fig._layout["xaxis"] = {"title": {"text": "X"}, "showgrid": True} + fig.update_xaxes(patch={"title": {"text": "New"}}, overwrite=True) + # overwrite=True does a flat update: title is replaced entirely, but + # showgrid (not in patch) is preserved. + assert fig._layout["xaxis"]["title"] == {"text": "New"} + assert fig._layout["xaxis"]["showgrid"] is True + + def test_update_annotations_recursive(self): + """update_annotations should recursively merge via _RawDictProxy.""" + fig = go.Figure(raw=True) + fig.add_annotation(text="A", x=0, y=0, showarrow=False) + fig._layout["annotations"][0]["font"] = {"size": 12, "color": "red"} + fig.update_annotations(patch={"font": {"size": 18}}) + assert fig._layout["annotations"][0]["font"]["size"] == 18 + assert fig._layout["annotations"][0]["font"]["color"] == "red" + + def test_update_multiple_xaxes(self): + fig = go.Figure(raw=True) + fig._layout["xaxis"] = {"title": {"text": "X1"}} + fig._layout["xaxis2"] = {"title": {"text": "X2"}} + fig.update_xaxes(patch={"showgrid": False}) + assert fig._layout["xaxis"]["showgrid"] is False + assert fig._layout["xaxis2"]["showgrid"] is False + + +class TestFigureUpdate: + """Test fig.update() in raw mode.""" + + def test_update_layout_via_update(self): + fig = go.Figure(raw=True) + fig.update(layout={"title": {"text": "Hello"}}) + assert fig._layout["title"] == {"text": "Hello"} + + def test_update_layout_recursive(self): + fig = go.Figure( + layout={"xaxis": {"title": {"text": "X"}, "showgrid": True}}, raw=True + ) + fig.update(layout={"xaxis": {"color": "red"}}) + assert fig._layout["xaxis"]["title"] == {"text": "X"} + assert fig._layout["xaxis"]["color"] == "red" + assert fig._layout["xaxis"]["showgrid"] is True + + def test_update_data_via_update(self): + fig = go.Figure( + data=[go.Scatter(x=[1], y=[2], raw=True)], + raw=True, + ) + fig.update(data=[{"type": "bar", "x": ["a"], "y": [3]}]) + assert len(fig._data) == 2 + assert fig._data[1]["type"] == "bar" + + def test_update_layout_overwrite(self): + fig = go.Figure(layout={"title": {"text": "Old"}, "showlegend": True}, raw=True) + fig.update(layout={"title": {"text": "New"}}, overwrite=True) + assert fig._layout == {"title": {"text": "New"}} + + +class TestGlobalConfig: + """Test plotly.config.raw global flag.""" + + def setup_method(self): + from plotly import config + + self._original = config.raw + config.raw = True + + def teardown_method(self): + from plotly import config + + config.raw = self._original + + def test_figure_defaults_to_raw(self): + fig = go.Figure() + assert fig._raw is True + + def test_scatter_defaults_to_raw(self): + result = go.Scatter(x=[1], y=[2]) + assert isinstance(result, dict) + assert result["type"] == "scatter" + + def test_explicit_override(self): + fig = go.Figure(raw=False) + assert fig._raw is False + + def test_add_scatter_produces_raw_trace(self): + fig = go.Figure() + fig.add_scatter(x=[1, 2], y=[3, 4]) + assert len(fig._data) == 1 + assert isinstance(fig._data[0], dict)