From e2249de46e015075fdac6bfcbdb871115b1de088 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 7 Oct 2026 23:58:50 +0800 Subject: [PATCH] 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 --- src/turtle_challenges/pen.py | 106 ++++++++++++++++++++++++++++++++ src/turtle_challenges/window.py | 64 +++++++++++++++++++ tests/fakes.py | 68 ++++++++++++++++++++ tests/test_window.py | 33 ++++++++++ 4 files changed, 271 insertions(+) create mode 100644 src/turtle_challenges/pen.py create mode 100644 src/turtle_challenges/window.py create mode 100644 tests/fakes.py create mode 100644 tests/test_window.py diff --git a/src/turtle_challenges/pen.py b/src/turtle_challenges/pen.py new file mode 100644 index 0000000..d05dd93 --- /dev/null +++ b/src/turtle_challenges/pen.py @@ -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. + """ + ... diff --git a/src/turtle_challenges/window.py b/src/turtle_challenges/window.py new file mode 100644 index 0000000..8772228 --- /dev/null +++ b/src/turtle_challenges/window.py @@ -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() diff --git a/tests/fakes.py b/tests/fakes.py new file mode 100644 index 0000000..88f3ed7 --- /dev/null +++ b/tests/fakes.py @@ -0,0 +1,68 @@ +"""Test doubles that stand in for the turtle and its window.""" + +from turtle_challenges.pen import Color + + +class FakePen: + """A pen that records every call instead of drawing. + + The heading follows `setheading` and `right`, like a real turtle that + starts facing east, so code that reads `heading()` behaves as in a window. + """ + + def __init__(self) -> None: + self.calls: list[tuple[str, tuple[object, ...]]] = [] + self._heading = 0.0 + + def _record(self, name: str, *args: object) -> None: + self.calls.append((name, args)) + + def args_of(self, name: str) -> list[tuple[object, ...]]: + """Return the arguments of every call to `name`, in order.""" + return [args for call, args in self.calls if call == name] + + def names(self) -> list[str]: + """Return the name of every call, in order.""" + return [call for call, _ in self.calls] + + def forward(self, distance: float, /) -> None: + self._record("forward", distance) + + def right(self, angle: float, /) -> None: + self._record("right", angle) + self._heading = (self._heading - angle) % 360 + + def penup(self) -> None: + self._record("penup") + + def pendown(self) -> None: + self._record("pendown") + + def color(self, color: Color, /) -> None: + self._record("color", color) + + def circle(self, radius: float, /) -> None: + self._record("circle", radius) + + def heading(self) -> float: + return self._heading + + def setheading(self, to_angle: float, /) -> None: + self._record("setheading", to_angle) + self._heading = to_angle % 360 + + def pensize(self, width: int, /) -> None: + self._record("pensize", width) + + def speed(self, speed: int, /) -> None: + self._record("speed", speed) + + +class FakeWindow: + """A window that records whether it was told to wait for a click.""" + + def __init__(self) -> None: + self.waited_for_click = False + + def exitonclick(self) -> None: + self.waited_for_click = True diff --git a/tests/test_window.py b/tests/test_window.py new file mode 100644 index 0000000..568cb7a --- /dev/null +++ b/tests/test_window.py @@ -0,0 +1,33 @@ +import sys + +import pytest + +from turtle_challenges.window import ( + TURTLE_UNAVAILABLE_MESSAGE, + TurtleUnavailableError, + create_pen, + create_window, +) + + +@pytest.fixture +def without_turtle(monkeypatch: pytest.MonkeyPatch) -> None: + """Make `import turtle` fail, as on a Python without Tk.""" + monkeypatch.setitem(sys.modules, "turtle", None) + + +def test_create_window_reports_a_missing_turtle(without_turtle: None) -> None: + with pytest.raises(TurtleUnavailableError) as error_info: + create_window() + + assert str(error_info.value) == TURTLE_UNAVAILABLE_MESSAGE + assert isinstance(error_info.value.__cause__, ImportError) + + +def test_create_pen_reports_a_missing_turtle(without_turtle: None) -> None: + with pytest.raises(TurtleUnavailableError): + create_pen() + + +def test_the_missing_turtle_message_names_the_debian_package() -> None: + assert "python3-tk" in TURTLE_UNAVAILABLE_MESSAGE