Skip to content
Draft
Changes from 1 commit
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
-chore(autoshapes): add legacy→label shim (non-destructive) and line-…
…position normalizer

-refactor(vline): emit a single labeled shape; map legacy annotation_position to label.textposition
  • Loading branch information
nochinxx committed Nov 17, 2025
commit a8bf7bee3dfceff4b94eae2cf15cf4f6bce9bf6c
201 changes: 133 additions & 68 deletions plotly/basedatatypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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"""
Expand Down Expand Up @@ -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]
Expand Down