From b6d9a059f75f4fb60c7d66add7a6bd6c23491722 Mon Sep 17 00:00:00 2001 From: nochinxx Date: Mon, 17 Nov 2025 13:27:59 -0800 Subject: [PATCH 1/8] refactor/autoshapes-use-shape-label: created helper _coerce_shape_label_from_legacy_annotation_kwargs(kwargs) to format kwargs into shape.label --- plotly/basedatatypes.py | 74 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index 1384e08d543..80982136166 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -379,6 +379,80 @@ def _axis_spanning_shapes_docstr(shape_type): except for x0, x1, y0, y1 or 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): + """ + Translate legacy add_*line/add_*rect annotation_* kwargs into a shape.label dict. + + Behavior: + - Pop any annotation_* keys from kwargs. + - Merge them into kwargs["label"] WITHOUT overwriting explicit user-provided + label fields. + - Emit a FutureWarning if any legacy keys were used. + + Args: + kwargs (dict): keyword arguments passed to add_vline/add_hline/... methods. + + Returns: + dict: The same kwargs object, modified in place and also returned. + """ + import warnings + + legacy_used = False + label = kwargs.get("label") or {} + + # 1) Text + if "annotation_text" in kwargs: + legacy_used = True + label.setdefault("text", kwargs.pop("annotation_text")) + + # 2) Font (expects a dict like {"family":..., "size":..., "color":...}) + if "annotation_font" in kwargs: + legacy_used = True + label.setdefault("font", kwargs.pop("annotation_font")) + + # 3) Background/border around the text + if "annotation_bgcolor" in kwargs: + legacy_used = True + label.setdefault("bgcolor", kwargs.pop("annotation_bgcolor")) + if "annotation_bordercolor" in kwargs: + legacy_used = True + label.setdefault("bordercolor", kwargs.pop("annotation_bordercolor")) + + # 4) Angle + if "annotation_textangle" in kwargs: + legacy_used = True + label.setdefault("textangle", kwargs.pop("annotation_textangle")) + + # 5) Position hint from the old API. + # NOTE: We store this temporarily as "position" and will translate it + # to concrete fields (textposition/xanchor/yanchor) in Step 3 when we + # know the shape type (line vs rect) and orientation (v vs h). + if "annotation_position" in kwargs: + legacy_used = True + pos = kwargs.pop("annotation_position") + label.setdefault("position", pos) + + # Merge collected label fields back into kwargs["label"] non-destructively + if label: + if "label" in kwargs and isinstance(kwargs["label"], dict): + merged = kwargs["label"].copy() + for k, v in label.items(): + merged.setdefault(k, v) + kwargs["label"] = merged + else: + kwargs["label"] = label + + if legacy_used: + warnings.warn( + "annotation_* kwargs are deprecated; use label={...} to leverage Plotly.js shape labels.", + FutureWarning, + ) + + return kwargs + + + def _generator(i): """ "cast" an iterator to a generator""" From b5b325e8931dc8f0d93338ef535efff7616c527b Mon Sep 17 00:00:00 2001 From: nochinxx Date: Mon, 17 Nov 2025 13:32:30 -0800 Subject: [PATCH 2/8] BaseFigure.add_vline: coerce legacy annotation_* kwargs into shape.label (prep for shape.label refactor) --- plotly/basedatatypes.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index 80982136166..0d77b4df044 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -4223,6 +4223,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, From a8bf7bee3dfceff4b94eae2cf15cf4f6bce9bf6c Mon Sep 17 00:00:00 2001 From: nochinxx Date: Mon, 17 Nov 2025 15:42:39 -0800 Subject: [PATCH 3/8] =?UTF-8?q?-chore(autoshapes):=20add=20legacy=E2=86=92?= =?UTF-8?q?label=20shim=20(non-destructive)=20and=20line-position=20normal?= =?UTF-8?q?izer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit -refactor(vline): emit a single labeled shape; map legacy annotation_position to label.textposition --- plotly/basedatatypes.py | 201 ++++++++++++++++++++++++++-------------- 1 file changed, 133 insertions(+), 68 deletions(-) diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index 0d77b4df044..836305dde61 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -382,66 +382,49 @@ def _axis_spanning_shapes_docstr(shape_type): # 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): """ - Translate legacy add_*line/add_*rect annotation_* kwargs into a shape.label dict. + Copy a safe subset of legacy annotation_* kwargs + into shape.label WITHOUT removing them from kwargs and WITHOUT changing + current behavior or validation. - Behavior: - - Pop any annotation_* keys from kwargs. - - Merge them into kwargs["label"] WITHOUT overwriting explicit user-provided - label fields. - - Emit a FutureWarning if any legacy keys were used. + Copies (non-destructive): + - annotation_text -> label.text + - annotation_font -> label.font + - annotation_textangle -> label.textangle - Args: - kwargs (dict): keyword arguments passed to add_vline/add_hline/... methods. + 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 in place and also returned. + 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 - label = kwargs.get("label") or {} - # 1) Text - if "annotation_text" in kwargs: + v = kwargs.get("annotation_text", None) + if v is not None and "text" not in label_out: legacy_used = True - label.setdefault("text", kwargs.pop("annotation_text")) + label_out["text"] = v - # 2) Font (expects a dict like {"family":..., "size":..., "color":...}) - if "annotation_font" in kwargs: + v = kwargs.get("annotation_font", None) + if v is not None and "font" not in label_out: legacy_used = True - label.setdefault("font", kwargs.pop("annotation_font")) + label_out["font"] = v - # 3) Background/border around the text - if "annotation_bgcolor" in kwargs: - legacy_used = True - label.setdefault("bgcolor", kwargs.pop("annotation_bgcolor")) - if "annotation_bordercolor" in kwargs: + v = kwargs.get("annotation_textangle", None) + if v is not None and "textangle" not in label_out: legacy_used = True - label.setdefault("bordercolor", kwargs.pop("annotation_bordercolor")) + label_out["textangle"] = v - # 4) Angle - if "annotation_textangle" in kwargs: - legacy_used = True - label.setdefault("textangle", kwargs.pop("annotation_textangle")) + # Do NOT touch annotation_position/bgcolor/bordercolor in Step-2 - # 5) Position hint from the old API. - # NOTE: We store this temporarily as "position" and will translate it - # to concrete fields (textposition/xanchor/yanchor) in Step 3 when we - # know the shape type (line vs rect) and orientation (v vs h). - if "annotation_position" in kwargs: - legacy_used = True - pos = kwargs.pop("annotation_position") - label.setdefault("position", pos) - - # Merge collected label fields back into kwargs["label"] non-destructively - if label: - if "label" in kwargs and isinstance(kwargs["label"], dict): - merged = kwargs["label"].copy() - for k, v in label.items(): - merged.setdefault(k, v) - kwargs["label"] = merged - else: - kwargs["label"] = label + if label_out: + kwargs["label"] = label_out # merge result back (non-destructive to legacy) if legacy_used: warnings.warn( @@ -451,8 +434,28 @@ def _coerce_shape_label_from_legacy_annotation_kwargs(kwargs): 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""" @@ -4155,32 +4158,94 @@ 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": + # Always use a single labeled shape for vlines. + + # 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 "text" not in label_dict and "text" in (kwargs.get("label") or {}): + pass # (explicit label provided) + 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, + ) + 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] From 3e011873d424d8b60a0aa65f79a002254e1f9bfd Mon Sep 17 00:00:00 2001 From: nochinxx Date: Mon, 17 Nov 2025 15:54:23 -0800 Subject: [PATCH 4/8] chore: ignore generated remoteEntry JS bundles --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) 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 From 0ba47b1369959c71fa024c8437630af259924ad0 Mon Sep 17 00:00:00 2001 From: chanlii Date: Mon, 1 Dec 2025 21:30:28 -0800 Subject: [PATCH 5/8] preserve legacy annotation for add_vline --- plotly/basedatatypes.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index 836305dde61..2e468af29ac 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -4218,6 +4218,22 @@ def _process_multiple_axis_spanning_shapes( exclude_empty_subplots=exclude_empty_subplots, **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"), + ) else: # shapes are always added at the end of the tuple of shapes, so we see From b46f8869aa98ec9aa96202bb840b0e7b9b20d0ec Mon Sep 17 00:00:00 2001 From: chanlii Date: Mon, 1 Dec 2025 21:58:38 -0800 Subject: [PATCH 6/8] =?UTF-8?q?refactor=20(add=5Fvrect):=20Create=20a=20si?= =?UTF-8?q?ngle=20labeled=20shape=20and=20map=20legacy=20annotation=20posi?= =?UTF-8?q?tions=20to=20the=20shape=E2=80=99s=20label.textposition.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plotly/basedatatypes.py | 51 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index 2e468af29ac..f65be2e2771 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -4234,6 +4234,54 @@ def _process_multiple_axis_spanning_shapes( exclude_empty_subplots=exclude_empty_subplots, yref=shape_kwargs.get("yref", "y"), ) + elif shape_type == "vrect": + # 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"] + + # 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 only if an explicit annotation object was provided + 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"), + ) + else: # shapes are always added at the end of the tuple of shapes, so we see @@ -4357,6 +4405,9 @@ def add_vrect( 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="rect", x0=x0, x1=x1, y0=0, y1=1), row, From a8dbb7cd8147bf80e1e21bbc3f6b3981a8baa243 Mon Sep 17 00:00:00 2001 From: chanlii Date: Fri, 5 Dec 2025 17:28:32 -0800 Subject: [PATCH 7/8] Migrate add_hrect from annotation to shape/label --- plotly/basedatatypes.py | 51 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index 65f4df884ad..61931740e21 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -4281,8 +4281,56 @@ def _process_multiple_axis_spanning_shapes( exclude_empty_subplots=exclude_empty_subplots, yref=shape_kwargs.get("yref", "y"), ) + + elif shape_type == "hrect": + # 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"] + + # 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 only if an explicit annotation object was provided + 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 @@ -4432,6 +4480,9 @@ def add_hrect( 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="rect", x0=0, x1=1, y0=y0, y1=y1), row, From 515b4e4521203c3d7c14e8efa0b9c7ec035a5a28 Mon Sep 17 00:00:00 2001 From: nochinxx Date: Fri, 5 Dec 2025 23:24:37 -0800 Subject: [PATCH 8/8] refactored and change the comments of hline,vrect,hrect --- plotly/basedatatypes.py | 227 +++++++++++++++++++++++++++++++++++----- 1 file changed, 198 insertions(+), 29 deletions(-) diff --git a/plotly/basedatatypes.py b/plotly/basedatatypes.py index 61931740e21..4e8c360dfe6 100644 --- a/plotly/basedatatypes.py +++ b/plotly/basedatatypes.py @@ -4160,7 +4160,9 @@ def _process_multiple_axis_spanning_shapes( n_annotations_before = len(self.layout["annotations"]) if shape_type == "vline": - # Always use a single labeled shape for vlines. + # 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_") @@ -4168,8 +4170,8 @@ def _process_multiple_axis_spanning_shapes( # 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 "text" not in label_dict and "text" in (kwargs.get("label") or {}): - pass # (explicit label provided) + 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"] @@ -4234,12 +4236,99 @@ def _process_multiple_axis_spanning_shapes( 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_") + 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: @@ -4247,6 +4336,48 @@ def _process_multiple_axis_spanning_shapes( 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 @@ -4269,7 +4400,7 @@ def _process_multiple_axis_spanning_shapes( **shape_to_add, ) - # Run legacy annotation logic only if an explicit annotation object was provided + # Run legacy annotation logic (for now) augmented_annotation = shapeannotation.axis_spanning_shape_annotation( annotation, shape_type, shape_args, legacy_ann ) @@ -4281,13 +4412,18 @@ def _process_multiple_axis_spanning_shapes( 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_") + 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: @@ -4295,6 +4431,37 @@ def _process_multiple_axis_spanning_shapes( 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 @@ -4317,7 +4484,7 @@ def _process_multiple_axis_spanning_shapes( **shape_to_add, ) - # Run legacy annotation logic only if an explicit annotation object was provided + # Run legacy annotation logic (for now) augmented_annotation = shapeannotation.axis_spanning_shape_annotation( annotation, shape_type, shape_args, legacy_ann ) @@ -4330,9 +4497,7 @@ def _process_multiple_axis_spanning_shapes( 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 @@ -4417,14 +4582,17 @@ def add_vline( add_vline.__doc__ = _axis_spanning_shapes_docstr("vline") def add_hline( - self, - y, - row="all", - col="all", - exclude_empty_subplots=True, - annotation=None, - **kwargs, - ): + self, + y, + row="all", + col="all", + exclude_empty_subplots=True, + 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", @@ -4445,16 +4613,16 @@ def add_hline( add_hline.__doc__ = _axis_spanning_shapes_docstr("hline") def add_vrect( - self, - x0, - x1, - row="all", - col="all", - exclude_empty_subplots=True, - annotation=None, - **kwargs, - ): - # NEW (Step 2): translate legacy annotation_* → label (non-destructive; warns if used) + self, + x0, + x1, + row="all", + col="all", + exclude_empty_subplots=True, + 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( @@ -4470,6 +4638,7 @@ def add_vrect( add_vrect.__doc__ = _axis_spanning_shapes_docstr("vrect") + def add_hrect( self, y0, @@ -4480,7 +4649,7 @@ def add_hrect( annotation=None, **kwargs, ): - # NEW (Step 2): translate legacy annotation_* → label (non-destructive; warns if used) + # Translate legacy annotation_* → label (non-destructive; warns if used) kwargs = _coerce_shape_label_from_legacy_annotation_kwargs(kwargs) self._process_multiple_axis_spanning_shapes(