python -m turtle_challenges <challenge> and the turtle-challenges command open a window, run the named challenge and wait for a click. square and dashed-line are registered; a missing Tk is reported with a hint instead of a traceback. Task: MIL-002#6
This commit is contained in:
@@ -64,11 +64,37 @@ python -m pip install -e ".[dev]"
|
||||
|
||||
## Run
|
||||
|
||||
There is no challenge to run yet: the challenges arrive with milestones 002 to 004. To check the set-up, run the tests described in the next section.
|
||||
With the virtual environment active, run one challenge by name. A window opens, the turtle draws, and the window closes when you click it.
|
||||
|
||||
```bash
|
||||
turtle-challenges square
|
||||
python -m turtle_challenges dashed-line
|
||||
```
|
||||
|
||||
| Challenge | Command | Function |
|
||||
| --- | --- | --- |
|
||||
| 1. Draw a square | `turtle-challenges square` | `draw_square` |
|
||||
| 2. Draw a dashed line | `turtle-challenges dashed-line` | `draw_dashed_line` |
|
||||
|
||||
Challenges 3 to 5 are added by milestones 003 and 004.
|
||||
|
||||
`turtle-challenges --help` lists the challenges. If the command prints that the turtle module needs Tk, install Tk as described under Requirements.
|
||||
|
||||
To use the functions in your own code, create a window and a turtle, then pass the turtle as the first argument. The assignment's functions use one global turtle; here the turtle is a parameter, which is what lets the tests run without a window:
|
||||
|
||||
```python
|
||||
from turtle_challenges import draw_square
|
||||
from turtle_challenges.window import create_pen, create_window
|
||||
|
||||
window = create_window()
|
||||
tim = create_pen()
|
||||
draw_square(tim)
|
||||
window.exitonclick()
|
||||
```
|
||||
|
||||
## Run the tests
|
||||
|
||||
The tests never open a window. With the virtual environment active:
|
||||
The tests never open a window: they pass a recording fake in place of the turtle and check the moves it recorded. With the virtual environment active:
|
||||
|
||||
```bash
|
||||
python -m pytest
|
||||
@@ -103,8 +129,13 @@ Open `build/html/index.html` in a browser. Any Doxygen warning fails the build,
|
||||
├── .github/workflows/ci.yml continuous integration
|
||||
├── docs/ project documents: business case, plan, milestones
|
||||
├── src/turtle_challenges/ the package
|
||||
│ └── constants.py every constant of the project
|
||||
├── tests/ pytest tests
|
||||
│ ├── constants.py every constant of the project
|
||||
│ ├── pen.py the Pen and Window protocols the challenges use
|
||||
│ ├── window.py opens the turtle window and creates the turtle
|
||||
│ ├── square.py challenge 1: draw_square
|
||||
│ ├── dashed_line.py challenge 2: draw_dashed_line
|
||||
│ └── cli.py the turtle-challenges command
|
||||
├── tests/ pytest tests and the recording fake pen
|
||||
├── Doxyfile Doxygen configuration
|
||||
├── pyproject.toml project, tool and dependency configuration
|
||||
└── LICENSE
|
||||
|
||||
@@ -25,6 +25,9 @@ dev = [
|
||||
"ruff>=0.8",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
turtle-challenges = "turtle_challenges.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""!
|
||||
@file __main__.py
|
||||
@brief Entry point of `python -m turtle_challenges`.
|
||||
"""
|
||||
|
||||
from turtle_challenges.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,74 @@
|
||||
"""!
|
||||
@file cli.py
|
||||
@brief Command line: run one challenge in a turtle window.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from collections.abc import Callable, Sequence
|
||||
from functools import partial
|
||||
|
||||
from turtle_challenges.constants import (
|
||||
CHALLENGE_DASHED_LINE,
|
||||
CHALLENGE_NAMES,
|
||||
CHALLENGE_SQUARE,
|
||||
)
|
||||
from turtle_challenges.dashed_line import draw_dashed_line
|
||||
from turtle_challenges.pen import Pen
|
||||
from turtle_challenges.square import draw_square
|
||||
from turtle_challenges.window import (
|
||||
TurtleUnavailableError,
|
||||
create_pen,
|
||||
create_window,
|
||||
)
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""!
|
||||
@brief Build the argument parser of the command line.
|
||||
|
||||
@return A parser with the challenge name.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="turtle-challenges",
|
||||
description="Run one of the turtle challenges in a window. "
|
||||
"Click the window to close it.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"challenge",
|
||||
choices=CHALLENGE_NAMES,
|
||||
help="the challenge to run",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def build_challenges(pen: Pen) -> dict[str, Callable[[], None]]:
|
||||
"""!
|
||||
@brief Bind every challenge to a turtle.
|
||||
|
||||
@param pen The turtle that draws.
|
||||
@return A mapping from the command line name to a function without
|
||||
arguments that draws the challenge.
|
||||
"""
|
||||
return {
|
||||
CHALLENGE_SQUARE: partial(draw_square, pen),
|
||||
CHALLENGE_DASHED_LINE: partial(draw_dashed_line, pen),
|
||||
}
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""!
|
||||
@brief Run the challenge named on the command line.
|
||||
|
||||
@param argv Arguments without the program name; `sys.argv` if omitted.
|
||||
@return The exit status: 0 on success.
|
||||
"""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
window = create_window()
|
||||
tim = create_pen()
|
||||
except TurtleUnavailableError as error:
|
||||
parser.exit(1, f"{parser.prog}: error: {error}\n")
|
||||
build_challenges(tim)[args.challenge]()
|
||||
window.exitonclick()
|
||||
return 0
|
||||
@@ -31,3 +31,15 @@ DASH_LENGTH = 10
|
||||
|
||||
## Challenge 2: length of the gap after each dash, in turtle units.
|
||||
GAP_LENGTH = 10
|
||||
|
||||
## Command line name of challenge 1.
|
||||
CHALLENGE_SQUARE = "square"
|
||||
|
||||
## Command line name of challenge 2.
|
||||
CHALLENGE_DASHED_LINE = "dashed-line"
|
||||
|
||||
## Names of the challenges on the command line, in the order of the course.
|
||||
CHALLENGE_NAMES: tuple[str, ...] = (
|
||||
CHALLENGE_SQUARE,
|
||||
CHALLENGE_DASHED_LINE,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import pytest
|
||||
|
||||
from tests.fakes import FakePen, FakeWindow
|
||||
from turtle_challenges import cli
|
||||
from turtle_challenges.constants import (
|
||||
CHALLENGE_NAMES,
|
||||
DASH_COUNT,
|
||||
SQUARE_SIDES,
|
||||
)
|
||||
from turtle_challenges.pen import Pen, Window
|
||||
from turtle_challenges.window import TurtleUnavailableError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_pen_and_window(monkeypatch: pytest.MonkeyPatch) -> tuple[FakePen, FakeWindow]:
|
||||
"""Replace the turtle window and pen of the command line with fakes."""
|
||||
pen = FakePen()
|
||||
window = FakeWindow()
|
||||
|
||||
def create_fake_window() -> Window:
|
||||
return window
|
||||
|
||||
def create_fake_pen() -> Pen:
|
||||
return pen
|
||||
|
||||
monkeypatch.setattr(cli, "create_window", create_fake_window)
|
||||
monkeypatch.setattr(cli, "create_pen", create_fake_pen)
|
||||
return pen, window
|
||||
|
||||
|
||||
def test_every_challenge_name_has_a_challenge() -> None:
|
||||
challenges = cli.build_challenges(FakePen())
|
||||
|
||||
assert set(challenges) == set(CHALLENGE_NAMES)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", CHALLENGE_NAMES)
|
||||
def test_parser_accepts_every_challenge_name(name: str) -> None:
|
||||
args = cli.build_parser().parse_args([name])
|
||||
|
||||
assert args.challenge == name
|
||||
|
||||
|
||||
def test_parser_rejects_an_unknown_challenge(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
with pytest.raises(SystemExit) as exit_info:
|
||||
cli.build_parser().parse_args(["triangle"])
|
||||
|
||||
assert exit_info.value.code == 2
|
||||
assert "invalid choice" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_help_lists_every_challenge(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
with pytest.raises(SystemExit) as exit_info:
|
||||
cli.build_parser().parse_args(["--help"])
|
||||
|
||||
assert exit_info.value.code == 0
|
||||
output = capsys.readouterr().out
|
||||
assert all(name in output for name in CHALLENGE_NAMES)
|
||||
|
||||
|
||||
def test_main_runs_the_square_and_waits_for_a_click(
|
||||
fake_pen_and_window: tuple[FakePen, FakeWindow],
|
||||
) -> None:
|
||||
pen, window = fake_pen_and_window
|
||||
|
||||
status = cli.main(["square"])
|
||||
|
||||
assert status == 0
|
||||
assert len(pen.args_of("forward")) == SQUARE_SIDES
|
||||
assert window.waited_for_click
|
||||
|
||||
|
||||
def test_main_runs_the_dashed_line(
|
||||
fake_pen_and_window: tuple[FakePen, FakeWindow],
|
||||
) -> None:
|
||||
pen, _ = fake_pen_and_window
|
||||
|
||||
cli.main(["dashed-line"])
|
||||
|
||||
assert len(pen.args_of("forward")) == 2 * DASH_COUNT
|
||||
|
||||
|
||||
def test_main_reports_a_missing_turtle_and_exits_with_status_1(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
def fail() -> Window:
|
||||
raise TurtleUnavailableError("no Tk here")
|
||||
|
||||
monkeypatch.setattr(cli, "create_window", fail)
|
||||
|
||||
with pytest.raises(SystemExit) as exit_info:
|
||||
cli.main(["square"])
|
||||
|
||||
assert exit_info.value.code == 1
|
||||
assert "no Tk here" in capsys.readouterr().err
|
||||
@@ -0,0 +1,5 @@
|
||||
from turtle_challenges import constants
|
||||
|
||||
|
||||
def test_challenge_names_are_unique() -> None:
|
||||
assert len(set(constants.CHALLENGE_NAMES)) == len(constants.CHALLENGE_NAMES)
|
||||
Reference in New Issue
Block a user