From 2047f7025c9eeb847ecc16dcc32c33e16fa60934 Mon Sep 17 00:00:00 2001 From: eeshsaxena Date: Fri, 14 Aug 2026 21:17:37 +0530 Subject: [PATCH] Do not pop the root node in end_variation read_game recovers from an illegal move by entering skip mode, and a later unmatched ')' closes that skip by calling end_variation(). When the error happened on the mainline there was no matching begin_variation(), so the unconditional variation_stack.pop() removed the root game node, leaving the stack empty and making the next visit_move() raise IndexError. Keep the root on the stack, mirroring the assert in begin_variation(). --- chess/pgn.py | 7 ++++++- test.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/chess/pgn.py b/chess/pgn.py index 3d4d7e5ff..5096022ef 100644 --- a/chess/pgn.py +++ b/chess/pgn.py @@ -1229,7 +1229,12 @@ def begin_variation(self) -> None: @override def end_variation(self) -> None: - self.variation_stack.pop() + # Keep the root game node on the stack. A malformed PGN (e.g. an error + # recovery that skips to an unmatched ")") can call end_variation more + # often than begin_variation; popping the root here would empty the + # stack and make the next visit_move raise IndexError. + if len(self.variation_stack) > 1: + self.variation_stack.pop() @override def visit_result(self, result: str) -> None: diff --git a/test.py b/test.py index ac9066385..220a83ae6 100755 --- a/test.py +++ b/test.py @@ -2358,6 +2358,17 @@ def test_variation_stack(self): self.assertEqual(game[0].san(), "c4") self.assertEqual(len(game.errors), 0) + # Survive a closing bracket reached through error recovery. The illegal + # move sends the parser into skip mode, and the unmatched ")" used to + # pop the root off the variation stack, so the following move raised an + # IndexError instead of being parsed. + pgn = io.StringIO("1. e4 Nf3 ) e5 *") + logging.disable(logging.ERROR) + game = chess.pgn.read_game(pgn) + logging.disable(logging.NOTSET) + self.assertEqual([node.san() for node in game.mainline()], ["e4", "e5"]) + self.assertEqual(len(game.errors), 1) + def test_game_starting_comment(self): pgn = io.StringIO("{ Game starting comment } 1. d3") game = chess.pgn.read_game(pgn)