From b3153b9dc1996758fea03a222aa56c75ba19a603 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 7 Oct 2026 23:58:56 +0800 Subject: [PATCH] Add the command line entry point python -m turtle_challenges 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 --- README.md | 39 ++++++++++-- pyproject.toml | 3 + src/turtle_challenges/__main__.py | 9 +++ src/turtle_challenges/cli.py | 74 +++++++++++++++++++++++ src/turtle_challenges/constants.py | 12 ++++ tests/test_cli.py | 97 ++++++++++++++++++++++++++++++ tests/test_constants.py | 5 ++ 7 files changed, 235 insertions(+), 4 deletions(-) create mode 100644 src/turtle_challenges/__main__.py create mode 100644 src/turtle_challenges/cli.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_constants.py diff --git a/README.md b/README.md index 2f221ad..4893089 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index da07083..3b718ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,9 @@ dev = [ "ruff>=0.8", ] +[project.scripts] +turtle-challenges = "turtle_challenges.cli:main" + [tool.setuptools.packages.find] where = ["src"] diff --git a/src/turtle_challenges/__main__.py b/src/turtle_challenges/__main__.py new file mode 100644 index 0000000..58e33ea --- /dev/null +++ b/src/turtle_challenges/__main__.py @@ -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()) diff --git a/src/turtle_challenges/cli.py b/src/turtle_challenges/cli.py new file mode 100644 index 0000000..69bbc1e --- /dev/null +++ b/src/turtle_challenges/cli.py @@ -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 diff --git a/src/turtle_challenges/constants.py b/src/turtle_challenges/constants.py index 84f1140..0c5ca52 100644 --- a/src/turtle_challenges/constants.py +++ b/src/turtle_challenges/constants.py @@ -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, +) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..7077631 --- /dev/null +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_constants.py b/tests/test_constants.py new file mode 100644 index 0000000..ac3dd94 --- /dev/null +++ b/tests/test_constants.py @@ -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)