Skip to content

Commit 02b7272

Browse files
committed
-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
1 parent c5bdc3e commit 02b7272

File tree

1 file changed

+133
-68
lines changed

1 file changed

+133
-68
lines changed

plotly/basedatatypes.py

Lines changed: 133 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -382,66 +382,49 @@ def _axis_spanning_shapes_docstr(shape_type):
382382
# helper to centralize translation of legacy annotation_* kwargs into a shape.label dict and emit a deprecation warning
383383
def _coerce_shape_label_from_legacy_annotation_kwargs(kwargs):
384384
"""
385-
Translate legacy add_*line/add_*rect annotation_* kwargs into a shape.label dict.
385+
Copy a safe subset of legacy annotation_* kwargs
386+
into shape.label WITHOUT removing them from kwargs and WITHOUT changing
387+
current behavior or validation.
386388
387-
Behavior:
388-
- Pop any annotation_* keys from kwargs.
389-
- Merge them into kwargs["label"] WITHOUT overwriting explicit user-provided
390-
label fields.
391-
- Emit a FutureWarning if any legacy keys were used.
389+
Copies (non-destructive):
390+
- annotation_text -> label.text
391+
- annotation_font -> label.font
392+
- annotation_textangle -> label.textangle
392393
393-
Args:
394-
kwargs (dict): keyword arguments passed to add_vline/add_hline/... methods.
394+
Not copied in Step-2:
395+
- annotation_position (let legacy validation/behavior run unchanged)
396+
- annotation_bgcolor / annotation_bordercolor (Label doesn't support them)
395397
396398
Returns:
397-
dict: The same kwargs object, modified in place and also returned.
399+
dict: The same kwargs object, modified (label merged) and returned.
398400
"""
399401
import warnings
400402

403+
# Don't mutate caller's label unless needed
404+
label = kwargs.get("label")
405+
label_out = label.copy() if isinstance(label, dict) else {}
406+
401407
legacy_used = False
402-
label = kwargs.get("label") or {}
403408

404-
# 1) Text
405-
if "annotation_text" in kwargs:
409+
v = kwargs.get("annotation_text", None)
410+
if v is not None and "text" not in label_out:
406411
legacy_used = True
407-
label.setdefault("text", kwargs.pop("annotation_text"))
412+
label_out["text"] = v
408413

409-
# 2) Font (expects a dict like {"family":..., "size":..., "color":...})
410-
if "annotation_font" in kwargs:
414+
v = kwargs.get("annotation_font", None)
415+
if v is not None and "font" not in label_out:
411416
legacy_used = True
412-
label.setdefault("font", kwargs.pop("annotation_font"))
417+
label_out["font"] = v
413418

414-
# 3) Background/border around the text
415-
if "annotation_bgcolor" in kwargs:
416-
legacy_used = True
417-
label.setdefault("bgcolor", kwargs.pop("annotation_bgcolor"))
418-
if "annotation_bordercolor" in kwargs:
419+
v = kwargs.get("annotation_textangle", None)
420+
if v is not None and "textangle" not in label_out:
419421
legacy_used = True
420-
label.setdefault("bordercolor", kwargs.pop("annotation_bordercolor"))
422+
label_out["textangle"] = v
421423

422-
# 4) Angle
423-
if "annotation_textangle" in kwargs:
424-
legacy_used = True
425-
label.setdefault("textangle", kwargs.pop("annotation_textangle"))
424+
# Do NOT touch annotation_position/bgcolor/bordercolor in Step-2
426425

427-
# 5) Position hint from the old API.
428-
# NOTE: We store this temporarily as "position" and will translate it
429-
# to concrete fields (textposition/xanchor/yanchor) in Step 3 when we
430-
# know the shape type (line vs rect) and orientation (v vs h).
431-
if "annotation_position" in kwargs:
432-
legacy_used = True
433-
pos = kwargs.pop("annotation_position")
434-
label.setdefault("position", pos)
435-
436-
# Merge collected label fields back into kwargs["label"] non-destructively
437-
if label:
438-
if "label" in kwargs and isinstance(kwargs["label"], dict):
439-
merged = kwargs["label"].copy()
440-
for k, v in label.items():
441-
merged.setdefault(k, v)
442-
kwargs["label"] = merged
443-
else:
444-
kwargs["label"] = label
426+
if label_out:
427+
kwargs["label"] = label_out # merge result back (non-destructive to legacy)
445428

446429
if legacy_used:
447430
warnings.warn(
@@ -451,8 +434,28 @@ def _coerce_shape_label_from_legacy_annotation_kwargs(kwargs):
451434

452435
return kwargs
453436

454-
455-
437+
def _normalize_legacy_line_position_to_textposition(pos: str) -> str:
438+
"""
439+
Map old annotation_position strings for vline/hline to Label.textposition.
440+
For lines, Plotly.js supports only: "start" | "middle" | "end".
441+
- For vertical lines: "top"->"end", "bottom"->"start"
442+
- For horizontal lines: "left"->"start", "right"->"end"
443+
We’ll resolve orientation in the caller; this returns one of the valid tokens.
444+
Raises ValueError for unknown positions.
445+
"""
446+
if pos is None:
447+
return "middle"
448+
p = pos.strip().lower()
449+
# Common synonyms
450+
if p in ("middle", "center", "centre"):
451+
return "middle"
452+
if p in ("start", "end"):
453+
return p
454+
# Let the caller decide how to turn top/bottom/left/right into start/end;
455+
# here we only validate the token is known.
456+
if any(tok in p for tok in ("top", "bottom", "left", "right")):
457+
return "middle" # caller will override to start/end as needed
458+
raise ValueError(f'Invalid annotation position "{pos}"')
456459

457460
def _generator(i):
458461
""" "cast" an iterator to a generator"""
@@ -4155,32 +4158,94 @@ def _process_multiple_axis_spanning_shapes(
41554158
col = None
41564159
n_shapes_before = len(self.layout["shapes"])
41574160
n_annotations_before = len(self.layout["annotations"])
4158-
# shapes are always added at the end of the tuple of shapes, so we see
4159-
# how long the tuple is before the call and after the call, and adjust
4160-
# the new shapes that were added at the end
4161-
# extract annotation prefixed kwargs
4162-
# annotation with extra parameters based on the annotation_position
4163-
# argument and other annotation_ prefixed kwargs
4164-
shape_kwargs, annotation_kwargs = shapeannotation.split_dict_by_key_prefix(
4165-
kwargs, "annotation_"
4166-
)
4167-
augmented_annotation = shapeannotation.axis_spanning_shape_annotation(
4168-
annotation, shape_type, shape_args, annotation_kwargs
4169-
)
4170-
self.add_shape(
4171-
row=row,
4172-
col=col,
4173-
exclude_empty_subplots=exclude_empty_subplots,
4174-
**_combine_dicts([shape_args, shape_kwargs]),
4175-
)
4176-
if augmented_annotation is not None:
4177-
self.add_annotation(
4178-
augmented_annotation,
4161+
4162+
if shape_type == "vline":
4163+
# Always use a single labeled shape for vlines.
4164+
4165+
# Split kwargs into shape vs legacy annotation_* (which we map to label)
4166+
shape_kwargs, legacy_ann = shapeannotation.split_dict_by_key_prefix(kwargs, "annotation_")
4167+
4168+
# Build/merge label dict: start with explicit label=..., then copy safe legacy fields
4169+
label_dict = (kwargs.get("label") or {}).copy()
4170+
# Reuse Step-2 shim behavior (safe fields only)
4171+
if "text" not in label_dict and "text" in (kwargs.get("label") or {}):
4172+
pass # (explicit label provided)
4173+
else:
4174+
if "annotation_text" in legacy_ann and "text" not in label_dict:
4175+
label_dict["text"] = legacy_ann["annotation_text"]
4176+
if "annotation_font" in legacy_ann and "font" not in label_dict:
4177+
label_dict["font"] = legacy_ann["annotation_font"]
4178+
if "annotation_textangle" in legacy_ann and "textangle" not in label_dict:
4179+
label_dict["textangle"] = legacy_ann["annotation_textangle"]
4180+
4181+
# Position mapping (legacy → label.textposition for LINES)
4182+
# Legacy tests used "top/bottom/left/right". For vlines:
4183+
# top -> end, bottom -> start, middle/center -> middle
4184+
pos_hint = legacy_ann.get("annotation_position", None)
4185+
if "textposition" not in label_dict:
4186+
if pos_hint is not None:
4187+
# validate token (raises ValueError for nonsense)
4188+
_ = _normalize_legacy_line_position_to_textposition(pos_hint)
4189+
p = pos_hint.strip().lower()
4190+
if "top" in p:
4191+
label_dict["textposition"] = "end"
4192+
elif "bottom" in p:
4193+
label_dict["textposition"] = "start"
4194+
elif p in ("middle", "center", "centre"):
4195+
label_dict["textposition"] = "middle"
4196+
# if p only contains left/right, keep default "middle"
4197+
else:
4198+
# default for lines is "middle"
4199+
label_dict.setdefault("textposition", "middle")
4200+
4201+
# NOTE: Label does not support bgcolor/bordercolor; keep emitting a warning when present
4202+
if "annotation_bgcolor" in legacy_ann or "annotation_bordercolor" in legacy_ann:
4203+
import warnings
4204+
warnings.warn(
4205+
"annotation_bgcolor/annotation_bordercolor are not supported on shape.label "
4206+
"and will be ignored; use label.font/color or a background shape instead.",
4207+
FutureWarning,
4208+
)
4209+
4210+
# Build the shape (no arithmetic on x)
4211+
shape_to_add = _combine_dicts([shape_args, shape_kwargs])
4212+
if label_dict:
4213+
shape_to_add["label"] = label_dict
4214+
4215+
self.add_shape(
41794216
row=row,
41804217
col=col,
41814218
exclude_empty_subplots=exclude_empty_subplots,
4182-
yref=shape_kwargs.get("yref", "y"),
4219+
**shape_to_add,
4220+
)
4221+
else:
4222+
4223+
# shapes are always added at the end of the tuple of shapes, so we see
4224+
# how long the tuple is before the call and after the call, and adjust
4225+
# the new shapes that were added at the end
4226+
# extract annotation prefixed kwargs
4227+
# annotation with extra parameters based on the annotation_position
4228+
# argument and other annotation_ prefixed kwargs
4229+
shape_kwargs, annotation_kwargs = shapeannotation.split_dict_by_key_prefix(
4230+
kwargs, "annotation_"
41834231
)
4232+
augmented_annotation = shapeannotation.axis_spanning_shape_annotation(
4233+
annotation, shape_type, shape_args, annotation_kwargs
4234+
)
4235+
self.add_shape(
4236+
row=row,
4237+
col=col,
4238+
exclude_empty_subplots=exclude_empty_subplots,
4239+
**_combine_dicts([shape_args, shape_kwargs]),
4240+
)
4241+
if augmented_annotation is not None:
4242+
self.add_annotation(
4243+
augmented_annotation,
4244+
row=row,
4245+
col=col,
4246+
exclude_empty_subplots=exclude_empty_subplots,
4247+
yref=shape_kwargs.get("yref", "y"),
4248+
)
41844249
# update xref and yref for the new shapes and annotations
41854250
for layout_obj, n_layout_objs_before in zip(
41864251
["shapes", "annotations"], [n_shapes_before, n_annotations_before]

0 commit comments

Comments
 (0)