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]

Reply via email to