Skip to content

Commit c19acb9

Browse files
committed
Validate game headers (fixes niklasf#258)
1 parent 6f4bf80 commit c19acb9

3 files changed

Lines changed: 75 additions & 21 deletions

File tree

chess/pgn.py

Lines changed: 73 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@
8080

8181
TAG_REGEX = re.compile(r"^\[([A-Za-z0-9_]+)\s+\"(.*)\"\]\s*$")
8282

83+
TAG_NAME_REGEX = re.compile(r"^[A-Za-z9-9_]+\Z")
84+
8385
MOVETEXT_REGEX = re.compile(r"""
8486
(
8587
[NBKRQ]?[a-h]?[1-8]?[\-x]?[a-h][1-8](?:=?[nbrqkNBRQK])?
@@ -99,6 +101,9 @@
99101
""", re.DOTALL | re.VERBOSE)
100102

101103

104+
TAG_ROSTER = ["Event", "Site", "Date", "Round", "White", "Black", "Result"]
105+
106+
102107
class GameNode(object):
103108

104109
def __init__(self):
@@ -389,18 +394,9 @@ class Game(GameNode):
389394
:class:`~chess.pgn.GameNode`.
390395
"""
391396

392-
def __init__(self):
397+
def __init__(self, headers=None):
393398
super(Game, self).__init__()
394-
395-
self.headers = collections.OrderedDict()
396-
self.headers["Event"] = "?"
397-
self.headers["Site"] = "?"
398-
self.headers["Date"] = "????.??.??"
399-
self.headers["Round"] = "?"
400-
self.headers["White"] = "?"
401-
self.headers["Black"] = "?"
402-
self.headers["Result"] = "*"
403-
399+
self.headers = Headers(headers)
404400
self.errors = []
405401

406402
def board(self, _cache=False):
@@ -504,11 +500,70 @@ def from_board(cls, board):
504500
@classmethod
505501
def without_tag_roster(cls):
506502
"""Creates an empty game without the default 7 tag roster."""
507-
game = cls.__new__(cls)
508-
super(Game, game).__init__()
509-
game.headers = collections.OrderedDict()
510-
game.errors = []
511-
return game
503+
return cls(headers=Headers({}))
504+
505+
506+
class Headers(collections.MutableMapping):
507+
def __init__(self, data=None, **kwargs):
508+
self._tag_roster = {}
509+
self._others = {}
510+
511+
if data is None:
512+
data = {
513+
"Event": "?",
514+
"Site": "?",
515+
"Date": "????.??.??",
516+
"Round": "?",
517+
"White": "?",
518+
"Black": "?",
519+
"Result": "*"
520+
}
521+
522+
self.update(data, **kwargs)
523+
524+
def __setitem__(self, key, value):
525+
if key in TAG_ROSTER:
526+
self._tag_roster[key] = value
527+
elif not TAG_NAME_REGEX.match(key):
528+
raise ValueError("non-alphanumeric pgn header tag: {0}".format(repr(key)))
529+
elif "\n" in value or "\r" in value:
530+
raise ValueError("line break in pgn header {0}: {1}".format(key, repr(value)))
531+
else:
532+
self._others[key] = value
533+
534+
def __getitem__(self, key):
535+
if key in TAG_ROSTER:
536+
return self._tag_roster[key]
537+
else:
538+
return self._others[key]
539+
540+
def __delitem__(self, key):
541+
if key in TAG_ROSTER:
542+
del self._tag_roster[key]
543+
else:
544+
del self._others[key]
545+
546+
def __iter__(self):
547+
for key in TAG_ROSTER:
548+
if key in self._tag_roster:
549+
yield key
550+
551+
for key in sorted(self._others):
552+
yield key
553+
554+
def __len__(self):
555+
return len(self._tag_roster) + len(self._others)
556+
557+
def copy(self):
558+
return type(self)(self)
559+
560+
def __copy__(self):
561+
return self.copy()
562+
563+
def __repr__(self):
564+
return "{0}({1})".format(
565+
type(self).__name__,
566+
", ".join("{0}={1}".format(key, repr(value)) for key, value in self.items()))
512567

513568

514569
class BaseVisitor(object):
@@ -1003,7 +1058,7 @@ def scan_headers(handle):
10031058
Scan a PGN file opened in text mode for game offsets and headers.
10041059
10051060
Yields a tuple for each game. The first element is the offset and the
1006-
second element is an ordered dictionary of game headers.
1061+
second element is a mapping of game headers.
10071062
10081063
Since actually parsing many games from a big file is relatively expensive,
10091064
this is a better way to look only for specific games and then seek and
@@ -1057,7 +1112,7 @@ def scan_headers(handle):
10571112
tag_match = TAG_REGEX.match(line)
10581113
if tag_match:
10591114
if game_pos is None:
1060-
game_headers = Game().headers
1115+
game_headers = Headers()
10611116
game_pos = last_pos
10621117

10631118
game_headers[tag_match.group(1)] = tag_match.group(2)

docs/pgn.rst

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,7 @@ holds general information, such as game headers.
5252

5353
.. py:attribute:: headers
5454
55-
A :class:`collections.OrderedDict` of game headers. By default, the
56-
following 7 headers are provided:
55+
A mapping of headers. By default, the following 7 headers are provided:
5756

5857
>>> import chess.pgn
5958
>>>

test.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2071,8 +2071,8 @@ def test_black_to_move(self):
20712071
[White "?"]
20722072
[Black "?"]
20732073
[Result "*"]
2074-
[SetUp "1"]
20752074
[FEN "8/8/4k3/8/4P3/4K3/8/8 b - - 0 17"]
2075+
[SetUp "1"]
20762076
20772077
17... Kd6 18. Kd4 Ke6 *""")
20782078

0 commit comments

Comments
 (0)