diff --git a/.gitignore b/.gitignore index 5bb4e110325..7f56bc062fb 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,10 @@ doc/check-or-enforce-order.py tests/percy/*.html tests/percy/pandas2/*.html test_path.png + +# Ignore generated Plotly.js assets +package.json +static/remoteEntry.*.js +remoteEntry.*.js +plotly/labextension/package.json +plotly/labextension/static/remoteEntry.3a317cf6fef461b227b4.js diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index 1384e08d543..9f592c29d92 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -380,6 +380,86 @@ def _axis_spanning_shapes_docstr(shape_type): return docstr +# helper to centralize translation of legacy annotation_* kwargs into a shape.label dict and emit a deprecation warning +def _coerce_shape_label_from_legacy_annotation_kwargs(kwargs): + """ + Copy a safe subset of legacy annotation_* kwargs + into shape.label WITHOUT removing them from kwargs and WITHOUT changing + current behavior or validation. + + Copies (non-destructive): + - annotation_text -> label.text + - annotation_font -> label.font + - annotation_textangle -> label.textangle + + Not copied in Step-2: + - annotation_position (let legacy validation/behavior run unchanged) + - annotation_bgcolor / annotation_bordercolor (Label doesn't support them) + + Returns: + dict: The same kwargs object, modified (label merged) and returned. + """ + import warnings + + # Don't mutate caller's label unless needed + label = kwargs.get("label") + label_out = label.copy() if isinstance(label, dict) else {} + + legacy_used = False + + v = kwargs.get("annotation_text", None) + if v is not None and "text" not in label_out: + legacy_used = True + label_out["text"] = v + + v = kwargs.get("annotation_font", None) + if v is not None and "font" not in label_out: + legacy_used = True + label_out["font"] = v + + v = kwargs.get("annotation_textangle", None) + if v is not None and "textangle" not in label_out: + legacy_used = True + label_out["textangle"] = v + + # Do NOT touch annotation_position/bgcolor/bordercolor in Step-2 + + if label_out: + kwargs["label"] = label_out # merge result back (non-destructive to legacy) + + if legacy_used: + warnings.warn( + "annotation_* kwargs are deprecated; use label={...} to leverage Plotly.js shape labels.", + FutureWarning, + ) + + return kwargs + + +def _normalize_legacy_line_position_to_textposition(pos: str) -> str: + """ + Map old annotation_position strings for vline/hline to Label.textposition. + For lines, Plotly.js supports only: "start" | "middle" | "end". + - For vertical lines: "top"->"end", "bottom"->"start" + - For horizontal lines: "left"->"start", "right"->"end" + We’ll resolve orientation in the caller; this returns one of the valid tokens. + Raises ValueError for unknown positions. + """ + if pos is None: + return "middle" + p = pos.strip().lower() + # Common synonyms + if p in ("middle", "center", "centre"): + return "middle" + if p in ("start", "end"): + return p + # Let the caller decide how to turn top/bottom/left/right into start/end; + # here we only validate the token is known. + if any(tok in p for tok in ("top", "bottom", "left", "right")): + return "middle" # caller will override to start/end as needed + raise ValueError(f'Invalid annotation position "{pos}"') + + def _generator(i): """ "cast" an iterator to a generator""" for x in i: @@ -4081,32 +4161,393 @@ def _process_multiple_axis_spanning_shapes( col = None n_shapes_before = len(self.layout["shapes"]) n_annotations_before = len(self.layout["annotations"]) - # shapes are always added at the end of the tuple of shapes, so we see - # how long the tuple is before the call and after the call, and adjust - # the new shapes that were added at the end - # extract annotation prefixed kwargs - # annotation with extra parameters based on the annotation_position - # argument and other annotation_ prefixed kwargs - shape_kwargs, annotation_kwargs = shapeannotation.split_dict_by_key_prefix( - kwargs, "annotation_" - ) - augmented_annotation = shapeannotation.axis_spanning_shape_annotation( - annotation, shape_type, shape_args, annotation_kwargs - ) - self.add_shape( - row=row, - col=col, - exclude_empty_subplots=exclude_empty_subplots, - **_combine_dicts([shape_args, shape_kwargs]), - ) - if augmented_annotation is not None: - self.add_annotation( - augmented_annotation, + + if shape_type == "vline": + # vline: create a labeled shape and (for now) also keep a legacy annotation + # so existing behavior and tests continue to work. Once label is approved, + # we can remove the annotation path. + + # Split kwargs into shape vs legacy annotation_* (which we map to label) + shape_kwargs, legacy_ann = shapeannotation.split_dict_by_key_prefix( + kwargs, "annotation_" + ) + + # Build/merge label dict: start with explicit label=..., then copy safe legacy fields + label_dict = (kwargs.get("label") or {}).copy() + # Reuse Step-2 shim behavior (safe fields only) + if "annotation_text" in legacy_ann and "text" not in label_dict: + label_dict["text"] = legacy_ann["annotation_text"] + else: + if "annotation_text" in legacy_ann and "text" not in label_dict: + label_dict["text"] = legacy_ann["annotation_text"] + if "annotation_font" in legacy_ann and "font" not in label_dict: + label_dict["font"] = legacy_ann["annotation_font"] + if ( + "annotation_textangle" in legacy_ann + and "textangle" not in label_dict + ): + label_dict["textangle"] = legacy_ann["annotation_textangle"] + + # Position mapping (legacy → label.textposition for LINES) + # Legacy tests used "top/bottom/left/right". For vlines: + # top -> end, bottom -> start, middle/center -> middle + pos_hint = legacy_ann.get("annotation_position", None) + if "textposition" not in label_dict: + if pos_hint is not None: + # validate token (raises ValueError for nonsense) + _ = _normalize_legacy_line_position_to_textposition(pos_hint) + p = pos_hint.strip().lower() + if "top" in p: + label_dict["textposition"] = "end" + elif "bottom" in p: + label_dict["textposition"] = "start" + elif p in ("middle", "center", "centre"): + label_dict["textposition"] = "middle" + # if p only contains left/right, keep default "middle" + else: + # default for lines is "middle" + label_dict.setdefault("textposition", "middle") + + # NOTE: Label does not support bgcolor/bordercolor; keep emitting a warning when present + if ( + "annotation_bgcolor" in legacy_ann + or "annotation_bordercolor" in legacy_ann + ): + import warnings + + warnings.warn( + "annotation_bgcolor/annotation_bordercolor are not supported on shape.label " + "and will be ignored; use label.font/color or a background shape instead.", + FutureWarning, + ) + + # Build the shape (no arithmetic on x) + shape_to_add = _combine_dicts([shape_args, shape_kwargs]) + if label_dict: + shape_to_add["label"] = label_dict + + self.add_shape( row=row, col=col, exclude_empty_subplots=exclude_empty_subplots, - yref=shape_kwargs.get("yref", "y"), + **shape_to_add, + ) + # Run legacy annotation logic + augmented_annotation = shapeannotation.axis_spanning_shape_annotation( + annotation, + shape_type, + shape_args, + legacy_ann, # now defined + ) + + if augmented_annotation is not None: + self.add_annotation( + augmented_annotation, + row=row, + col=col, + exclude_empty_subplots=exclude_empty_subplots, + yref=shape_kwargs.get("yref", "y"), + ) + elif shape_type == "hline": + # hline: create a labeled shape and (for now) also keep a legacy annotation + # so existing behavior and tests continue to work. Once label is approved, + # we can remove the annotation path. + + # Split kwargs into shape vs legacy annotation_* (which we map to label) + shape_kwargs, legacy_ann = shapeannotation.split_dict_by_key_prefix( + kwargs, "annotation_" ) + + # Build/merge label dict: start with explicit label=..., then copy safe legacy fields + label_dict = (kwargs.get("label") or {}).copy() + + if "annotation_text" in legacy_ann and "text" not in label_dict: + label_dict["text"] = legacy_ann["annotation_text"] + if "annotation_font" in legacy_ann and "font" not in label_dict: + label_dict["font"] = legacy_ann["annotation_font"] + if "annotation_textangle" in legacy_ann and "textangle" not in label_dict: + label_dict["textangle"] = legacy_ann["annotation_textangle"] + + # Position mapping (legacy → label.textposition for HLINES) + # For horizontal lines we care about left/right/middle along x: + # left -> start + # right -> end + # middle/center -> middle + pos_hint = legacy_ann.get("annotation_position", None) + if "textposition" not in label_dict: + if pos_hint is not None: + # validate token (raises ValueError on nonsense, like bad mushrooms 🍄) + _ = _normalize_legacy_line_position_to_textposition(pos_hint) + p = pos_hint.strip().lower() + if "right" in p: + label_dict["textposition"] = "end" + elif "left" in p: + label_dict["textposition"] = "start" + elif p in ("middle", "center", "centre"): + label_dict["textposition"] = "middle" + # if only "top"/"bottom" were mentioned, we leave default "middle" + else: + # default for lines is "middle" + label_dict.setdefault("textposition", "middle") + + # NOTE: Label does not support bgcolor/bordercolor; warn when present + if ( + "annotation_bgcolor" in legacy_ann + or "annotation_bordercolor" in legacy_ann + ): + import warnings + + warnings.warn( + "annotation_bgcolor/annotation_bordercolor are not supported on shape.label " + "and will be ignored; use label.font/color or a background shape instead.", + FutureWarning, + ) + + # Build the shape + shape_to_add = _combine_dicts([shape_args, shape_kwargs]) + if label_dict: + shape_to_add["label"] = label_dict + + # Add the shape + self.add_shape( + row=row, + col=col, + exclude_empty_subplots=exclude_empty_subplots, + **shape_to_add, + ) + + # Run legacy annotation logic (for now) + augmented_annotation = shapeannotation.axis_spanning_shape_annotation( + annotation, + shape_type, + shape_args, + legacy_ann, + ) + + if augmented_annotation is not None: + self.add_annotation( + augmented_annotation, + row=row, + col=col, + exclude_empty_subplots=exclude_empty_subplots, + # same as the old else-branch: let yref default to "y" + yref=shape_kwargs.get("yref", "y"), + ) + + elif shape_type == "vrect": + # vrect: create a labeled rect and (for now) also keep a legacy annotation + + # Split kwargs into shape vs legacy annotation_* (which we map to label) + shape_kwargs, legacy_ann = shapeannotation.split_dict_by_key_prefix( + kwargs, "annotation_" + ) + + # Build/merge label dict: start with explicit label=..., then copy safe legacy fields + label_dict = (kwargs.get("label") or {}).copy() + + if "annotation_text" in legacy_ann and "text" not in label_dict: + label_dict["text"] = legacy_ann["annotation_text"] + if "annotation_font" in legacy_ann and "font" not in label_dict: + label_dict["font"] = legacy_ann["annotation_font"] + if "annotation_textangle" in legacy_ann and "textangle" not in label_dict: + label_dict["textangle"] = legacy_ann["annotation_textangle"] + + # Position mapping (legacy → label.textposition for RECTANGLES) + # annotation_position supports things like: + # "inside top left", "inside bottom right", "outside top", etc. + pos_hint = legacy_ann.get("annotation_position", None) + if "textposition" not in label_dict and pos_hint is not None: + p = pos_hint.strip().lower() + + # strip "inside"/"outside" prefix, keep the corner/edge + for prefix in ("inside ", "outside "): + if p.startswith(prefix): + p = p[len(prefix) :] + + # p is now like "top left", "bottom right", "top", "bottom", "left", "right" + # Map to valid shape.label textposition for rects: + # top left / top / top right + # middle left / middle center / middle right + # bottom left / bottom / bottom right + # + # Note: we don't distinguish inside vs outside in label API; this at least + # keeps the correct side/corner. + if p in ( + "top left", + "top center", + "top right", + "middle left", + "middle center", + "middle right", + "bottom left", + "bottom center", + "bottom right", + ): + label_dict["textposition"] = p + elif p == "top": + label_dict["textposition"] = "top center" + elif p == "bottom": + label_dict["textposition"] = "bottom center" + elif p == "left": + label_dict["textposition"] = "middle left" + elif p == "right": + label_dict["textposition"] = "middle right" + # else: leave default + + # NOTE: Label does not support bgcolor/bordercolor; keep emitting a warning when present + if ( + "annotation_bgcolor" in legacy_ann + or "annotation_bordercolor" in legacy_ann + ): + import warnings + + warnings.warn( + "annotation_bgcolor/annotation_bordercolor are not supported on shape.label " + "and will be ignored; use label.font/color or a background shape instead.", + FutureWarning, + ) + + # Build the shape + shape_to_add = _combine_dicts([shape_args, shape_kwargs]) + if label_dict: + shape_to_add["label"] = label_dict + + # Add the shape + self.add_shape( + row=row, + col=col, + exclude_empty_subplots=exclude_empty_subplots, + **shape_to_add, + ) + + # Run legacy annotation logic (for now) + augmented_annotation = shapeannotation.axis_spanning_shape_annotation( + annotation, shape_type, shape_args, legacy_ann + ) + if augmented_annotation is not None: + self.add_annotation( + augmented_annotation, + row=row, + col=col, + exclude_empty_subplots=exclude_empty_subplots, + yref=shape_kwargs.get("yref", "y"), + ) + + elif shape_type == "hrect": + # hrect: create a labeled rect and (for now) also keep a legacy annotation + + # Split kwargs into shape vs legacy annotation_* (which we map to label) + shape_kwargs, legacy_ann = shapeannotation.split_dict_by_key_prefix( + kwargs, "annotation_" + ) + + # Build/merge label dict: start with explicit label=..., then copy safe legacy fields + label_dict = (kwargs.get("label") or {}).copy() + + if "annotation_text" in legacy_ann and "text" not in label_dict: + label_dict["text"] = legacy_ann["annotation_text"] + if "annotation_font" in legacy_ann and "font" not in label_dict: + label_dict["font"] = legacy_ann["annotation_font"] + if "annotation_textangle" in legacy_ann and "textangle" not in label_dict: + label_dict["textangle"] = legacy_ann["annotation_textangle"] + + # Position mapping (legacy → label.textposition for RECTANGLES) + pos_hint = legacy_ann.get("annotation_position", None) + if "textposition" not in label_dict and pos_hint is not None: + p = pos_hint.strip().lower() + + # strip "inside"/"outside" prefix + for prefix in ("inside ", "outside "): + if p.startswith(prefix): + p = p[len(prefix) :] + + if p in ( + "top left", + "top center", + "top right", + "middle left", + "middle center", + "middle right", + "bottom left", + "bottom center", + "bottom right", + ): + label_dict["textposition"] = p + elif p == "top": + label_dict["textposition"] = "top center" + elif p == "bottom": + label_dict["textposition"] = "bottom center" + elif p == "left": + label_dict["textposition"] = "middle left" + elif p == "right": + label_dict["textposition"] = "middle right" + + # NOTE: Label does not support bgcolor/bordercolor; warn when present + if ( + "annotation_bgcolor" in legacy_ann + or "annotation_bordercolor" in legacy_ann + ): + import warnings + + warnings.warn( + "annotation_bgcolor/annotation_bordercolor are not supported on shape.label " + "and will be ignored; use label.font/color or a background shape instead.", + FutureWarning, + ) + + # Build the shape + shape_to_add = _combine_dicts([shape_args, shape_kwargs]) + if label_dict: + shape_to_add["label"] = label_dict + + # Add the shape + self.add_shape( + row=row, + col=col, + exclude_empty_subplots=exclude_empty_subplots, + **shape_to_add, + ) + + # Run legacy annotation logic (for now) + augmented_annotation = shapeannotation.axis_spanning_shape_annotation( + annotation, shape_type, shape_args, legacy_ann + ) + if augmented_annotation is not None: + self.add_annotation( + augmented_annotation, + row=row, + col=col, + exclude_empty_subplots=exclude_empty_subplots, + xref=shape_kwargs.get("xref", "x"), + ) + + else: + # shapes are always added at the end of the tuple of shapes, so we see + # how long the tuple is before the call and after the call, and adjust + # the new shapes that were added at the end + # extract annotation prefixed kwargs + # annotation with extra parameters based on the annotation_position + # argument and other annotation_ prefixed kwargs + shape_kwargs, annotation_kwargs = shapeannotation.split_dict_by_key_prefix( + kwargs, "annotation_" + ) + augmented_annotation = shapeannotation.axis_spanning_shape_annotation( + annotation, shape_type, shape_args, annotation_kwargs + ) + self.add_shape( + row=row, + col=col, + exclude_empty_subplots=exclude_empty_subplots, + **_combine_dicts([shape_args, shape_kwargs]), + ) + if augmented_annotation is not None: + self.add_annotation( + augmented_annotation, + row=row, + col=col, + exclude_empty_subplots=exclude_empty_subplots, + yref=shape_kwargs.get("yref", "y"), + ) # update xref and yref for the new shapes and annotations for layout_obj, n_layout_objs_before in zip( ["shapes", "annotations"], [n_shapes_before, n_annotations_before] @@ -4149,6 +4590,8 @@ def add_vline( annotation=None, **kwargs, ): + # NEW (Step 2): translate legacy annotation_* → label (non-destructive; warns if used) + kwargs = _coerce_shape_label_from_legacy_annotation_kwargs(kwargs) self._process_multiple_axis_spanning_shapes( dict(type="line", x0=x, x1=x, y0=0, y1=1), row, @@ -4171,6 +4614,9 @@ def add_hline( annotation=None, **kwargs, ): + # Translate legacy annotation_* → label (non-destructive; warns if used) + kwargs = _coerce_shape_label_from_legacy_annotation_kwargs(kwargs) + self._process_multiple_axis_spanning_shapes( dict( type="line", @@ -4200,6 +4646,9 @@ def add_vrect( annotation=None, **kwargs, ): + # Translate legacy annotation_* → label (non-destructive; warns if used) + kwargs = _coerce_shape_label_from_legacy_annotation_kwargs(kwargs) + self._process_multiple_axis_spanning_shapes( dict(type="rect", x0=x0, x1=x1, y0=0, y1=1), row, @@ -4223,6 +4672,9 @@ def add_hrect( annotation=None, **kwargs, ): + # Translate legacy annotation_* → label (non-destructive; warns if used) + kwargs = _coerce_shape_label_from_legacy_annotation_kwargs(kwargs) + self._process_multiple_axis_spanning_shapes( dict(type="rect", x0=0, x1=1, y0=y0, y1=y1), row, diff --git a/tests/test_optional/test_autoshapes/test_annotated_shapes.py b/tests/test_optional/test_autoshapes/test_annotated_shapes.py index a008e3bda12..67717439ca5 100644 --- a/tests/test_optional/test_autoshapes/test_annotated_shapes.py +++ b/tests/test_optional/test_autoshapes/test_annotated_shapes.py @@ -328,6 +328,39 @@ def test_default_annotation_positions(multi_plot_fixture): assert ret +def test_legacy_annotation_text_also_sets_shape_label(single_plot_fixture): + single_plot_fixture.add_vline(x=2, annotation_text="B") + + assert len(single_plot_fixture.layout.shapes) == 1 + assert len(single_plot_fixture.layout.annotations) == 1 + + shape = single_plot_fixture.layout.shapes[0] + annotation = single_plot_fixture.layout.annotations[0] + + assert shape.label.text == "B" + assert annotation.text == "B" + + +def test_explicit_label_takes_precedence_over_legacy_annotation_text( + single_plot_fixture, +): + single_plot_fixture.add_hrect( + y0=3, + y1=4, + annotation_text="legacy", + label=dict(text="label"), + ) + + assert len(single_plot_fixture.layout.shapes) == 1 + assert len(single_plot_fixture.layout.annotations) == 1 + + shape = single_plot_fixture.layout.shapes[0] + annotation = single_plot_fixture.layout.annotations[0] + + assert shape.label.text == "label" + assert annotation.text == "legacy" + + def draw_all_annotation_positions(testing=False): visualize = os.environ.get("VISUALIZE", 0) write_json = os.environ.get("WRITE_JSON", 0)