]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-126349: Add 'fill', 'poly', and 'no_animation' context managers to turtle (#126350)
authorMarie Roald <roald.marie@gmail.com>
Sat, 18 Jan 2025 10:27:22 +0000 (11:27 +0100)
committerGitHub <noreply@github.com>
Sat, 18 Jan 2025 10:27:22 +0000 (11:27 +0100)
Co-authored-by: Marie Roald <roald.marie@gmail.com>
Co-authored-by: Yngve Mardal Moe <yngve.m.moe@gmail.com>
Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com>
Co-authored-by: Daniel Hollas <danekhollas@gmail.com>
Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com>
Co-authored-by: Erlend E. Aasland <erlend@python.org>
Doc/library/turtle.rst
Doc/whatsnew/3.14.rst
Lib/test/test_turtle.py
Lib/turtle.py
Misc/NEWS.d/next/Core_and_Builtins/2024-11-03-06-05-16.gh-issue-126349.7YwWsI.rst [new file with mode: 0644]

index 548b9d3543fb948aea192c510080ea2467c87a1c..98dfa0f53bc6a516470040189b3c2832fe7d74f7 100644 (file)
@@ -213,6 +213,31 @@ useful when working with learners for whom typing is not a skill.
     use turtle graphics with a learner.
 
 
+Automatically begin and end filling
+-----------------------------------
+
+Starting with Python 3.14, you can use the :func:`fill` :term:`context manager`
+instead of :func:`begin_fill` and :func:`end_fill` to automatically begin and
+end fill. Here is an example::
+
+   with fill():
+       for i in range(4):
+           forward(100)
+           right(90)
+
+   forward(200)
+
+The code above is equivalent to::
+
+   begin_fill()
+   for i in range(4):
+       forward(100)
+       right(90)
+   end_fill()
+
+   forward(200)
+
+
 Use the ``turtle`` module namespace
 -----------------------------------
 
@@ -351,6 +376,7 @@ Pen control
 
    Filling
       | :func:`filling`
+      | :func:`fill`
       | :func:`begin_fill`
       | :func:`end_fill`
 
@@ -381,6 +407,7 @@ Using events
    | :func:`ondrag`
 
 Special Turtle methods
+   | :func:`poly`
    | :func:`begin_poly`
    | :func:`end_poly`
    | :func:`get_poly`
@@ -403,6 +430,7 @@ Window control
    | :func:`setworldcoordinates`
 
 Animation control
+   | :func:`no_animation`
    | :func:`delay`
    | :func:`tracer`
    | :func:`update`
@@ -1275,6 +1303,29 @@ Filling
       ... else:
       ...    turtle.pensize(3)
 
+.. function:: fill()
+
+   Fill the shape drawn in the ``with turtle.fill():`` block.
+
+   .. doctest::
+      :skipif: _tkinter is None
+
+      >>> turtle.color("black", "red")
+      >>> with turtle.fill():
+      ...     turtle.circle(80)
+
+   Using :func:`!fill` is equivalent to adding the :func:`begin_fill` before the
+   fill-block and :func:`end_fill` after the fill-block:
+
+   .. doctest::
+      :skipif: _tkinter is None
+
+      >>> turtle.color("black", "red")
+      >>> turtle.begin_fill()
+      >>> turtle.circle(80)
+      >>> turtle.end_fill()
+
+   .. versionadded:: next
 
 
 .. function:: begin_fill()
@@ -1648,6 +1699,23 @@ Using events
 Special Turtle methods
 ----------------------
 
+
+.. function:: poly()
+
+   Record the vertices of a polygon drawn in the ``with turtle.poly():`` block.
+   The first and last vertices will be connected.
+
+   .. doctest::
+      :skipif: _tkinter is None
+
+      >>> with turtle.poly():
+      ...     turtle.forward(100)
+      ...     turtle.right(60)
+      ...     turtle.forward(100)
+
+   .. versionadded:: next
+
+
 .. function:: begin_poly()
 
    Start recording the vertices of a polygon.  Current turtle position is first
@@ -1926,6 +1994,23 @@ Window control
 Animation control
 -----------------
 
+.. function:: no_animation()
+
+   Temporarily disable turtle animation. The code written inside the
+   ``no_animation`` block will not be animated;
+   once the code block is exited, the drawing will appear.
+
+   .. doctest::
+      :skipif: _tkinter is None
+
+      >>> with screen.no_animation():
+      ...     for dist in range(2, 400, 2):
+      ...         fd(dist)
+      ...         rt(90)
+
+   .. versionadded:: next
+
+
 .. function:: delay(delay=None)
 
    :param delay: positive integer
index 9f7ef101e56478eedf97c2fc0c7d2b7d8199c3bf..d6aa6b346417e5f608e2ef06ef98858fab04e8f5 100644 (file)
@@ -660,6 +660,14 @@ tkinter
   (Contributed by Zhikang Yan in :gh:`126899`.)
 
 
+turtle
+------
+
+* Add context managers for :func:`turtle.fill`, :func:`turtle.poly`
+  and :func:`turtle.no_animation`.
+  (Contributed by Marie Roald and Yngve Mardal Moe in :gh:`126350`.)
+
+
 unicodedata
 -----------
 
index c75a002a89b4c40a44cecf0dca0b6f23a34aab81..de6508ff8c791df4152ad235c07f6c5a56c36b13 100644 (file)
@@ -1,9 +1,9 @@
 import os
 import pickle
 import re
+import tempfile
 import unittest
 import unittest.mock
-import tempfile
 from test import support
 from test.support import import_helper
 from test.support import os_helper
@@ -54,6 +54,21 @@ visible = False
 """
 
 
+def patch_screen():
+    """Patch turtle._Screen for testing without a display.
+
+    We must patch the _Screen class itself instead of the _Screen
+    instance because instantiating it requires a display.
+    """
+    return unittest.mock.patch(
+        "turtle._Screen.__new__",
+        **{
+            "return_value.__class__": turtle._Screen,
+            "return_value.mode.return_value": "standard",
+        },
+    )
+
+
 class TurtleConfigTest(unittest.TestCase):
 
     def get_cfg_file(self, cfg_str):
@@ -513,7 +528,7 @@ class TestTurtleScreen(unittest.TestCase):
 
             turtle.TurtleScreen.save(screen, file_path, overwrite=True)
             with open(file_path) as f:
-                assert f.read() == "postscript"
+                self.assertEqual(f.read(), "postscript")
 
     def test_save(self) -> None:
         screen = unittest.mock.Mock()
@@ -524,7 +539,98 @@ class TestTurtleScreen(unittest.TestCase):
 
             turtle.TurtleScreen.save(screen, file_path)
             with open(file_path) as f:
-                assert f.read() == "postscript"
+                self.assertEqual(f.read(), "postscript")
+
+    def test_no_animation_sets_tracer_0(self):
+        s = turtle.TurtleScreen(cv=unittest.mock.MagicMock())
+
+        with s.no_animation():
+            self.assertEqual(s.tracer(), 0)
+
+    def test_no_animation_resets_tracer_to_old_value(self):
+        s = turtle.TurtleScreen(cv=unittest.mock.MagicMock())
+
+        for tracer in [0, 1, 5]:
+            s.tracer(tracer)
+            with s.no_animation():
+                pass
+            self.assertEqual(s.tracer(), tracer)
+
+    def test_no_animation_calls_update_at_exit(self):
+        s = turtle.TurtleScreen(cv=unittest.mock.MagicMock())
+        s.update = unittest.mock.MagicMock()
+
+        with s.no_animation():
+            s.update.assert_not_called()
+        s.update.assert_called_once()
+
+
+class TestTurtle(unittest.TestCase):
+    def setUp(self):
+        with patch_screen():
+            self.turtle = turtle.Turtle()
+
+    def test_begin_end_fill(self):
+        self.assertFalse(self.turtle.filling())
+        self.turtle.begin_fill()
+        self.assertTrue(self.turtle.filling())
+        self.turtle.end_fill()
+        self.assertFalse(self.turtle.filling())
+
+    def test_fill(self):
+        # The context manager behaves like begin_fill and end_fill.
+        self.assertFalse(self.turtle.filling())
+        with self.turtle.fill():
+            self.assertTrue(self.turtle.filling())
+        self.assertFalse(self.turtle.filling())
+
+    def test_fill_resets_after_exception(self):
+        # The context manager cleans up correctly after exceptions.
+        try:
+            with self.turtle.fill():
+                self.assertTrue(self.turtle.filling())
+                raise ValueError
+        except ValueError:
+            self.assertFalse(self.turtle.filling())
+
+    def test_fill_context_when_filling(self):
+        # The context manager works even when the turtle is already filling.
+        self.turtle.begin_fill()
+        self.assertTrue(self.turtle.filling())
+        with self.turtle.fill():
+            self.assertTrue(self.turtle.filling())
+        self.assertFalse(self.turtle.filling())
+
+    def test_begin_end_poly(self):
+        self.assertFalse(self.turtle._creatingPoly)
+        self.turtle.begin_poly()
+        self.assertTrue(self.turtle._creatingPoly)
+        self.turtle.end_poly()
+        self.assertFalse(self.turtle._creatingPoly)
+
+    def test_poly(self):
+        # The context manager behaves like begin_poly and end_poly.
+        self.assertFalse(self.turtle._creatingPoly)
+        with self.turtle.poly():
+            self.assertTrue(self.turtle._creatingPoly)
+        self.assertFalse(self.turtle._creatingPoly)
+
+    def test_poly_resets_after_exception(self):
+        # The context manager cleans up correctly after exceptions.
+        try:
+            with self.turtle.poly():
+                self.assertTrue(self.turtle._creatingPoly)
+                raise ValueError
+        except ValueError:
+            self.assertFalse(self.turtle._creatingPoly)
+
+    def test_poly_context_when_creating_poly(self):
+        # The context manager works when the turtle is already creating poly.
+        self.turtle.begin_poly()
+        self.assertTrue(self.turtle._creatingPoly)
+        with self.turtle.poly():
+            self.assertTrue(self.turtle._creatingPoly)
+        self.assertFalse(self.turtle._creatingPoly)
 
 
 class TestModuleLevel(unittest.TestCase):
index 1320cfd93fd6db55c22d3724d1e243f48e6f4a8e..e88981d298ad52eb2f49f6d0eed7760b8dd1a688 100644 (file)
@@ -107,6 +107,7 @@ import sys
 
 from os.path import isfile, split, join
 from pathlib import Path
+from contextlib import contextmanager
 from copy import deepcopy
 from tkinter import simpledialog
 
@@ -114,23 +115,24 @@ _tg_classes = ['ScrolledCanvas', 'TurtleScreen', 'Screen',
                'RawTurtle', 'Turtle', 'RawPen', 'Pen', 'Shape', 'Vec2D']
 _tg_screen_functions = ['addshape', 'bgcolor', 'bgpic', 'bye',
         'clearscreen', 'colormode', 'delay', 'exitonclick', 'getcanvas',
-        'getshapes', 'listen', 'mainloop', 'mode', 'numinput',
+        'getshapes', 'listen', 'mainloop', 'mode', 'no_animation', 'numinput',
         'onkey', 'onkeypress', 'onkeyrelease', 'onscreenclick', 'ontimer',
         'register_shape', 'resetscreen', 'screensize', 'save', 'setup',
-        'setworldcoordinates', 'textinput', 'title', 'tracer', 'turtles', 'update',
-        'window_height', 'window_width']
+        'setworldcoordinates', 'textinput', 'title', 'tracer', 'turtles',
+        'update', 'window_height', 'window_width']
 _tg_turtle_functions = ['back', 'backward', 'begin_fill', 'begin_poly', 'bk',
         'circle', 'clear', 'clearstamp', 'clearstamps', 'clone', 'color',
         'degrees', 'distance', 'dot', 'down', 'end_fill', 'end_poly', 'fd',
-        'fillcolor', 'filling', 'forward', 'get_poly', 'getpen', 'getscreen', 'get_shapepoly',
-        'getturtle', 'goto', 'heading', 'hideturtle', 'home', 'ht', 'isdown',
-        'isvisible', 'left', 'lt', 'onclick', 'ondrag', 'onrelease', 'pd',
-        'pen', 'pencolor', 'pendown', 'pensize', 'penup', 'pos', 'position',
-        'pu', 'radians', 'right', 'reset', 'resizemode', 'rt',
-        'seth', 'setheading', 'setpos', 'setposition',
-        'setundobuffer', 'setx', 'sety', 'shape', 'shapesize', 'shapetransform', 'shearfactor', 'showturtle',
-        'speed', 'st', 'stamp', 'teleport', 'tilt', 'tiltangle', 'towards',
-        'turtlesize', 'undo', 'undobufferentries', 'up', 'width',
+        'fillcolor', 'fill', 'filling', 'forward', 'get_poly', 'getpen',
+        'getscreen', 'get_shapepoly', 'getturtle', 'goto', 'heading',
+        'hideturtle', 'home', 'ht', 'isdown', 'isvisible', 'left', 'lt',
+        'onclick', 'ondrag', 'onrelease', 'pd', 'pen', 'pencolor', 'pendown',
+        'pensize', 'penup', 'poly', 'pos', 'position', 'pu', 'radians', 'right',
+        'reset', 'resizemode', 'rt', 'seth', 'setheading', 'setpos',
+        'setposition', 'setundobuffer', 'setx', 'sety', 'shape', 'shapesize',
+        'shapetransform', 'shearfactor', 'showturtle', 'speed', 'st', 'stamp',
+        'teleport', 'tilt', 'tiltangle', 'towards', 'turtlesize', 'undo',
+        'undobufferentries', 'up', 'width',
         'write', 'xcor', 'ycor']
 _tg_utilities = ['write_docstringdict', 'done']
 
@@ -1275,6 +1277,26 @@ class TurtleScreen(TurtleScreenBase):
             return self._delayvalue
         self._delayvalue = int(delay)
 
+    @contextmanager
+    def no_animation(self):
+        """Temporarily turn off auto-updating the screen.
+
+        This is useful for drawing complex shapes where even the fastest setting
+        is too slow. Once this context manager is exited, the drawing will
+        be displayed.
+
+        Example (for a TurtleScreen instance named screen
+        and a Turtle instance named turtle):
+        >>> with screen.no_animation():
+        ...    turtle.circle(50)
+        """
+        tracer = self.tracer()
+        try:
+            self.tracer(0)
+            yield
+        finally:
+            self.tracer(tracer)
+
     def _incrementudc(self):
         """Increment update counter."""
         if not TurtleScreen._RUNNING:
@@ -3380,6 +3402,24 @@ class RawTurtle(TPen, TNavigator):
         """
         return isinstance(self._fillpath, list)
 
+    @contextmanager
+    def fill(self):
+        """A context manager for filling a shape.
+
+        Implicitly ensures the code block is wrapped with
+        begin_fill() and end_fill().
+
+        Example (for a Turtle instance named turtle):
+        >>> turtle.color("black", "red")
+        >>> with turtle.fill():
+        ...     turtle.circle(60)
+        """
+        self.begin_fill()
+        try:
+            yield
+        finally:
+            self.end_fill()
+
     def begin_fill(self):
         """Called just before drawing a shape to be filled.
 
@@ -3400,7 +3440,6 @@ class RawTurtle(TPen, TNavigator):
             self.undobuffer.push(("beginfill", self._fillitem))
         self._update()
 
-
     def end_fill(self):
         """Fill the shape drawn after the call begin_fill().
 
@@ -3504,6 +3543,27 @@ class RawTurtle(TPen, TNavigator):
         if self.undobuffer:
             self.undobuffer.cumulate = False
 
+    @contextmanager
+    def poly(self):
+        """A context manager for recording the vertices of a polygon.
+
+        Implicitly ensures that the code block is wrapped with
+        begin_poly() and end_poly()
+
+        Example (for a Turtle instance named turtle) where we create a
+        triangle as the polygon and move the turtle 100 steps forward:
+        >>> with turtle.poly():
+        ...     for side in range(3)
+        ...         turtle.forward(50)
+        ...         turtle.right(60)
+        >>> turtle.forward(100)
+        """
+        self.begin_poly()
+        try:
+            yield
+        finally:
+            self.end_poly()
+
     def begin_poly(self):
         """Start recording the vertices of a polygon.
 
diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2024-11-03-06-05-16.gh-issue-126349.7YwWsI.rst b/Misc/NEWS.d/next/Core_and_Builtins/2024-11-03-06-05-16.gh-issue-126349.7YwWsI.rst
new file mode 100644 (file)
index 0000000..aecc8c9
--- /dev/null
@@ -0,0 +1,2 @@
+Add :func:`turtle.fill`, :func:`turtle.poly` and :func:`turtle.no_animation` context managers.
+Patch by Marie Roald and Yngve Mardal Moe.