Improving the Turtle library

The Idea

I’m a big fan of the turtle module (to the point where MarieRoald and I created a library to make embroidery patterns with turtle commands: https://turtlethread.com/), and I have used turtle.py several times in introductory Python courses. However, the more I’ve used it, the more I notice features that I wish were present.

Here is a list of the main features I’m missing and that I’ve found other educators miss as well.

  1. Context managers for begin/end-functions
    • begin_fill/end_fill
    • begin_poly/end_poly
    • turtle.tracer(0)/turtle.update(); turtle.tracer(1) (An easy way to disable automatic canvas updates)
  2. Context managers for other utilities that are frequently changed back and forth in loops
    • penup / pendown (might be fixed with the new teleport method)
    • color, pencolor, fillcolor
    • pensize
  3. An easy way to save drawings
  4. Decoupling turtle.py from Tkinter (big change)

At the end of this post, I have also listed some oddities and (potential) bugs that Marie and I have found working on this.

The Utility

Context managers for begin/end-functions

I’ve typically used Turtle to introduce variables, loops and functions. However, when we move on to context managers, I can no longer use turtle. So I’ll have to teach learners to close their files after opening them and also what a context manager is at the same time. It would be extremely useful to demonstrate context managers with Turtle so I only have to explain one concept at a time.

Filling polygons are particularly well suited for this! You always need to call begin_fill and end_fill, which would be the perfect visual demonstration of what context managers do.

Unfortunately, Turtle doesn’t work this way, so I’ll have to resolve to hand-waving about context managers while also explaining file-opening – and whenever someone asks, “Why don’t we have this in Turtle?” (I’ve been asked on more than one occasion), I don’t have any good answer.

As an addendum to this, I would love a context manager for disabling auto-update that resets and updates the screen once exited.

Example code:

import turtle

with turtle.disable_autoupdate():
    with turtle.fill():
        for side in range(4):
            turtle.forward(50)
            turtle.right(90)

Context managers for other utilities

Really the same as above. However, for the three cases above, we already follow the setup-teardown pattern manually. For penup/pendown, color, pencolor, fillcolor and pensize, we don’t explicitly follow this pattern in the same way. However, we still do often want to change colour, pen size, etc temporarilly.

An easy way to save drawings

The turtle library supports saving the drawings as postscript files by calling the cryptic command turtle.getscreen().getcanvas().postscript(file=filename). Whenever a learner asks “How can I print what I made?”, I’ll have to show them that extremely scary command with method chaining - not the best snippet to show someone who’s still struggling with functions…

If we instead had a turtle.save(filename, format="postscript"), or maybe a turtle.getscreen().save(filename), then I could tell the learners to use that function and an online postscript to pdf converter instead.

An easy way to disable auto-updating the canvas

The Turtle library quickly becomes slow if you want to draw complex drawings, which leads some learners to find the cryptic turtle.tracer(0) command. This command disables automatically updating the canvas, and lets the learner draw many lines at once by calling turtle.update(). This is a must-have for anyone that uses turtle for anything a bit complicated.

Unfortunately, explaining why you write turtle.tracer(0) is not something I have managed in a good way, and I think it would be much easier to tell my learners to use something like “turtle.disable_autoupdate()” instead of turtle.tracer(0).

Decoupling turtle.py from Tkinter

turtle.py is tightly coupled with tkinter. In fact, you cannot even import the turtle module if you have a Python version built without Tkinter. In an ideal world, we would have a general Turtle API in Python where anyone can implement their own backend on top of it. This could enable fully compatible turtle interfaces across various platforms that currently don’t support Tkinter (like PyScript), saving the canvas as something other than a postscript file and more.

There are also several reasons for why I would like to import Turtle on systems without Tkinter both in TurtleThread and elsewhere. In TurtleThread, we inherit from the TNavigator class from the Turtle module. However, we also want it to be possible to run TurtleThread on a web server without Tkinter installed. To facilitate this, we had to copy the TNavigator class into a separate file. We also have some visualisation code that we use Xvfb to test, and it would be great if we could switch out the turtle-type with something that doesn’t draw on a Tkinter canvas.

Marie Roald and I spent some time on PyCon US 2024 trying to figure out how to do this without breaking backwards compatibility and we have since then tried to assemble our thoughts.

Thoughts on how to decouple turtle.py from Tkinter

Some observations about the current API (can be skipped)

  • The API is structured around two classes: A singleton Screen class that contains the Tkinter canvas and a Turtle object that is responsible for drawing on the screen.
  • Most of the user-facing API lies on the Turtle class. However, not everything – Screen.bgcolor being a notable exception.
  • The Screen object contains a ScrolledCanvas – a wrapper of the TKinter canvas that adds some extra functionality.
  • Methods of Turtle instances are forwarded to the Screen instance as drawing instructions and are immediately passed for Tkinter to be drawn during the next iteration of the GUI loop.
  • The code doesn’t follow the encapsulation principle: Whenever Turtle-instances do anything, they call Screen._incrementudc (increment update counter) before checking if the screen should be updated. If so, then the command is passed to the screen. This introduces a bug shown below.
  • Multiple turtles can draw on the same screen, but they can only write to one screen at a time
  • The tight coupling between the “logical drawing” and the GUI window means that we need a larger rewrite of the turtle module if we want to support other backends.
  • Some methods are duplicated with the same name but have different behaviours for turtles and the screen. An example is onscreenclick, which records when the turtle shape is pressed for turtle instances and when the turtle canvas is clicked for the screen instance.

The proposed API

We propose to define an official Turtle interface in a backend-agnostic way. Implementors of new Turtle backends (e.g. for WASM-builds of CPython, Jupyter notebooks or image exporters) can then all agree on how to implement Turtle renderers and what they should include (outside the minimal requirements).

Specifically, we propose to define three main classes: Turtle, Canvas and Renderer. Each turtle draws on exactly one canvas (stored as an immutable variable), and the canvas has a list of turtles that have drawn on it. Whenever a command is passed to a turtle, it forwards it to the Canvas, which stores a programmatic list of them. Then, if the canvas’ current update counter is zero, it forwards the commands to the renderers.

The renderers must be able to do the following:

  • Draw straight lines (remember, circles are polygons in turtle)
  • Undo drawing a line
  • Draw filled polygons
  • Draw polygonal vector “stamps”
  • Set the background colour

The renderers can also do the following

  • Draw custom “stamps” (e.g. raster images)
  • Do live updates of the drawing
  • Support interactivity such as onclick.
  • Support interactive events for clicking the shape of a single turtle.

However, this architecture is not compatible with the current state of affairs. Therefore, to ensure backward compatibility, we can also include a Screen class, which is a wrapper of both a canvas and a renderer (maybe the renderer type can be controlled with an environment variable?).

Oddities, bugs and potential bugs

The turtle.tracer-bug

Run the following code:

import turtle

espen = turtle.Turtle()
gard = turtle.Turtle()
turtle.tracer(3)  # Only update every third command
turtle.bgcolor("pink")
espen.forward(100)
gard.right(90)
gard.forward(100)  # The third command, will trigger screen update

There are two oddities here, one of which is definitely a bug.

  1. Potential bug: The colour of the screen is immediately updated as only turtle instances check if the screen is supposed to be updated
  2. Bug: Both espen and gard have moved, but only gard made a line on the screen.

The Terminator exception

Run the following code

import turtle

turtle.forward(50)
turtle.done()

turtle.forward(100)
turtle.done()

By reading this code, you’d think that you first get one turtle drawing and once you close it, you’ll get a second turtle drawing.
However, this is not the case. You’ll get a turtle.Terminator exception instead as the singleton Screen-object is closed.
I’ve seen people mistakenly saying that calling turtle.bye will fix this, but that is not the case – it will only trigger the turtle.Terminator exception again.
I have two solutions:


import turtle

turtle.forward(50)
turtle.done()

try:
    turtle.bye()
except Exception:
    pass

turtle.forward(100)
turtle.done()

or by manually modifying “private” variables:

import turtle

turtle.forward(50)
turtle.done()

turtle.Turtle._screen = None  # force recreation of singleton Screen object
turtle.TurtleScreen._RUNNING = True  # only set upon TurtleScreen() definition

turtle.forward(100)
turtle.done()

neither are good solutions in an intro to Python class.

This bug has been a big headache during teaching since this bug will trigger for anyone that use an interactive coding environment like Spyder.

Undo quircks

Turtle-specific undo buffers

Each turtle object has an undo-buffer, and calling turtle.undo() will undo the latest command by the global Turtle-instance, so if you have multiple turtles (e.g. inêsand the global turtle-instance), then turtle.undo() might behave in surprising ways. Try, for example:

import turtle

inês = turtle.Turtle()
inês.forward(100)
turtle.undo()

Confusing clone behaviour

We can also clone turtles, which is even more confusing!

import turtle

inês = turtle.Turtle()
inês.forward(100)
inês2 = inês.clone()
inês2.undo()

Here, inês2 moves back to to (0, 0) while inês stays put at (100, 0). However, the line that inês drew disappeared!

Custom deque?

The undo-function implements its own circular buffer instead of using a deque. I cannot understand why, especially since (as far as I can tell) the deque was added before undo was added to turtle.py?

Prior Discussion

I haven’t found any discussion on this online, but I mentioned it to some core team members and educators at PyCon US 2023 and PyCon US 2024 and received positive feedback – educators, in particular, seemed excited about the proposed changes. The main question was how functional changes to the turtle module would affect existing teaching materials and schools and universities that may be on older versions of Python.

While it could be confusing for learners in the beginning to see that Python code they find online doesn’t work on a school computer with an old Python version, I don’t think that alone is worth skipping these changes. Teaching materials change all the time, and I believe that these changes will bring more positive changes than negative.

Who will make these changes

Marie and I would be happy to attempt to make these changes. However, our free time is unfortunately very limited these days, so progress on the main rewrite will be slow if we lead the work on it.

25 Likes

My guess is to support the cumulate flag. You can still back it with a deque, looks like it saves a few lines:

diff --git a/Lib/turtle.py b/Lib/turtle.py
index 99850ae5ef..64969988f7 100644
--- a/Lib/turtle.py
+++ b/Lib/turtle.py
@@ -100,6 +100,7 @@
 
 import tkinter as TK
 import types
+import collections
 import math
 import time
 import inspect
@@ -905,42 +906,33 @@ def addcomponent(self, poly, fill, outline=None):
         self._data.append([poly, fill, outline])
 
 
-class Tbuffer(object):
-    """Ring buffer used as undobuffer for RawTurtle objects."""
+class Tbuffer:
+    """Used for undobuffer of RawTurtle objects.
+    Light wrapper around collections.deque that additionally allows aggregation
+    of items when the cumulate flag is set.
+    """
     def __init__(self, bufsize=10):
-        self.bufsize = bufsize
-        self.buffer = [[None]] * bufsize
-        self.ptr = -1
+        self.buf = collections.deque(maxlen=bufsize)
         self.cumulate = False
     def reset(self, bufsize=None):
         if bufsize is None:
-            for i in range(self.bufsize):
-                self.buffer[i] = [None]
+            self.buf.clear()
         else:
-            self.bufsize = bufsize
-            self.buffer = [[None]] * bufsize
-        self.ptr = -1
+            self.buf = collections.deque(maxlen=bufsize)
     def push(self, item):
-        if self.bufsize > 0:
-            if not self.cumulate:
-                self.ptr = (self.ptr + 1) % self.bufsize
-                self.buffer[self.ptr] = item
-            else:
-                self.buffer[self.ptr].append(item)
+        if self.cumulate:
+            self.buf[-1].append(item)
+        else:
+            self.buf.append(item)
     def pop(self):
-        if self.bufsize > 0:
-            item = self.buffer[self.ptr]
-            if item is None:
-                return None
-            else:
-                self.buffer[self.ptr] = [None]
-                self.ptr = (self.ptr - 1) % self.bufsize
-                return (item)
+        try:
+            return self.buf.pop()
+        except IndexError:
+            return None
     def nr_of_items(self):
-        return self.bufsize - self.buffer.count([None])
+        return len(self.buf)
     def __repr__(self):
-        return str(self.buffer) + " " + str(self.ptr)
-
+        return str(self.buf)
 
 
 class TurtleScreen(TurtleScreenBase):
@@ -3130,14 +3122,9 @@ def _clearstamp(self, stampid):
         # Delete stampitem from undobuffer if necessary
         # if clearstamp is called directly.
         item = ("stamp", stampid)
-        buf = self.undobuffer
-        if item not in buf.buffer:
+        if item not in self.undobuffer.buf:
             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])
+        self.undobuffer.buf.remove(item)
 
     def clearstamp(self, stampid):
         """Delete stamp with given stampid
1 Like

I see lots of support and no objections to your ideas!

To get started, would you like to pick something smallish with high impact? (Perhaps the new save() method?) Each will need its own issue and PR. Instructions are in the devguide, but don’t hesitate to ask questions in the issue, this topic, or new topics here.

Obviously the big rewrite is a lot of work and would need careful planning by volunteers with enough spare time. It could be that a new API or module is better. But let’s get the ball rolling turtle shuffling with some incremental improvements. :turtle:

5 Likes

FYI, it looks like there’s a typo in the GitHub link. I believe the intended target is https://github.com/MarieRoald.

Echoing what @hugovk said - I don’t see anything especially controversial in the suggestions you’re making here. There might be some details to work out when we get down to actual PRs, but at a high level, these all seem like reasonable and achievable goals (or potential bugs worth investigating further). I’m also aware that Turtle is a frequently used module in an education context, so improvements and bugfixes are well worth the effort.

When you do get to the point of a PR, feel free to tag me personally for a review - I’m happy to work with you to get these changes in. In particular, item (4) in your list of proposed work is of interest to me, as a Toga-based backend/replacement for Turtle has been on my TODO list for a long time.

However that item is a big one, and probably the last one to tackle. In the meantime, start with smaller PRs addressing specific bugs and other items on your list, and we can start making a better Turtle :turtle: :slight_smile:

4 Likes

save() is probably a good place to start, so we’ll give it a try – it’s a nice to start with something small. We’ll hopefully have something this week :slight_smile:

I’m really torn on this. On one hand, it would be nice to change some of the old functionality – especially with regards to interactivity and the more esoteric functions. But there’s a fine balance to make sure that we don’t break e.g. textbooks for schools as they are famously very expensive to replace.

You’re right, thanks for spotting! Unfortunately I can no longer edit the post.

Great, we’ll do that then :slight_smile: Funny how we might have accidentally ended up with another huge BeeWare task :honeybee: :briefcase:

2 Likes

Then here’s a clickable link :slight_smile: MarieRoald (Marie Roald) · GitHub

For others, I recommend Marie and Yngve’s excellent :turtle::sewing_needle: talk from PyCon US last year:

1 Like

Ok, we just posted the PR now: gh-123614: Add save function to turtle.py by yngvem · Pull Request #123617 · python/cpython (github.com)

Also, a bit of an aside, but I’m very impressed with the devguide, lots of work has clearly been put into it!

6 Likes

I think the turtle module has long been due for improvement. Turtle graphics as a learning tool are a bit old-fashioned; they were a hit when computers were making the switch from printouts to CRT screens. But I think we can still get a lot of mileage out them.

Several years ago I had an idea for creating non-English function names for turtle just to make it easier for instructors and students outside the anglosphere to use. While crowding the API with adelante() and avant() in addition to forward() isn’t best practice for code, it does make sense here. https://github.com/asweigart/tortuga

As long as we don’t break backwards compatibility, I’d love to put new additions in turtle.py. I don’t want to give up on it quite yet.

I’ve also had an idea for a Simple English turtle tutorial that could be easily translated into other languages. https://github.com/asweigart/simple-turtle-tutorial-for-python/

These projects have been sitting on the back burner for me for a while, but if anyone is interested, reach out.

1 Like

Great ideas.

These will require some changes in documentation, too.
So, I propose a major change to it: moving the ‘Tutorial’ and ‘How to’ sections to document(s) on Python HOWTOs.

After that, maybe the Introduction and Get Started sections can be merged.

In other words, the document in /library will only have the module reference.

1 Like

It’s great to be thinking about how to best organize the docs. As a counter-proposal, take a look at the sqlite3 module docs, which explicitly segment into tutorial, reference, and guides, but all co-located.

3 Likes

turtle.py describes itself as “an extended reimplementation of turtle.py from the Python standard distribution up to Python 2.5.” In a real sense, it is ‘turtle2’, and replaced ‘turtle1’. Some things to consider:

Copyright & License: turtle.py is “Copyright (C) 2006 - 2010 Gregor Lingl” with no reference to the turtle1 PSF copyright. So it claims to be fresh code. It has its own license, again without reference to the PSF license. The file was merged without a signed CLA. Several years ago, I twice emailed Gregor requesting that he sign one. He agreed but never did so. It seems to me that PSF is distributing at least Gregor’s code under his license, not the PSF license.

The turtle license item 2 says

  1. Altered source versions must be plainly marked as such, and must not be
    misrepresented as being the original software.

We are in violation of both clauses. The file is label as “Version 1.1b - 4. 5. 2009”, which is the ‘original software’ with no indication that is has been altered.

@hugovk I think a) we should add the required notice before encouraging people to put substantial energy into further changes; (b) the added notice should indicate when a change was first made by anyone other than Lingl; and c) claim PSF copywrite and license for alterations.

Code style: turtle1 was written directly in fairly plain tkinter. For the purpose of teaching tkinter, it would probably have been better to have patched the original in the same style. Gregor explained to me that he made the code more indirect because he planned to add a second backend. But he abandoned this idea soon after it was merged into the stdlib.

SVG and tkinter: Current tcl/tkinter is 8.6.15, and there should be more 8.6 releases. tcl/tkinter 9.0 is reported to be in candidate stage with the final release soon. Most important for IDLE is full support for all of unicode, not just the basic multilingual plane. More important for other tkinter apps may be some use of SVG (ScaledVectorGraphics). The announcement hinted that more integration of SVG is intended. Perhaps we will eventually see an option to save as .svg files instead of postscript .ps.

Web and SVG: There is a program that runs python in browsers with simple drawing available. It uses WASM or something related but I don’t remember the name or the program name. Curious about the drawing part, I downloaded the code. It implements a subset of turtle commands with html svg elements.

For other people to know, we’ve continued this topic in another thread.

That would be a big win.

Should we revert to turtle1 and make these improvements/rewrites to that code base?

And about make possible to use turtle without the graphics window?

In an educational context, the teacher can provide automatic tests that run on a student’s computer without screens or in the server, which doesn’t screen as we know.

This can also be useful in simulation which requires geometric computations.

I also considered using the new save method to compare the result with a reference image. Is this possible? (This idea doesn’t cancel the previous one.)

Raymond Hettinger mentioned that idea a decade ago. I have no plan to try it and would not suggest it unless a real problem arises or if there were other positive motivation. It is possible that Gregor later signed the CLA and I missed the fact, or that he still would if properly asked by someone else.