Skip to content

Commit bed5626

Browse files
committed
Fix: handle percentage symbols in cursor labels
Fixed ValueError when cursor labels contain "%" followed by format specifiers. Fixes: ValueError: unsupported format character '=' (0x3d)
1 parent 7c60b3b commit bed5626

File tree

2 files changed

+124
-12
lines changed

2 files changed

+124
-12
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@
5656
* Range selection items (`XRangeSelection`, `YRangeSelection`):
5757
* Handles are now displayed only when the item is resizable
5858
* If the item is set as not resizable (using the `set_resizable` method), the handles will be hidden
59+
* Fixed cursor label formatting error with percentage symbols:
60+
* Fixed `ValueError: unsupported format character '=' (0x3d)` when cursor labels contain percentage signs followed by format specifiers (e.g., "Crossing at 20.0% = %g")
61+
* The issue occurred because old-style string formatting (`label % value`) treated the `%` in percentage displays as format specifiers
62+
* Added robust fallback mechanism that tests label format once during cursor creation and uses regex-based replacement when needed
63+
* Performance optimized: format validation is done once at cursor creation time, not on every callback execution
64+
* Affects `vcursor`, `hcursor`, and `xcursor` methods in `CurveMarkerBuilder`
5965

6066
Other changes:
6167

plotpy/builder/curvemarker.py

Lines changed: 118 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from __future__ import annotations
2222

23+
import re
2324
from typing import TYPE_CHECKING
2425

2526
import numpy # only to help intersphinx finding numpy doc
@@ -722,10 +723,40 @@ def label_cb(x, y):
722723
return ""
723724

724725
else:
725-
726-
def label_cb(x, y):
727-
"""Label callback"""
728-
return label % x
726+
# Test the label format once with a dummy value to determine the strategy
727+
dummy_x = 3.14159
728+
try:
729+
_ = label % dummy_x # Test if old-style formatting works
730+
731+
# If we get here, old-style formatting works fine
732+
def label_cb(x, y):
733+
"""Label callback"""
734+
return label % x
735+
except (ValueError, TypeError):
736+
# If old-style formatting fails, prepare regex-based fallback
737+
738+
# Pre-compile patterns for efficiency
739+
patterns = [
740+
(re.compile(r"%g"), lambda x: str(x)),
741+
(re.compile(r"%f"), lambda x: f"{x:f}"),
742+
(re.compile(r"%d"), lambda x: f"{int(x):d}"),
743+
(re.compile(r"%.(\d+)f"), lambda x, m: f"{x:.{m.group(1)}f}"),
744+
(re.compile(r"%.(\d+)g"), lambda x, m: f"{x:.{m.group(1)}g}"),
745+
]
746+
747+
def label_cb(x, y):
748+
"""Label callback with regex-based replacement"""
749+
result = label
750+
for pattern, replacement_func in patterns:
751+
if pattern.groups > 0: # Pattern with groups
752+
753+
def repl(match):
754+
return replacement_func(x, match)
755+
756+
result = pattern.sub(repl, result)
757+
else: # Simple pattern
758+
result = pattern.sub(lambda m: replacement_func(x), result)
759+
return result
729760

730761
return self.marker(
731762
position=(x, 0),
@@ -763,10 +794,41 @@ def label_cb(x, y):
763794
return ""
764795

765796
else:
766-
767-
def label_cb(x, y):
768-
"""Label callback"""
769-
return label % y
797+
# Test the label format once with a dummy value to determine the strategy
798+
dummy_y = 3.14159
799+
try:
800+
_ = label % dummy_y # Test if old-style formatting works
801+
802+
# If we get here, old-style formatting works fine
803+
def label_cb(x, y):
804+
"""Label callback"""
805+
return label % y
806+
except (ValueError, TypeError):
807+
# If old-style formatting fails, prepare regex-based fallback
808+
import re
809+
810+
# Pre-compile patterns for efficiency
811+
patterns = [
812+
(re.compile(r"%g"), lambda y: str(y)),
813+
(re.compile(r"%f"), lambda y: f"{y:f}"),
814+
(re.compile(r"%d"), lambda y: f"{int(y):d}"),
815+
(re.compile(r"%.(\d+)f"), lambda y, m: f"{y:.{m.group(1)}f}"),
816+
(re.compile(r"%.(\d+)g"), lambda y, m: f"{y:.{m.group(1)}g}"),
817+
]
818+
819+
def label_cb(x, y):
820+
"""Label callback with regex-based replacement"""
821+
result = label
822+
for pattern, replacement_func in patterns:
823+
if pattern.groups > 0: # Pattern with groups
824+
825+
def repl(match):
826+
return replacement_func(y, match)
827+
828+
result = pattern.sub(repl, result)
829+
else: # Simple pattern
830+
result = pattern.sub(lambda m: replacement_func(y), result)
831+
return result
770832

771833
return self.marker(
772834
position=(0, y),
@@ -806,10 +868,54 @@ def label_cb(x, y):
806868
return ""
807869

808870
else:
809-
810-
def label_cb(x, y):
811-
"""Label callback"""
812-
return label % (x, y)
871+
# Test the label format once with dummy values to determine the strategy
872+
dummy_x, dummy_y = 3.14159, 2.71828
873+
try:
874+
_ = label % (dummy_x, dummy_y) # Test if old-style formatting works
875+
876+
# If we get here, old-style formatting works fine
877+
def label_cb(x, y):
878+
"""Label callback"""
879+
return label % (x, y)
880+
except (ValueError, TypeError):
881+
# If old-style formatting fails, prepare regex-based fallback
882+
import re
883+
884+
# Pre-compile patterns for efficiency
885+
# For xcursor, handle both single and dual format specifiers
886+
single_patterns = [
887+
(re.compile(r"%g"), lambda val: str(val)),
888+
(re.compile(r"%f"), lambda val: f"{val:f}"),
889+
(re.compile(r"%d"), lambda val: f"{int(val):d}"),
890+
(re.compile(r"%.(\d+)f"), lambda val, m: f"{val:.{m.group(1)}f}"),
891+
(re.compile(r"%.(\d+)g"), lambda val, m: f"{val:.{m.group(1)}g}"),
892+
]
893+
894+
def label_cb(x, y):
895+
"""Label callback with regex-based replacement"""
896+
result = label
897+
# Apply single patterns, alternating between x and y values
898+
x_turn = True # Start with x
899+
for pattern, replacement_func in single_patterns:
900+
if pattern.groups > 0: # Pattern with groups
901+
902+
def repl(match):
903+
nonlocal x_turn
904+
val = x if x_turn else y
905+
x_turn = not x_turn
906+
return replacement_func(val, match)
907+
908+
result = pattern.sub(repl, result)
909+
else: # Simple pattern
910+
911+
def repl(match):
912+
nonlocal x_turn
913+
val = x if x_turn else y
914+
x_turn = not x_turn
915+
return replacement_func(val)
916+
917+
result = pattern.sub(repl, result)
918+
return result
813919

814920
return self.marker(
815921
position=(x, y),

0 commit comments

Comments
 (0)