https://github.com/python/cpython/commit/9d3b0b889b72df25ec5c894437a32e8867ef4e4e
commit: 9d3b0b889b72df25ec5c894437a32e8867ef4e4e
branch: main
author: Stan Ulbrych <[email protected]>
committer: StanFromIreland <[email protected]>
date: 2026-10-02T17:45:11+01:00
summary:
gh-158563: Fix various bugs in `turtle`'s undo handling (#158564)
files:
A Misc/NEWS.d/next/Library/2026-10-01-13-59-02.gh-issue-158563.kR7wQe.rst
M Lib/test/test_turtle.py
M Lib/turtle.py
diff --git a/Lib/test/test_turtle.py b/Lib/test/test_turtle.py
index 7e24f1d526a1bc..88e18e7daa5c5a 100644
--- a/Lib/test/test_turtle.py
+++ b/Lib/test/test_turtle.py
@@ -694,6 +694,34 @@ def test_dot_signature(self):
self.assertRaises(turtle.TurtleGraphicsError, self.turtle.dot, 0, (0,
257, 0))
self.assertRaises(turtle.TurtleGraphicsError, self.turtle.dot, 0, 0,
257, 0)
+ def test_circle_undo(self):
+ self.turtle.circle(50, 90)
+ self.turtle.undo()
+ self.assertEqual(self.turtle.pos(), (0, 0))
+ self.assertEqual(self.turtle.undobufferentries(), 0)
+
+ def test_undo_sequence_resets_after_exception(self):
+ with unittest.mock.patch.object(self.turtle, "_write",
+ side_effect=ValueError):
+ self.assertRaises(ValueError, self.turtle.write, "spam")
+ self.assertFalse(self.turtle.undobuffer.cumulate)
+
+ def test_nested_undo_sequence(self):
+ with self.turtle._undo_sequence():
+ self.turtle.teleport(10, 20)
+ self.turtle.forward(10)
+ self.assertEqual(self.turtle.undobufferentries(), 1)
+ self.turtle.undo()
+ self.assertEqual(self.turtle.pos(), (0, 0))
+
+ def test_stamp_without_undobuffer(self):
+ shape = turtle.Shape("polygon", ((0, 0), (5, 9), (-5, 9)))
+ self.turtle.screen._shapes = {self.turtle.shape(): shape}
+ self.turtle.setundobuffer(None)
+ stamp = self.turtle.stamp()
+ self.turtle.clearstamp(stamp)
+ self.assertEqual(self.turtle.stampItems, [])
+
class TestModuleLevel(unittest.TestCase):
def test_all_signatures(self):
import inspect
diff --git a/Lib/turtle.py b/Lib/turtle.py
index 6e77d257ba8c7f..a731bd69664a4a 100644
--- a/Lib/turtle.py
+++ b/Lib/turtle.py
@@ -897,6 +897,15 @@ def pop(self):
self.ptr = (self.ptr - 1) % self.bufsize
return (item)
+ def remove(self, item):
+ if item not in self.buffer:
+ return
+ index = self.buffer.index(item)
+ self.buffer.remove(item)
+ if index <= self.ptr:
+ self.ptr = (self.ptr - 1) % self.bufsize
+ self.buffer.insert((self.ptr+1) % self.bufsize, [None])
+
def nr_of_items(self):
return self.bufsize - self.buffer.count([None])
@@ -1643,6 +1652,20 @@ def _goto(self, end):
"""Move the turtle to the end position."""
self._position = end
+ @contextmanager
+ def _undo_sequence(self):
+ """Record the enclosed actions as a single undo step."""
+ undobuffer = self.undobuffer
+ if not undobuffer or undobuffer.cumulate:
+ yield
+ return
+ undobuffer.push(["seq"])
+ undobuffer.cumulate = True
+ try:
+ yield
+ finally:
+ undobuffer.cumulate = False
+
def teleport(self, x=None, y=None, *, fill_gap: bool = False) -> None:
"""To be overwritten by child class RawTurtle.
Includes no TPen references."""
@@ -1985,15 +2008,12 @@ def circle(self, radius, extent = None, steps = None):
>>> turtle.circle(50)
>>> turtle.circle(120, 180) # draw a semicircle
"""
- if self.undobuffer:
- self.undobuffer.push(["seq"])
- self.undobuffer.cumulate = True
speed = self.speed()
if extent is None:
extent = self._fullcircle
if steps is None:
frac = abs(extent)/self._fullcircle
- steps = 1+int(min(11+abs(radius)/6.0, 59.0)*frac)
+ steps = 1 + int(min(11 + abs(radius) / 6.0, 59.0) * frac)
w = 1.0 * extent / steps
w2 = 0.5 * w
l = 2.0 * radius * math.sin(math.radians(w2)*self._degreesPerAU)
@@ -2001,22 +2021,21 @@ def circle(self, radius, extent = None, steps = None):
l, w, w2 = -l, -w, -w2
tr = self._tracer()
dl = self._delay()
- if speed == 0:
- self._tracer(0, 0)
- else:
- self.speed(0)
- self._rotate(w2)
- for i in range(steps):
+ with self._undo_sequence():
+ if speed == 0:
+ self._tracer(0, 0)
+ else:
+ self.speed(0)
+ self._rotate(w2)
+ for i in range(steps):
+ self.speed(speed)
+ self._go(l)
+ self.speed(0)
+ self._rotate(w)
+ self._rotate(-w2)
+ if speed == 0:
+ self._tracer(tr, dl)
self.speed(speed)
- self._go(l)
- self.speed(0)
- self._rotate(w)
- self._rotate(-w2)
- if speed == 0:
- self._tracer(tr, dl)
- self.speed(speed)
- if self.undobuffer:
- self.undobuffer.cumulate = False
# Three dummy methods to be implemented by the child class:
@@ -2787,16 +2806,19 @@ def teleport(self, x=None, y=None, *, fill_gap: bool =
False) -> None:
"""
pendown = self.isdown()
was_filling = self.filling()
- if pendown:
- self.pen(pendown=False)
- if was_filling and not fill_gap:
- self.end_fill()
- new_x = x if x is not None else self._position[0]
- new_y = y if y is not None else self._position[1]
- self._position = Vec2D(new_x, new_y)
- self.pen(pendown=pendown)
- if was_filling and not fill_gap:
- self.begin_fill()
+ with self._undo_sequence():
+ if pendown:
+ self.pen(pendown=False)
+ if was_filling and not fill_gap:
+ self.end_fill()
+ new_x = x if x is not None else self._position[0]
+ new_y = y if y is not None else self._position[1]
+ if self.undobuffer:
+ self.undobuffer.push(("teleport", self._position))
+ self._position = Vec2D(new_x, new_y)
+ self.pen(pendown=pendown)
+ if was_filling and not fill_gap:
+ self.begin_fill()
def clone(self):
"""Create and return a clone of the turtle.
@@ -3147,7 +3169,8 @@ def stamp(self):
screen._drawpoly(item, poly, fill=self._cc(fc),
outline=self._cc(oc),
width=self._outlinewidth, top=True)
self.stampItems.append(stitem)
- self.undobuffer.push(("stamp", stitem))
+ if self.undobuffer:
+ self.undobuffer.push(("stamp", stitem))
return stitem
def _clearstamp(self, stampid):
@@ -3162,15 +3185,8 @@ def _clearstamp(self, stampid):
self.stampItems.remove(stampid)
# Delete stampitem from undobuffer if necessary
# if clearstamp is called directly.
- item = ("stamp", stampid)
- buf = self.undobuffer
- if item not in buf.buffer:
- return
- index = buf.buffer.index(item)
- buf.buffer.remove(item)
- if index <= buf.ptr:
- buf.ptr = (buf.ptr - 1) % buf.bufsize
- buf.buffer.insert((buf.ptr+1)%buf.bufsize, [None])
+ if self.undobuffer:
+ self.undobuffer.remove(("stamp", stampid))
def clearstamp(self, stampid):
"""Delete stamp with given stampid
@@ -3468,20 +3484,16 @@ def dot(self, size=None, *color):
color = self._colorstr(color)
# If screen were to gain a dot function, see GH #104218.
pen = self.pen()
- if self.undobuffer:
- self.undobuffer.push(["seq"])
- self.undobuffer.cumulate = True
- try:
- if self.resizemode() == 'auto':
- self.ht()
- self.pendown()
- self.pensize(size)
- self.pencolor(color)
- self.forward(0)
- finally:
- self.pen(pen)
- if self.undobuffer:
- self.undobuffer.cumulate = False
+ with self._undo_sequence():
+ try:
+ if self.resizemode() == 'auto':
+ self.ht()
+ self.pendown()
+ self.pensize(size)
+ self.pencolor(color)
+ self.forward(0)
+ finally:
+ self.pen(pen)
def _write(self, txt, align, font):
"""Performs the writing for write()
@@ -3513,15 +3525,11 @@ def write(self, arg, move=False, align="left",
font=("Arial", 8, "normal")):
>>> turtle.write('Home = ', True, align="center")
>>> turtle.write((0,0), True)
"""
- if self.undobuffer:
- self.undobuffer.push(["seq"])
- self.undobuffer.cumulate = True
- end = self._write(str(arg), align.lower(), font)
- if move:
- x, y = self.pos()
- self.setpos(end, y)
- if self.undobuffer:
- self.undobuffer.cumulate = False
+ with self._undo_sequence():
+ end = self._write(str(arg), align.lower(), font)
+ if move:
+ x, y = self.pos()
+ self.setpos(end, y)
@contextmanager
def poly(self):
@@ -3709,6 +3717,9 @@ def _undo(self, action, data):
self.clearstamp(stitem)
elif action == "go":
self._undogoto(data)
+ elif action == "teleport":
+ self._position = data[0]
+ self._update()
elif action in ["wri", "dot"]:
item = data[0]
self.screen._delete(item)
diff --git
a/Misc/NEWS.d/next/Library/2026-10-01-13-59-02.gh-issue-158563.kR7wQe.rst
b/Misc/NEWS.d/next/Library/2026-10-01-13-59-02.gh-issue-158563.kR7wQe.rst
new file mode 100644
index 00000000000000..938a1b69fd8077
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-10-01-13-59-02.gh-issue-158563.kR7wQe.rst
@@ -0,0 +1,4 @@
+Fix :func:`turtle.undo` after :func:`turtle.teleport` and after an exception
+in :func:`turtle.circle`, :func:`turtle.dot` or :func:`turtle.write`. Fix
+:func:`turtle.stamp`, :func:`turtle.clearstamp`, :func:`turtle.clear` and
+:func:`turtle.reset` when the undo buffer is disabled.
_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]