Add the Pen protocol, window helpers and a recording fake turtle

The drawing functions take a Pen, the part of turtle.Turtle they use, so a test can
pass a fake that records every call. window.py is the only module that imports
turtle, and it does so inside the functions so that importing the package needs no Tk.

Task: MIL-002#1
Task: MIL-002#2
This commit is contained in:
2026-10-07 23:58:50 +08:00
parent d38f114cc2
commit e2249de46e
4 changed files with 271 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
"""!
@file pen.py
@brief The turtle methods the challenges use, as protocols.
The drawing functions take a @ref turtle_challenges.pen.Pen instead of creating
a turtle themselves. A real `turtle.Turtle` fits the protocol, and so does the
recording fake of the tests, which lets every challenge be tested without a
window.
"""
from typing import Protocol
## A color as the turtle accepts it here: a name or an RGB tuple of 0 to 255.
Color = str | tuple[int, int, int]
class Pen(Protocol):
"""!
@brief The part of `turtle.Turtle` that the challenges call.
"""
def forward(self, distance: float, /) -> None:
"""!
@brief Move forward, drawing if the pen is down.
@param distance How far to move, in turtle units.
"""
...
def right(self, angle: float, /) -> None:
"""!
@brief Turn clockwise.
@param angle Degrees to turn.
"""
...
def penup(self) -> None:
"""!
@brief Lift the pen: moves leave no line.
"""
...
def pendown(self) -> None:
"""!
@brief Lower the pen: moves draw a line.
"""
...
def color(self, color: Color, /) -> None:
"""!
@brief Set the pen and fill color.
@param color A color name or an RGB tuple of 0 to 255.
"""
...
def circle(self, radius: float, /) -> None:
"""!
@brief Draw a circle that starts at the turtle and curves left.
@param radius Radius of the circle, in turtle units.
"""
...
def heading(self) -> float:
"""!
@brief Return the direction the turtle faces, in degrees.
"""
...
def setheading(self, to_angle: float, /) -> None:
"""!
@brief Face a direction.
@param to_angle Degrees: 0 is east, 90 is north.
"""
...
def pensize(self, width: int, /) -> None:
"""!
@brief Set the thickness of the line.
@param width Thickness in pixels.
"""
...
def speed(self, speed: int, /) -> None:
"""!
@brief Set the animation speed.
@param speed 0 is fastest (no animation); 1 to 10 run from slow to fast.
"""
...
class Window(Protocol):
"""!
@brief The part of the turtle screen that the command line calls.
"""
def exitonclick(self) -> None:
"""!
@brief Keep the window open until it is clicked, then close it.
"""
...
+64
View File
@@ -0,0 +1,64 @@
"""!
@file window.py
@brief Creates the turtle window and the turtle that draws in it.
This is the only module that touches the `turtle` library. The lecture on
imports shows these styles, and this project uses the second one:
- `import turtle` then `turtle.Turtle()`: clear, but long when used often.
- `from turtle import Screen, Turtle`: short, and names exactly what is used.
- `import turtle as t` then `t.Turtle()`: an alias for a long module name.
- `from turtle import *`: avoided, because it hides where a name comes from.
The import sits inside the functions so that importing the package (for
example to run the tests) does not need Tk; only opening a window does.
"""
from turtle_challenges.constants import COLOR_MODE, WINDOW_TITLE
from turtle_challenges.pen import Pen, Window
## Hint shown when Tk is missing, the usual reason `turtle` cannot be imported.
TURTLE_UNAVAILABLE_MESSAGE = (
"the turtle module needs Tk (tkinter), which this Python does not provide; "
"on Debian run: sudo apt install python3-tk"
)
class TurtleUnavailableError(RuntimeError):
"""!
@brief Raised when the `turtle` module cannot be imported.
"""
def create_window() -> Window:
"""!
@brief Open the turtle window with RGB colors from 0 to 255.
@return The window, ready for drawing.
@exception TurtleUnavailableError If `turtle` (Tk) cannot be imported.
"""
try:
from turtle import Screen
except ImportError as error:
raise TurtleUnavailableError(TURTLE_UNAVAILABLE_MESSAGE) from error
screen = Screen()
screen.colormode(COLOR_MODE)
screen.title(WINDOW_TITLE)
return screen
def create_pen() -> Pen:
"""!
@brief Create the turtle that draws in the window.
Call @ref turtle_challenges.window.create_window first, so that the turtle
draws in the window that has the right color mode.
@return A new turtle.
@exception TurtleUnavailableError If `turtle` (Tk) cannot be imported.
"""
try:
from turtle import Turtle
except ImportError as error:
raise TurtleUnavailableError(TURTLE_UNAVAILABLE_MESSAGE) from error
return Turtle()