From 75c3faf49d0d58e2fc98f3f145464969643d9f9e Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Thu, 16 Jul 2026 22:59:16 +0200 Subject: [PATCH 1/2] pgn: escape and unescape header values --- chess/pgn.py | 8 +++++--- test.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/chess/pgn.py b/chess/pgn.py index 3d4d7e5f..cbc24303 100644 --- a/chess/pgn.py +++ b/chess/pgn.py @@ -1414,7 +1414,8 @@ def begin_headers(self) -> None: def visit_header(self, tagname: str, tagvalue: str) -> None: if self.headers: self.found_headers = True - self.write_line(f"[{tagname} \"{tagvalue}\"]") + escaped_tagvalue = tagvalue.replace("\\", "\\\\").replace('"', '\\"') + self.write_line(f"[{tagname} \"{escaped_tagvalue}\"]") def end_headers(self) -> None: if self.found_headers: @@ -1645,9 +1646,10 @@ def read_game(handle: TextIO, *, Visitor: Any = GameBuilder) -> Any: if not skipping_game: tag_match = TAG_REGEX.match(stripped) if tag_match: - visitor.visit_header(tag_match.group(1), tag_match.group(2)) + tagvalue = re.sub(r'\\(["\\])', r'\1', tag_match.group(2)) + visitor.visit_header(tag_match.group(1), tagvalue) if unmanaged_headers is not None: - unmanaged_headers[tag_match.group(1)] = tag_match.group(2) + unmanaged_headers[tag_match.group(1)] = tagvalue else: # Ignore invalid or malformed headers. line = handle.readline() diff --git a/test.py b/test.py index ac906638..5b30cd38 100755 --- a/test.py +++ b/test.py @@ -2094,6 +2094,30 @@ def test_minimum_weight(self): class PgnTestCase(unittest.TestCase): + HEADER_VALUE_CASES = [ + ("Plain", "Plain"), + ('The "Open"', r'The \"Open\"'), + (r"C:\chess", r"C:\\chess"), + ('quote " and slash \\', r'quote \" and slash \\'), + ('ends in \\', r'ends in \\'), + ("right ] bracket", "right ] bracket"), + ] + + def test_exporter_escapes_headers(self): + for value, encoded in self.HEADER_VALUE_CASES: + with self.subTest(value=value): + game = chess.pgn.Game.without_tag_roster() + game.headers["Event"] = value + + exported = game.accept(chess.pgn.StringExporter(columns=None)) + expected = f'[Event "{encoded}"]\n\n*' + self.assertEqual(exported, expected) + self.assertEqual(chess.pgn.read_headers(io.StringIO(exported))["Event"], value) + + handle = io.StringIO() + game.accept(chess.pgn.FileExporter(handle, columns=None)) + self.assertEqual(handle.getvalue(), expected + "\n\n") + def test_exporter(self): game = chess.pgn.Game() game.comments = ["Test game:"] @@ -2251,6 +2275,16 @@ def test_read_game_with_leading_whitespace_before_header(self): self.assertEqual(game.next().move, chess.Move.from_uci("d2d4")) self.assertEqual(game.errors, []) + def test_reader_unescapes_headers(self): + for expected, encoded in self.HEADER_VALUE_CASES: + with self.subTest(encoded=encoded): + text = f'[Event "{encoded}"]\n\n*\n' + game = chess.pgn.read_game(io.StringIO(text)) + headers = chess.pgn.read_headers(io.StringIO(text)) + + self.assertEqual(game.headers["Event"], expected) + self.assertEqual(headers["Event"], expected) + def test_read_game_with_multicomment_move(self): pgn = io.StringIO("1. e4 {A common opening} 1... e5 {A common response} {An uncommon comment}") game = chess.pgn.read_game(pgn) From e485fc1838906a86b76061dcac8e1dc902929a8d Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Fri, 17 Jul 2026 07:32:19 +0200 Subject: [PATCH 2/2] ci: apt-get update before installs to fix stale-index 404s --- .github/workflows/setup-ubuntu-latest.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/setup-ubuntu-latest.sh b/.github/workflows/setup-ubuntu-latest.sh index 10b8d111..c45fe02e 100755 --- a/.github/workflows/setup-ubuntu-latest.sh +++ b/.github/workflows/setup-ubuntu-latest.sh @@ -1,5 +1,9 @@ #!/bin/sh -e +# Refresh the package index: the runner image's baked-in lists can reference +# package versions the mirrors no longer carry (404). +sudo apt-get update + # Stockfish sudo apt-get install -y stockfish