From bc946310084d91396793ec6ab84c769e6fe1cc5d Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 7 Dec 2018 09:44:18 +0100 Subject: [PATCH 0001/1451] Rewrite EPD operations parser (fixes #340) --- chess/__init__.py | 155 ++++++++++++++++++++++++---------------------- test.py | 12 ++++ 2 files changed, 94 insertions(+), 73 deletions(-) diff --git a/chess/__init__.py b/chess/__init__.py index ce4e5e8a4..9c7581abb 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -2241,7 +2241,7 @@ def _epd_operations(self, operations): # Append as escaped string. epd.append(" \"") - epd.append(str(operand).replace("\r", "").replace("\n", " ").replace("\\", "\\\\").replace(";", "\\s")) + epd.append(str(operand).replace("\r", "").replace("\n", " ").replace("\\", "\\\\").replace("\"", "\\\"")) epd.append("\";") return "".join(epd) @@ -2286,90 +2286,99 @@ def epd(self, shredder=False, en_passant="legal", promoted=None, **operations): def _parse_epd_ops(self, operation_part, make_board): operations = {} - - if not operation_part: - return operations - - operation_part += ";" - + state = "opcode" opcode = "" operand = "" - in_operand = False - in_quotes = False - escape = False - position = None - for c in operation_part: - if not in_operand: - if c == ";": - operations[opcode] = None - opcode = "" - elif c == " ": + for ch in itertools.chain(operation_part, [None]): + if state == "opcode": + if ch == " ": + if opcode: + state = "after_opcode" + elif ch in [";", None]: if opcode: - in_operand = True + operations[opcode] = None + opcode = "" else: - opcode += c - else: - if c == "\"": - if not operand and not in_quotes: - in_quotes = True - elif escape: - operand += c - elif c == "\\": - if escape: - operand += c - else: - escape = True - elif c == "s": - if escape: - operand += ";" - else: - operand += c - elif c == ";": - if escape: - operand += "\\" - - if in_quotes: - # A string operand. - operations[opcode] = operand + opcode += ch + elif state == "after_opcode": + if ch == " ": + pass + elif ch in "+-.0123456789": + operand = ch + state = "numeric" + elif ch == "\"": + state = "string" + elif ch in [";", None]: + if opcode: + operations[opcode] = None + opcode = "" + state = "opcode" + else: + operand = ch + state = "san" + elif state == "numeric": + if ch in [";", None]: + operations[opcode] = float(operand) + try: + operations[opcode] = int(operand) + except: + pass + opcode = "" + operand = "" + state = "opcode" + else: + operand += ch + elif state == "string": + if ch in ["\"", None]: + operations[opcode] = operand + opcode = "" + operand = "" + state = "opcode" + elif ch == "\\": + state = "string_escape" + else: + operand += ch + elif state == "string_escape": + if ch is None: + operations[opcode] = operand + opcode = "" + operand = "" + state = "opcode" + else: + operand += ch + state = "string" + elif state == "san": + if ch in [";", None]: + if position is None: + position = make_board() + + if opcode == "pv": + # A variation. + operations[opcode] = [] + for token in operand.split(): + move = position.parse_san(token) + operations[opcode].append(move) + position.push(move) + + # Reset the position. + while position.move_stack: + position.pop() + elif opcode in ["bm", "am"]: + # A set of moves. + operations[opcode] = [position.parse_san(token) for token in operand.split()] else: - try: - # An integer. - operations[opcode] = int(operand) - except ValueError: - try: - # A float. - operations[opcode] = float(operand) - except ValueError: - if position is None: - position = make_board() - if opcode == "pv": - # A variation. - operations[opcode] = [] - for token in operand.split(): - move = position.parse_san(token) - operations[opcode].append(move) - position.push(move) - - # Reset the position. - while position.move_stack: - position.pop() - elif opcode in ("bm", "am"): - # A set of moves. - operations[opcode] = [position.parse_san(token) for token in operand.split()] - else: - # A single move. - operations[opcode] = position.parse_san(operand) + # A single move. + operations[opcode] = position.parse_san(operand) opcode = "" operand = "" - in_operand = False - in_quotes = False - escape = False + state = "opcode" else: - operand += c + operand += ch + assert state == "opcode" return operations def set_epd(self, epd): diff --git a/test.py b/test.py index 750c152e6..7284c99a3 100755 --- a/test.py +++ b/test.py @@ -928,6 +928,18 @@ def test_epd(self): self.assertEqual(operations["pv"][1], chess.Move.from_uci("g8f8")) self.assertEqual(operations["pv"][2], chess.Move.from_uci("h7f7")) + # Test EPD with semicolon. + board = chess.Board() + operations = board.set_epd("r2qk2r/ppp1b1pp/2n1p3/3pP1n1/3P2b1/2PB1NN1/PP4PP/R1BQK2R w KQkq - bm Nxg5; c0 \"ERET.095; Queen sacrifice\";") + self.assertEqual(operations["bm"], [chess.Move.from_uci("f3g5")]) + self.assertEqual(operations["c0"], "ERET.095; Queen sacrifice") + + # Test an EPD with string escaping. + board = chess.Board() + operations = board.set_epd(r"""4k3/8/8/8/8/8/8/4K3 w - - a "foo\"bar";; ; b "foo\\\\";""") + self.assertEqual(operations["a"], "foo\"bar") + self.assertEqual(operations["b"], "foo\\\\") + def test_null_moves(self): self.assertEqual(str(chess.Move.null()), "0000") self.assertEqual(chess.Move.null().uci(), "0000") From 4d8eb17d51de98cc3d115f7e8665c6758a58a2ce Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 7 Dec 2018 17:29:50 +0100 Subject: [PATCH 0002/1451] Prepare 0.23.11 --- CHANGELOG.rst | 9 +++++++++ chess/__init__.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4cca5a3f0..4271b39d2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,15 @@ Changelog for python-chess ========================== +New in v0.23.11 +--------------- + +Bugfixes: + +* `chess.pgn.GameNode.uci()` was always raising an exception. +* Fix `chess.Board.set_epd()` and `chess.Board.from_epd()` with semicolon + in string operand. Thanks @jdart1. + New in v0.23.10 --------------- diff --git a/chess/__init__.py b/chess/__init__.py index 9c7581abb..15f601891 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -26,7 +26,7 @@ __email__ = "niklas.fiekas@backscattering.de" -__version__ = "0.23.10" +__version__ = "0.23.11" import collections import copy From 51a5150840c33dd38fd601dac89ef49f94515b45 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 7 Dec 2018 17:41:04 +0100 Subject: [PATCH 0003/1451] Prepare 0.24.1 --- CHANGELOG.rst | 5 +++-- chess/__init__.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cdd655101..d1a8daae6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,14 +1,15 @@ Changelog for python-chess ========================== -New in v0.23.11 ---------------- +New in v0.24.1, v0.23.11 +------------------------ Bugfixes: * Fix `chess.Board.set_epd()` and `chess.Board.from_epd()` with semicolon in string operand. Thanks @jdart1. * `chess.pgn.GameNode.uci()` was always raising an exception. + Also included in v0.24.0. New in v0.24.0 -------------- diff --git a/chess/__init__.py b/chess/__init__.py index bc611d79f..64f3c95d7 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -26,7 +26,7 @@ __email__ = "niklas.fiekas@backscattering.de" -__version__ = "0.24.0" +__version__ = "0.24.1" import collections import collections.abc From 2ea22cafb9c5e2e66751d435d2c65969610764f6 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 8 Dec 2018 12:00:19 +0100 Subject: [PATCH 0004/1451] Fix skipping tricky combinations of PGN comments --- chess/pgn.py | 20 +++++++++++--- test.py | 75 +++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 70 insertions(+), 25 deletions(-) diff --git a/chess/pgn.py b/chess/pgn.py index 00d65e1b7..47bb232b5 100644 --- a/chess/pgn.py +++ b/chess/pgn.py @@ -102,6 +102,7 @@ |([\?!]{1,2}) """, re.DOTALL | re.VERBOSE) +SKIP_MOVETEXT_REGEX = re.compile(r""";|\{|\}""") TAG_ROSTER = ["Event", "Site", "Date", "Round", "White", "Black", "Result"] @@ -1084,10 +1085,21 @@ def read_game(handle, *, Visitor=GameModelCreator): in_comment = False while line: - if not in_comment and line.isspace(): - break - elif (not in_comment and "{" in line) or (in_comment and "}" in line): - in_comment = line.rfind("{") > line.rfind("}") + if not in_comment: + if line.isspace(): + break + elif line.startswith("%"): + line = handle.readline() + continue + + for match in SKIP_MOVETEXT_REGEX.finditer(line): + token = match.group(0) + if token == "{": + in_comment = True + elif not in_comment and token == ";": + break + elif token == "}": + in_comment = False line = handle.readline() diff --git a/test.py b/test.py index b7e3b58d6..012162c7c 100755 --- a/test.py +++ b/test.py @@ -27,7 +27,7 @@ import textwrap import threading import unittest -from io import StringIO +import io import chess import chess.gaviota @@ -1805,7 +1805,7 @@ def test_exporter(self): self.assertEqual(str(exporter), pgn) # Test file exporter. - virtual_file = StringIO() + virtual_file = io.StringIO() exporter = chess.pgn.FileExporter(virtual_file) game.accept(exporter) self.assertEqual(virtual_file.getvalue(), pgn + "\n\n") @@ -1892,7 +1892,7 @@ def test_read_game(self): self.assertEqual(sixth_game.headers["Result"], "1-0") def test_comment_at_eol(self): - pgn = StringIO(textwrap.dedent(r"""\ + pgn = io.StringIO(textwrap.dedent(r"""\ 1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. c3 Nf6 5. d3 d6 6. Nbd2 a6 $6 (6... Bb6 $5 { /\ Ne7, c6}) *""")) @@ -1910,7 +1910,7 @@ def test_comment_at_eol(self): def test_promotion_without_equals(self): # Example game from https://github.com/rozim/ChessData as originally # reported. - pgn = StringIO(textwrap.dedent("""\ + pgn = io.StringIO(textwrap.dedent("""\ [Event "It (open)"] [Site "Aschach (Austria)"] [Date "2011.12.26"] @@ -1945,7 +1945,7 @@ def test_promotion_without_equals(self): self.assertEqual(last_node.move.uci(), "b2b1q") def test_chess960_without_fen(self): - pgn = StringIO(textwrap.dedent("""\ + pgn = io.StringIO(textwrap.dedent("""\ [Variant "Chess960"] 1. e4 * @@ -1956,7 +1956,7 @@ def test_chess960_without_fen(self): def test_variation_stack(self): # Survive superfluous closing brackets. - pgn = StringIO("1. e4 (1. d4))) !? *") + pgn = io.StringIO("1. e4 (1. d4))) !? *") logging.disable(logging.ERROR) game = chess.pgn.read_game(pgn) logging.disable(logging.NOTSET) @@ -1967,7 +1967,7 @@ def test_variation_stack(self): self.assertEqual(len(game.errors), 2) # Survive superfluous opening brackets. - pgn = StringIO("((( 1. c4 *") + pgn = io.StringIO("((( 1. c4 *") logging.disable(logging.ERROR) game = chess.pgn.read_game(pgn) logging.disable(logging.NOTSET) @@ -1975,17 +1975,17 @@ def test_variation_stack(self): self.assertEqual(len(game.errors), 3) def test_game_starting_comment(self): - pgn = StringIO("{ Game starting comment } 1. d3") + pgn = io.StringIO("{ Game starting comment } 1. d3") game = chess.pgn.read_game(pgn) self.assertEqual(game.comment, "Game starting comment") self.assertEqual(game[0].san(), "d3") - pgn = StringIO("{ Empty game, but has a comment }") + pgn = io.StringIO("{ Empty game, but has a comment }") game = chess.pgn.read_game(pgn) self.assertEqual(game.comment, "Empty game, but has a comment") def test_game_starting_variation(self): - pgn = StringIO(textwrap.dedent("""\ + pgn = io.StringIO(textwrap.dedent("""\ {Start of game} 1. e4 ({Start of variation} 1. d4) 1... e5 """)) @@ -2003,7 +2003,7 @@ def test_game_starting_variation(self): self.assertEqual(node.starting_comment, "Start of variation") def test_annotation_symbols(self): - pgn = StringIO("1. b4?! g6 2. Bb2 Nc6? 3. Bxh8!!") + pgn = io.StringIO("1. b4?! g6 2. Bb2 Nc6? 3. Bxh8!!") game = chess.pgn.read_game(pgn) node = game.variation(chess.Move.from_uci("b2b4")) @@ -2103,6 +2103,39 @@ def test_skip_game(self): self.assertEqual(sixth_game.headers["Event"], "IBM Man-Machine, New York USA") self.assertEqual(sixth_game.headers["Site"], "06") + def test_tricky_skip_game(self): + raw_pgn = textwrap.dedent(""" + 1. a3 ; { ; } + + 1. b3 { ; + % { + 1... g6 ; { + + 1. c3 { } + % { + 1... f6 ; { } {{{ + + 1. d3""") + pgn = io.StringIO(raw_pgn) + + offsets = [] + while True: + offset = pgn.tell() + if chess.pgn.skip_game(pgn): + offsets.append(offset) + else: + break + + self.assertEqual(len(offsets), 3) + + pgn.seek(offsets[0]) + self.assertEqual(chess.pgn.read_game(pgn).variations[0].move, chess.Move.from_uci("a2a3")) + pgn.seek(offsets[1]) + self.assertEqual(chess.pgn.read_game(pgn).variations[0].move, chess.Move.from_uci("b2b3")) + pgn.seek(offsets[2]) + self.assertEqual(chess.pgn.read_game(pgn).variations[0].move, chess.Move.from_uci("d2d3")) + self.assertEqual(chess.pgn.read_game(pgn), None) + def test_read_headers(self): with open("data/pgn/kasparov-deep-blue-1997.pgn") as pgn: offsets = [] @@ -2144,12 +2177,12 @@ def test_black_to_move(self): self.assertEqual(str(game), expected) def test_result_termination_marker(self): - pgn = StringIO("1. d4 1-0") + pgn = io.StringIO("1. d4 1-0") game = chess.pgn.read_game(pgn) self.assertEqual(game.headers["Result"], "1-0") def test_missing_setup_tag(self): - pgn = StringIO(textwrap.dedent("""\ + pgn = io.StringIO(textwrap.dedent("""\ [Event "Test position"] [Site "Black to move "] [Date "1997.10.26"] @@ -2190,7 +2223,7 @@ def test_game_from_board(self): self.assertEqual(game.headers["Result"], "1-0") def test_errors(self): - pgn = StringIO("1. e4 Qa1 e5 2. Qxf8") + pgn = io.StringIO("1. e4 Qa1 e5 2. Qxf8") logging.disable(logging.ERROR) game = chess.pgn.read_game(pgn) logging.disable(logging.NOTSET) @@ -2226,12 +2259,12 @@ def test_mainline(self): self.assertEqual(str(game.mainline_moves()), "1. d3 Nf6 2. e4") def test_lan(self): - pgn = StringIO("1. e2-e4") + pgn = io.StringIO("1. e2-e4") game = chess.pgn.read_game(pgn) self.assertEqual(game.end().move, chess.Move.from_uci("e2e4")) def test_variants(self): - pgn = StringIO(textwrap.dedent("""\ + pgn = io.StringIO(textwrap.dedent("""\ [Variant "Atomic"] [FEN "8/8/1b6/8/3Nk3/4K3/8/8 w - - 0 1"] @@ -2261,24 +2294,24 @@ def test_z0(self): self.assertEqual(board.fen(), "5rk1/2p1R2p/p5pb/2PPR3/8/2Q2B2/5P2/4K2q w - - 3 43") def test_wierd_header(self): - pgn = StringIO(r"""[Black "[=0040.34h5a4]"]""") + pgn = io.StringIO(r"""[Black "[=0040.34h5a4]"]""") game = chess.pgn.read_game(pgn) self.assertEqual(game.headers["Black"], "[=0040.34h5a4]") def test_semicolon_comment(self): - pgn = StringIO("1. e4 ; e5") + pgn = io.StringIO("1. e4 ; e5") game = chess.pgn.read_game(pgn) node = game.variations[0] self.assertEqual(node.move, chess.Move.from_uci("e2e4")) self.assertTrue(node.is_end()) def test_empty_game(self): - pgn = StringIO(" \n\n ") + pgn = io.StringIO(" \n\n ") game = chess.pgn.read_game(pgn) self.assertTrue(game is None) def test_no_movetext(self): - pgn = StringIO(textwrap.dedent(""" + pgn = io.StringIO(textwrap.dedent(""" [Event "A"] @@ -2293,7 +2326,7 @@ def test_no_movetext(self): self.assertTrue(chess.pgn.read_game(pgn) is None) def test_subgame(self): - pgn = StringIO("1. d4 d5 (1... Nf6 2. c4 (2. Nf3 g6 3. g3))") + pgn = io.StringIO("1. d4 d5 (1... Nf6 2. c4 (2. Nf3 g6 3. g3))") game = chess.pgn.read_game(pgn) node = game.variations[0].variations[1] subgame = node.accept_subgame(chess.pgn.GameModelCreator()) From bca09eef3a155483bd8775f256a2269449a85ca4 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 11 Dec 2018 00:27:52 +0100 Subject: [PATCH 0005/1451] Simplify headers in chess.pgn.read_game() --- chess/pgn.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/chess/pgn.py b/chess/pgn.py index 47bb232b5..aad4501b6 100644 --- a/chess/pgn.py +++ b/chess/pgn.py @@ -1037,21 +1037,25 @@ def read_game(handle, *, Visitor=GameModelCreator): skipping_game = False headers = None - # Skip leading empty lines and comments. + # Ignore leading empty lines and comments. line = handle.readline() while line.isspace() or line.startswith("%") or line.startswith(";"): line = handle.readline() # Parse game headers. while line: - # Skip comments. + # Ignore comments. if line.startswith("%") or line.startswith(";"): line = handle.readline() continue + # First token of the game. if not found_game: found_game = True skipping_game = visitor.begin_game() is SKIP + if not skipping_game: + visitor.begin_headers() + headers = Headers({}) if not line.startswith("["): break @@ -1059,10 +1063,6 @@ def read_game(handle, *, Visitor=GameModelCreator): if not skipping_game: tag_match = TAG_REGEX.match(line) if tag_match: - if headers is None: - headers = Headers({}) - visitor.begin_headers() - headers[tag_match.group(1)] = tag_match.group(2) visitor.visit_header(tag_match.group(1), tag_match.group(2)) else: @@ -1073,10 +1073,10 @@ def read_game(handle, *, Visitor=GameModelCreator): if not found_game: return None - if headers is not None: + if not skipping_game: skipping_game = visitor.end_headers() is SKIP - # Skip a single empty line after headers. + # Ignore single empty line after headers. if line.isspace(): line = handle.readline() @@ -1106,16 +1106,14 @@ def read_game(handle, *, Visitor=GameModelCreator): visitor.end_game() return visitor.result() - # Chess variant and initial position. - if headers is None: - headers = Headers({}) - + # Chess variant. try: VariantBoard = headers.variant() except ValueError as error: visitor.handle_error(error) VariantBoard = chess.Board + # Initial position. fen = headers.get("FEN", VariantBoard.starting_fen) try: board_stack = [VariantBoard(fen, chess960=headers.is_chess960())] @@ -1128,8 +1126,8 @@ def read_game(handle, *, Visitor=GameModelCreator): while line: read_next_line = True + # Ignore comments. if line.startswith("%") or line.startswith(";"): - # Ignore comments. line = handle.readline() continue From ccf407bd65064272cd8aa341d3cf2e456ff9d6c3 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 15 Dec 2018 00:12:00 +0100 Subject: [PATCH 0006/1451] Documentation improvements --- chess/__init__.py | 2 +- chess/gaviota.py | 11 +++++++++-- chess/pgn.py | 4 ++-- chess/syzygy.py | 11 +++++++++-- docs/core.rst | 4 ++-- docs/pgn.rst | 10 +++++----- 6 files changed, 28 insertions(+), 14 deletions(-) diff --git a/chess/__init__.py b/chess/__init__.py index 64f3c95d7..7feaf1338 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -1439,7 +1439,7 @@ def clear(self): """ Clears the board. - Resets move stacks and move counters. The side to move is white. There + Resets move stack and move counters. The side to move is white. There are no rooks or kings, so castling rights are removed. In order to be in a valid :func:`~chess.Board.status()` at least kings diff --git a/chess/gaviota.py b/chess/gaviota.py index 273c28cbb..c0dde3f3b 100644 --- a/chess/gaviota.py +++ b/chess/gaviota.py @@ -1530,7 +1530,10 @@ def __init__(self): self.block_age = 0 def add_directory(self, directory): - """Loads *.gtb.cp4* tables from a directory.""" + """ + Adds *.gtb.cp4* tables from a directory. The relevant files are lazily + opened when the tablebase is actually probed. + """ directory = os.path.abspath(directory) if not os.path.isdir(directory): raise IOError("not a directory: {}".format(repr(directory))) @@ -1564,6 +1567,8 @@ def probe_dtm(self, board): :exc:`chess.gaviota.MissingTableError`) if the probe fails. Use :func:`~chess.gaviota.PythonTablebase.get_dtm()` if you prefer to get ``None`` instead of an exception. + + Note that probing a corrupted table file is undefined behavior. """ # Can not probe positions with castling rights. if board.castling_rights: @@ -1644,6 +1649,8 @@ def probe_wdl(self, board): :exc:`chess.gaviota.MissingTableError`) if the probe fails. Use :func:`~chess.gaviota.PythonTablebase.get_wdl()` if you prefer to get ``None`` instead of an exception. + + Note that probing a corrupted table file is undefined behavior. """ dtm = self.probe_dtm(board) @@ -2094,7 +2101,7 @@ def open_tablebase(directory, *, libgtb=None, LibraryLoader=ctypes.cdll): The shared library has global state and caches, so only one instance can be open at a time. - Second pure Python probing code is tried. + Second, pure Python probing code is tried. """ try: if LibraryLoader: diff --git a/chess/pgn.py b/chess/pgn.py index aad4501b6..bdbd17041 100644 --- a/chess/pgn.py +++ b/chess/pgn.py @@ -1005,7 +1005,7 @@ def read_game(handle, *, Visitor=GameModelCreator): By using text mode, the parser does not need to handle encodings. It is the caller's responsibility to open the file with the correct encoding. - PGN files are ASCII or UTF-8 most of the time. So, the following should + PGN files are usually ASCII or UTF-8 encoded. So, the following should cover most relevant cases (ASCII, UTF-8, UTF-8 with BOM). >>> pgn = open("data/pgn/kasparov-deep-blue-1997.pgn", encoding="utf-8-sig") @@ -1018,7 +1018,7 @@ def read_game(handle, *, Visitor=GameModelCreator): >>> game = chess.pgn.read_game(pgn) The end of a game is determined by a completely blank line or the end of - the file. (Of course, blank lines in comments are possible.) + the file. (Of course, blank lines in comments are possible). According to the PGN standard, at least the usual 7 header tags are required for a valid game. This parser also handles games without any diff --git a/chess/syzygy.py b/chess/syzygy.py index 327ffec1b..b62022995 100644 --- a/chess/syzygy.py +++ b/chess/syzygy.py @@ -1483,11 +1483,14 @@ def _open_table(self, hashtable, Table, path): def add_directory(self, directory, *, load_wdl=True, load_dtz=True): """ - Loads tables from a directory. + Adds tables from a directory. By default all available tables with the correct file names (e.g. WDL files like ``KQvKN.rtbw`` and DTZ files like ``KRBvK.rtbz``) - are loaded. + are added. + + The relevant files are lazily opened when the tablebase is actually + probed. Returns the number of table files that were found. """ @@ -1651,6 +1654,8 @@ def probe_wdl(self, board): be found in the tablebase. Use :func:`~chess.syzygy.Tablebase.get_wdl()` if you prefer to get ``None`` instead of an exception. + + Note that probing corrupted table files is undefined behavior. """ # Positions with castling rights are not in the tablebase. if board.castling_rights: @@ -1846,6 +1851,8 @@ def probe_dtz(self, board): be found in the tablebase. Use :func:`~chess.syzygy.Tablebase.get_dtz()` if you prefer to get ``None`` instead of an exception. + + Note that probing corrupted table files is undefined behavior. """ v = self.probe_dtz_no_ep(board) diff --git a/docs/core.rst b/docs/core.rst index 97f9a5a5a..a43d102a3 100644 --- a/docs/core.rst +++ b/docs/core.rst @@ -12,7 +12,7 @@ Constants for the side to move or the color of a piece. .. py:data:: chess.BLACK :annotation: = False -You can get the opposite color using `not color`. +You can get the opposite *color* using ``not color``. Piece types ----------- @@ -126,7 +126,7 @@ Board >>> import chess >>> >>> board = chess.Board() - >>> bool(board.castling_rights & chess.BB_H1) # White can castle with the h1 rook + >>> bool(board.castling_rights & chess.BB_H1) # White can castle with the h1 rook True To add a specific square: diff --git a/docs/pgn.rst b/docs/pgn.rst index ce0e1a131..4000f67d1 100644 --- a/docs/pgn.rst +++ b/docs/pgn.rst @@ -62,26 +62,26 @@ holds general information, such as game headers. .. py:attribute:: errors - A list of illegal or ambiguous move errors encountered while parsing - the game. + A list of errors (such as illegal or ambiguous moves) encountered while + parsing the game. .. autoclass:: chess.pgn.GameNode :members: .. py:attribute:: parent - The parent node or `None` if this is the root node of the game. + The parent node or ``None`` if this is the root node of the game. .. py:attribute:: move - The move leading to this node or `None` if this is the root node of the + The move leading to this node or ``None`` if this is the root node of the game. .. py:attribute:: nags :annotation: = set() A set of NAGs as integers. NAGs always go behind a move, so the root - node of the game can have none. + node of the game will never have NAGs. .. py:attribute:: comment :annotation: = '' From b5daefa1793ed47806fe95967074eb74f2fc6d2a Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 15 Dec 2018 00:24:38 +0100 Subject: [PATCH 0007/1451] Board.stack -> Board._stack --- chess/__init__.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/chess/__init__.py b/chess/__init__.py index 7feaf1338..50ecfe67b 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -1412,7 +1412,7 @@ def __init__(self, fen=STARTING_FEN, *, chess960=False): self.legal_moves = LegalMoveGenerator(self) self.move_stack = [] - self.stack = [] + self._stack = [] if fen is None: self.clear() @@ -1460,13 +1460,13 @@ def clear_board(self): def clear_stack(self): """Clears the move stack.""" del self.move_stack[:] - del self.stack[:] + del self._stack[:] def root(self): """Returns a copy of the root position.""" - if self.stack: + if self._stack: board = type(self)(None, chess960=self.chess960) - self.stack[0].restore(board) + self._stack[0].restore(board) return board else: return self.copy(stack=False) @@ -1915,7 +1915,7 @@ def push(self, move): # Push move and remember board state. move = self._to_chess960(move) self.move_stack.append(self._from_chess960(self.chess960, move.from_square, move.to_square, move.promotion, move.drop)) - self.stack.append(_BoardState(self)) + self._stack.append(_BoardState(self)) # Reset en passant square. ep_square = self.ep_square @@ -2014,7 +2014,7 @@ def pop(self): :raises: :exc:`IndexError` if the stack is empty. """ move = self.move_stack.pop() - self.stack.pop().restore(self) + self._stack.pop().restore(self) return move def peek(self): @@ -2805,7 +2805,7 @@ def clean_castling_rights(self): Returns valid castling rights filtered from :data:`~chess.Board.castling_rights`. """ - if self.stack: + if self._stack: # Castling rights do not change in a game, so we can assume them to # be filtered already. return self.castling_rights @@ -3299,7 +3299,7 @@ def copy(self, *, stack=True): if stack: board.move_stack = copy.deepcopy(self.move_stack) - board.stack = copy.copy(self.stack) + board._stack = copy.copy(self._stack) return board From febbe7d03475ad06605c8b2465f2aa81609c8c72 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 15 Dec 2018 00:29:15 +0100 Subject: [PATCH 0008/1451] Avoid reference cycle for Board.legal_moves --- chess/__init__.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/chess/__init__.py b/chess/__init__.py index 50ecfe67b..5980c613f 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -1408,9 +1408,6 @@ def __init__(self, fen=STARTING_FEN, *, chess960=False): self.chess960 = chess960 - self.pseudo_legal_moves = PseudoLegalMoveGenerator(self) - self.legal_moves = LegalMoveGenerator(self) - self.move_stack = [] self._stack = [] @@ -1421,6 +1418,14 @@ def __init__(self, fen=STARTING_FEN, *, chess960=False): else: self.set_fen(fen) + @property + def pseudo_legal_moves(self): + return PseudoLegalMoveGenerator(self) + + @property + def legal_moves(self): + return LegalMoveGenerator(self) + def reset(self): """Restores the starting position.""" self.turn = WHITE From 1de43b505c0de46ab2a123da0c203d2e3f896fd4 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 15 Dec 2018 00:58:58 +0100 Subject: [PATCH 0009/1451] Optionally let visitor manage headers --- chess/pgn.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/chess/pgn.py b/chess/pgn.py index bdbd17041..d4a4108d4 100644 --- a/chess/pgn.py +++ b/chess/pgn.py @@ -748,6 +748,9 @@ def begin_game(self): self.starting_comment = "" self.in_variation = False + def begin_headers(self): + return self.game.headers + def visit_header(self, tagname, tagvalue): self.game.headers[tagname] = tagvalue @@ -803,6 +806,7 @@ class HeaderCreator(BaseVisitor): def begin_headers(self): self.headers = Headers({}) + return self.headers def visit_header(self, tagname, tagvalue): self.headers[tagname] = tagvalue @@ -1036,6 +1040,7 @@ def read_game(handle, *, Visitor=GameModelCreator): found_game = False skipping_game = False headers = None + managed_headers = None # Ignore leading empty lines and comments. line = handle.readline() @@ -1054,8 +1059,10 @@ def read_game(handle, *, Visitor=GameModelCreator): found_game = True skipping_game = visitor.begin_game() is SKIP if not skipping_game: - visitor.begin_headers() - headers = Headers({}) + managed_headers = visitor.begin_headers() + if not isinstance(managed_headers, Headers): + managed_headers = None + headers = Headers({}) if not line.startswith("["): break @@ -1063,8 +1070,9 @@ def read_game(handle, *, Visitor=GameModelCreator): if not skipping_game: tag_match = TAG_REGEX.match(line) if tag_match: - headers[tag_match.group(1)] = tag_match.group(2) visitor.visit_header(tag_match.group(1), tag_match.group(2)) + if headers is not None: + headers[tag_match.group(1)] = tag_match.group(2) else: break @@ -1107,6 +1115,7 @@ def read_game(handle, *, Visitor=GameModelCreator): return visitor.result() # Chess variant. + headers = managed_headers if headers is None else headers try: VariantBoard = headers.variant() except ValueError as error: From 125a82571cfdf114923d2e813efd7f4396448d5d Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 15 Dec 2018 01:09:40 +0100 Subject: [PATCH 0010/1451] Remove deprecated items --- chess/__init__.py | 5 ----- chess/gaviota.py | 13 ------------- chess/pgn.py | 6 ------ chess/syzygy.py | 7 ------- chess/variant.py | 4 ---- 5 files changed, 35 deletions(-) diff --git a/chess/__init__.py b/chess/__init__.py index 5980c613f..59744f226 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -3697,8 +3697,3 @@ def from_square(cls, square): True """ return cls(BB_SQUARES[square]) - - -# TODO: Deprecated -BB_VOID = 0 -bswap = flip_vertical diff --git a/chess/gaviota.py b/chess/gaviota.py index c0dde3f3b..8f6895805 100644 --- a/chess/gaviota.py +++ b/chess/gaviota.py @@ -1541,9 +1541,6 @@ def add_directory(self, directory): for tbfile in fnmatch.filter(os.listdir(directory), "*.gtb.cp4"): self.available_tables[os.path.basename(tbfile).replace(".gtb.cp4", "")] = os.path.join(directory, tbfile) - # TODO: Deprecated - open_directory = add_directory - def probe_dtm(self, board): """ Probes for depth to mate information. @@ -1945,9 +1942,6 @@ def add_directory(self, directory): self.paths.append(directory) self._tb_restart() - # TODO: Deprecated - open_directory = add_directory - def _tb_restart(self): self.c_paths = (ctypes.c_char_p * len(self.paths))() self.c_paths[:] = [path.encode("utf-8") for path in self.paths] @@ -2112,10 +2106,3 @@ def open_tablebase(directory, *, libgtb=None, LibraryLoader=ctypes.cdll): tables = PythonTablebase() tables.add_directory(directory) return tables - - -# TODO: Deprecated -open_tablebases_native = open_tablebase_native -open_tablebases = open_tablebase -PythonTablebases = PythonTablebase -NativeTablebases = NativeTablebase diff --git a/chess/pgn.py b/chess/pgn.py index d4a4108d4..1b0cf3d88 100644 --- a/chess/pgn.py +++ b/chess/pgn.py @@ -209,9 +209,6 @@ def is_mainline(self): return True - # TODO: Deprecated - is_main_line = is_mainline - def is_main_variation(self): """ Checks if this node is the first variation from the point of view of its @@ -295,9 +292,6 @@ def mainline_moves(self): """Returns an iterator over the main moves after this node.""" return Mainline(self, lambda node: node.move) - # TODO: Deprecated - main_line = mainline_moves - def add_line(self, moves, *, comment="", starting_comment="", nags=()): """ Creates a sequence of child nodes for the given list of moves. diff --git a/chess/syzygy.py b/chess/syzygy.py index b62022995..ca7102e37 100644 --- a/chess/syzygy.py +++ b/chess/syzygy.py @@ -1516,9 +1516,6 @@ def add_directory(self, directory, *, load_wdl=True, load_dtz=True): return num - # TODO: Deprecated - open_directory = add_directory - def probe_wdl_table(self, board): # Test for variant end. if board.is_variant_win(): @@ -1937,7 +1934,3 @@ def open_tablebase(directory, *, load_wdl=True, load_dtz=True, max_fds=128, Vari tables = Tablebase(max_fds=max_fds, VariantBoard=VariantBoard) tables.add_directory(directory, load_wdl=load_wdl, load_dtz=load_dtz) return tables - -# TODO: Deprecated -open_tablebases = open_tablebase -Tablebases = Tablebase diff --git a/chess/variant.py b/chess/variant.py index b6fa52c64..e6f448ce6 100644 --- a/chess/variant.py +++ b/chess/variant.py @@ -849,7 +849,3 @@ def find_variant(name): if any(alias.lower() == name.lower() for alias in variant.aliases): return variant raise ValueError("unsupported variant: {}".format(name)) - - -# TODO: Deprecated -BB_HILL = chess.BB_CENTER From 96d61b0e1e366711888b07b849618160b6a26162 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 15 Dec 2018 12:26:10 +0100 Subject: [PATCH 0011/1451] can_claim_threefold_repetition is expensive (#338) --- chess/__init__.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/chess/__init__.py b/chess/__init__.py index 59744f226..c18421edb 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -1690,10 +1690,11 @@ def is_game_over(self, *, claim_draw=False): :func:`fivefold repetition ` or a :func:`variant end condition `. - The game is not considered to be over by - :func:`threefold repetition ` - or the :func:`fifty-move rule `, - unless *claim_draw* is given. + The game is not considered to be over by the + :func:`fifty-move rule ` or + :func:`threefold repetition `, + unless *claim_draw* is given. Note that checking the latter can be + slow. """ # Seventyfive-move rule. if self.is_seventyfive_moves(): @@ -1838,6 +1839,8 @@ def can_claim_draw(self): """ Checks if the side to move can claim a draw by the fifty-move rule or by threefold repetition. + + Note that checking the latter can be slow. """ return self.can_claim_fifty_moves() or self.can_claim_threefold_repetition() @@ -1859,6 +1862,10 @@ def can_claim_threefold_repetition(self): Draw by threefold repetition can be claimed if the position on the board occured for the third time or if such a repetition is reached with one of the possible legal moves. + + Note that checking this can be slow: In the worst case + scenario every legal move has to be tested and the entire game has to + be replayed because there is no incremental transposition table. """ transposition_key = self._transposition_key() transpositions = collections.Counter() From 4feb6ed97a8f15d99be8a3324d0bda6728507d62 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 18 Dec 2018 17:44:38 +0100 Subject: [PATCH 0012/1451] chess.engine -> chess._engine --- chess/{engine.py => _engine.py} | 0 chess/uci.py | 22 +++++++++++----------- chess/xboard.py | 16 ++++++++-------- test.py | 4 ++-- 4 files changed, 21 insertions(+), 21 deletions(-) rename chess/{engine.py => _engine.py} (100%) diff --git a/chess/engine.py b/chess/_engine.py similarity index 100% rename from chess/engine.py rename to chess/_engine.py diff --git a/chess/uci.py b/chess/uci.py index 7808b6c95..1f1bb145a 100644 --- a/chess/uci.py +++ b/chess/uci.py @@ -18,17 +18,17 @@ import chess -from chess.engine import EngineTerminatedException -from chess.engine import EngineStateException -from chess.engine import MockProcess -from chess.engine import PopenProcess -from chess.engine import SpurProcess -from chess.engine import Option -from chess.engine import OptionMap -from chess.engine import LOGGER -from chess.engine import FUTURE_POLL_TIMEOUT -from chess.engine import _popen_engine -from chess.engine import _spur_spawn_engine +from chess._engine import EngineTerminatedException +from chess._engine import EngineStateException +from chess._engine import MockProcess +from chess._engine import PopenProcess +from chess._engine import SpurProcess +from chess._engine import Option +from chess._engine import OptionMap +from chess._engine import LOGGER +from chess._engine import FUTURE_POLL_TIMEOUT +from chess._engine import _popen_engine +from chess._engine import _spur_spawn_engine import collections import concurrent.futures diff --git a/chess/xboard.py b/chess/xboard.py index c654295ca..10ec54187 100644 --- a/chess/xboard.py +++ b/chess/xboard.py @@ -22,14 +22,14 @@ import shlex import threading -from chess.engine import EngineTerminatedException -from chess.engine import EngineStateException -from chess.engine import Option -from chess.engine import OptionMap -from chess.engine import LOGGER -from chess.engine import FUTURE_POLL_TIMEOUT -from chess.engine import _popen_engine -from chess.engine import _spur_spawn_engine +from chess._engine import EngineTerminatedException +from chess._engine import EngineStateException +from chess._engine import Option +from chess._engine import OptionMap +from chess._engine import LOGGER +from chess._engine import FUTURE_POLL_TIMEOUT +from chess._engine import _popen_engine +from chess._engine import _spur_spawn_engine import chess diff --git a/test.py b/test.py index 012162c7c..6639a3218 100755 --- a/test.py +++ b/test.py @@ -2573,7 +2573,7 @@ class XboardEngineTestCase(unittest.TestCase): def setUp(self): self.engine = chess.xboard.Engine() - self.mock = chess.engine.MockProcess(self.engine) + self.mock = chess._engine.MockProcess(self.engine) self.mock.expect("xboard") feature_list = [ "feature egt=syzygy,gaviota", @@ -2710,7 +2710,7 @@ def test_egtpath(self): self.mock.expect("egtpath syzygy /abc") try: self.engine.egtpath("non-existent", "random_path") - except chess.engine.EngineStateException: + except chess._engine.EngineStateException: pass self.engine.egtpath("syzygy", "/abc") self.mock.assert_done() From 9471ddce091941cb9177f9cf02a1b133b18cc43f Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 18 Dec 2018 17:57:50 +0100 Subject: [PATCH 0013/1451] Engine API scaffolding --- chess/engine.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 chess/engine.py diff --git a/chess/engine.py b/chess/engine.py new file mode 100644 index 000000000..d0e2fa36d --- /dev/null +++ b/chess/engine.py @@ -0,0 +1,23 @@ +import asyncio + + +class UciProtocol(asyncio.SubprocessProtocol): + pass + + +async def popen_uci(cmd): + loop = asyncio.get_running_loop() + return await loop.subprocess_shell(UciProtocol, cmd) + + +# TODO: Add unit tests instead +async def main(): + transport, engine = await popen_uci("stockfish") + + +if __name__ == "__main__": + loop = asyncio.get_event_loop() + try: + loop.run_until_complete(main()) + finally: + loop.close() From a8b0185eced8cfae97fa6a3f779d72111de1a079 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 18 Dec 2018 18:11:09 +0100 Subject: [PATCH 0014/1451] establish engine connection --- chess/engine.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index d0e2fa36d..a83f05f27 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1,8 +1,25 @@ import asyncio +import logging + + +LOGGER = logging.getLogger(__name__) class UciProtocol(asyncio.SubprocessProtocol): - pass + def __init__(self): + self.loop = asyncio.get_running_loop() + self.transport = None + + self.stdout_buffer = bytearray() + self.stderr_buffer = bytearray() + + def connection_made(self, transport): + self.transport = transport + LOGGER.debug("%s: Connection made", self) + + def __repr__(self): + pid = self.transport.get_pid() if self.transport is not None else None + return "<{} at {} (pid={})>".format(type(self).__name__, hex(id(self)), pid) async def popen_uci(cmd): @@ -16,6 +33,7 @@ async def main(): if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) loop = asyncio.get_event_loop() try: loop.run_until_complete(main()) From 72e4100d2df3ef959f39ba6f63c4102115445744 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 18 Dec 2018 18:22:50 +0100 Subject: [PATCH 0015/1451] handle stdout/stderr --- chess/engine.py | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index a83f05f27..edea128e6 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -10,13 +10,44 @@ def __init__(self): self.loop = asyncio.get_running_loop() self.transport = None - self.stdout_buffer = bytearray() - self.stderr_buffer = bytearray() + self.buffer = { + 1: bytearray(), # stdout + 2: bytearray(), # stderr + } def connection_made(self, transport): self.transport = transport LOGGER.debug("%s: Connection made", self) + def connection_lost(self, exc): + code = self.transport.get_returncode() + LOGGER.debug("%s: Connection lost (exit code: %d, error: %s)", self, code, exc) + + def process_exited(self): + LOGGER.debug("%s: Process exited", self) + + def send_line(self, line): + LOGGER.debug("%s: << %s", self, line) + stdin = self.transport.get_pipe_transport(0) + stdin.write(line.encode("utf-8")) + stdin.write(b"\n") + + def pipe_data_received(self, fd, data): + self.buffer[fd].extend(data) + while b"\n" in self.buffer[fd]: + line, self.buffer[fd] = self.buffer[fd].split(b"\n", 1) + line = line.decode("utf-8") + if fd == 1: + self.line_received(line) + else: + self.error_line_received(line) + + def error_line_received(self, line): + LOGGER.warn("%s: stderr >> %s", self, line) + + def line_received(self, line): + LOGGER.debug("%s: >> %s", self, line) + def __repr__(self): pid = self.transport.get_pid() if self.transport is not None else None return "<{} at {} (pid={})>".format(type(self).__name__, hex(id(self)), pid) @@ -30,6 +61,7 @@ async def popen_uci(cmd): # TODO: Add unit tests instead async def main(): transport, engine = await popen_uci("stockfish") + await asyncio.sleep(1) if __name__ == "__main__": From 8c0b55434135f86dc2772ac9c7c97790d2899e04 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 18 Dec 2018 18:25:58 +0100 Subject: [PATCH 0016/1451] add EngineProtocol.returncode --- chess/engine.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index edea128e6..088087c88 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -15,6 +15,8 @@ def __init__(self): 2: bytearray(), # stderr } + self.returncode = self.loop.create_future() + def connection_made(self, transport): self.transport = transport LOGGER.debug("%s: Connection made", self) @@ -23,6 +25,8 @@ def connection_lost(self, exc): code = self.transport.get_returncode() LOGGER.debug("%s: Connection lost (exit code: %d, error: %s)", self, code, exc) + self.returncode.set_result(code) + def process_exited(self): LOGGER.debug("%s: Process exited", self) @@ -61,7 +65,7 @@ async def popen_uci(cmd): # TODO: Add unit tests instead async def main(): transport, engine = await popen_uci("stockfish") - await asyncio.sleep(1) + await engine.returncode if __name__ == "__main__": From 4f168e5910f13fbd2f0b987f96463b8bd67d3aed Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 18 Dec 2018 19:02:28 +0100 Subject: [PATCH 0017/1451] work on command sequencing --- chess/engine.py | 51 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index 088087c88..5b2559afc 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -5,7 +5,7 @@ LOGGER = logging.getLogger(__name__) -class UciProtocol(asyncio.SubprocessProtocol): +class EngineProtocol(asyncio.SubprocessProtocol): def __init__(self): self.loop = asyncio.get_running_loop() self.transport = None @@ -15,6 +15,8 @@ def __init__(self): 2: bytearray(), # stderr } + self.command = None + self.returncode = self.loop.create_future() def connection_made(self, transport): @@ -52,11 +54,51 @@ def error_line_received(self, line): def line_received(self, line): LOGGER.debug("%s: >> %s", self, line) + async def communicate(self, command): + if self.command is None: + self.command = command + else: + # TODO + raise RuntimeError("engine is busy") + + self.command.start(self) + def __repr__(self): pid = self.transport.get_pid() if self.transport is not None else None return "<{} at {} (pid={})>".format(type(self).__name__, hex(id(self)), pid) +class Command: + def __init__(self, loop=None): + self.loop = loop or asyncio.get_running_loop() + self.idle = self.loop.create_future() + self.previous_command = None + + def prepare(self, engine, previous_command): + self.previous_command = previous_command + if self.previous_command: + self.previous_command.idle.add_done_callback(lambda: self.start(engine)) + self.previous_command.cancel() + + def start(self, engine): + self.previous_command = None + engine.send_line("isready") + + def cancel(self): + pass + + def line_received(self, line): + if self.previous_command: + return self.previous_command.line_received(line) + + if line == "readyok": + self.idle.set_result(None) + + +class UciProtocol(EngineProtocol): + pass + + async def popen_uci(cmd): loop = asyncio.get_running_loop() return await loop.subprocess_shell(UciProtocol, cmd) @@ -65,6 +107,13 @@ async def popen_uci(cmd): # TODO: Add unit tests instead async def main(): transport, engine = await popen_uci("stockfish") + + result = await engine.communicate(Command()) + print("Command 1:", result) + + result = await engine.communicate(Command()) + print("Command 2:", result) + await engine.returncode From 5ccf39bfdc755b700accab325ac4216f26744e77 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 18 Dec 2018 19:09:00 +0100 Subject: [PATCH 0018/1451] more sequencing --- chess/engine.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 5b2559afc..01b279364 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -54,14 +54,14 @@ def error_line_received(self, line): def line_received(self, line): LOGGER.debug("%s: >> %s", self, line) - async def communicate(self, command): - if self.command is None: - self.command = command - else: - # TODO - raise RuntimeError("engine is busy") + if self.command: + self.command.line_received(line) - self.command.start(self) + async def communicate(self, command): + previous_command = self.command + self.command = command + self.command.prepare(self, previous_command) + return await self.command.idle def __repr__(self): pid = self.transport.get_pid() if self.transport is not None else None @@ -77,8 +77,10 @@ def __init__(self, loop=None): def prepare(self, engine, previous_command): self.previous_command = previous_command if self.previous_command: - self.previous_command.idle.add_done_callback(lambda: self.start(engine)) + self.previous_command.idle.add_done_callback(lambda _: self.start(engine)) self.previous_command.cancel() + else: + self.start(engine) def start(self, engine): self.previous_command = None From 839725966a7cbb66a7f8a53fa3206ceca5a3c6e0 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 18 Dec 2018 19:27:25 +0100 Subject: [PATCH 0019/1451] work on BaseCommand --- chess/engine.py | 57 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 01b279364..661c08647 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -55,12 +55,12 @@ def line_received(self, line): LOGGER.debug("%s: >> %s", self, line) if self.command: - self.command.line_received(line) + self.command._line_received(self, line) async def communicate(self, command): previous_command = self.command self.command = command - self.command.prepare(self, previous_command) + self.command._prepare(self, previous_command) return await self.command.idle def __repr__(self): @@ -68,32 +68,51 @@ def __repr__(self): return "<{} at {} (pid={})>".format(type(self).__name__, hex(id(self)), pid) -class Command: +class BaseCommand: def __init__(self, loop=None): + self._previous_command = None + self.loop = loop or asyncio.get_running_loop() + self.sent = self.loop.create_future() + self.result = self.loop.create_future() self.idle = self.loop.create_future() - self.previous_command = None - def prepare(self, engine, previous_command): - self.previous_command = previous_command - if self.previous_command: - self.previous_command.idle.add_done_callback(lambda _: self.start(engine)) - self.previous_command.cancel() + def _prepare(self, engine, previous_command): + def after_previous_command(_): + assert self._previous_command is None or self._previous_command.idle.done() + self._previous_command = None + self.send(engine) + + self._previous_command = previous_command + if self._previous_command: + self._previous_command.idle.add_done_callback(after_previous_command) + self._previous_command.cancel(engine) else: - self.start(engine) + after_previous_command(None) - def start(self, engine): - self.previous_command = None - engine.send_line("isready") + def _line_received(self, engine, line): + if self._previous_command: + self._previous_command._line_received(engine, line) + else: + self.line_received(engine, line) - def cancel(self): + def send(self, engine): pass - def line_received(self, line): - if self.previous_command: - return self.previous_command.line_received(line) + def cancel(self, engine): + pass + + def line_received(self, engine, line): + pass + + +class IsReady(BaseCommand): + def send(self, engine): + engine.send_line("isready") + def line_received(self, engine, line): if line == "readyok": + self.result.set_result(None) self.idle.set_result(None) @@ -110,10 +129,10 @@ async def popen_uci(cmd): async def main(): transport, engine = await popen_uci("stockfish") - result = await engine.communicate(Command()) + result = await engine.communicate(IsReady()) print("Command 1:", result) - result = await engine.communicate(Command()) + result = await engine.communicate(IsReady()) print("Command 2:", result) await engine.returncode From 10e7df5f90f4c14394f30a0dfd3443d78955cc18 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 18 Dec 2018 19:44:39 +0100 Subject: [PATCH 0020/1451] do not even start commands that will be cancelled --- chess/engine.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 661c08647..e3cf3a9c1 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -49,7 +49,7 @@ def pipe_data_received(self, fd, data): self.error_line_received(line) def error_line_received(self, line): - LOGGER.warn("%s: stderr >> %s", self, line) + LOGGER.warning("%s: stderr >> %s", self, line) def line_received(self, line): LOGGER.debug("%s: >> %s", self, line) @@ -61,7 +61,7 @@ async def communicate(self, command): previous_command = self.command self.command = command self.command._prepare(self, previous_command) - return await self.command.idle + return await self.command.result def __repr__(self): pid = self.transport.get_pid() if self.transport is not None else None @@ -71,9 +71,9 @@ def __repr__(self): class BaseCommand: def __init__(self, loop=None): self._previous_command = None + self._started = False self.loop = loop or asyncio.get_running_loop() - self.sent = self.loop.create_future() self.result = self.loop.create_future() self.idle = self.loop.create_future() @@ -81,10 +81,11 @@ def _prepare(self, engine, previous_command): def after_previous_command(_): assert self._previous_command is None or self._previous_command.idle.done() self._previous_command = None + self._started = True self.send(engine) - self._previous_command = previous_command - if self._previous_command: + if previous_command and previous_command._started: + self._previous_command = previous_command self._previous_command.idle.add_done_callback(after_previous_command) self._previous_command.cancel(engine) else: @@ -114,6 +115,8 @@ def line_received(self, engine, line): if line == "readyok": self.result.set_result(None) self.idle.set_result(None) + else: + LOGGER.warning("%s: Unexpected engine output: %s", engine, line) class UciProtocol(EngineProtocol): @@ -129,11 +132,7 @@ async def popen_uci(cmd): async def main(): transport, engine = await popen_uci("stockfish") - result = await engine.communicate(IsReady()) - print("Command 1:", result) - - result = await engine.communicate(IsReady()) - print("Command 2:", result) + await engine.communicate(IsReady()) await engine.returncode From 911c3c43da6b855dc08cfa1f579bc2ec3f4f8aa6 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 18 Dec 2018 20:24:54 +0100 Subject: [PATCH 0021/1451] add BaseCommand.state --- chess/engine.py | 45 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index e3cf3a9c1..3cc23b50f 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1,5 +1,6 @@ import asyncio import logging +import enum LOGGER = logging.getLogger(__name__) @@ -58,6 +59,9 @@ def line_received(self, line): self.command._line_received(self, line) async def communicate(self, command): + if command.state != CommandState.New: + raise RuntimeError("command with invalid state passed to communicate") + previous_command = self.command self.command = command self.command._prepare(self, previous_command) @@ -68,33 +72,47 @@ def __repr__(self): return "<{} at {} (pid={})>".format(type(self).__name__, hex(id(self)), pid) +class CommandState(enum.Enum): + New = 1 + Started = 2 + Finished = 3 + + class BaseCommand: def __init__(self, loop=None): self._previous_command = None - self._started = False + self.state = CommandState.New self.loop = loop or asyncio.get_running_loop() self.result = self.loop.create_future() self.idle = self.loop.create_future() def _prepare(self, engine, previous_command): + assert self.state == CommandState.New + def after_previous_command(_): - assert self._previous_command is None or self._previous_command.idle.done() + assert self._previous_command is None or self._previous_command.state == CommandState.Finished self._previous_command = None - self._started = True - self.send(engine) + if self.state == CommandState.New: + self.state = CommandState.Started + self.send(engine) - if previous_command and previous_command._started: + if not previous_command or previous_command.state == CommandState.Finished: + after_previous_command(None) + elif previous_command.state == CommandState.New: + previous_command.state = CommandState.Finished + previous_command.result.cancel() + after_previous_command(None) + elif previous_command.state == CommandState.Started: self._previous_command = previous_command self._previous_command.idle.add_done_callback(after_previous_command) self._previous_command.cancel(engine) - else: - after_previous_command(None) def _line_received(self, engine, line): if self._previous_command: self._previous_command._line_received(engine, line) - else: + elif self.state != CommandState.Finished: + assert self.state == CommandState.Started self.line_received(engine, line) def send(self, engine): @@ -113,7 +131,8 @@ def send(self, engine): def line_received(self, engine, line): if line == "readyok": - self.result.set_result(None) + if not self.result.cancelled(): + self.result.set_result(None) self.idle.set_result(None) else: LOGGER.warning("%s: Unexpected engine output: %s", engine, line) @@ -130,9 +149,15 @@ async def popen_uci(cmd): # TODO: Add unit tests instead async def main(): - transport, engine = await popen_uci("stockfish") + transport, engine = await popen_uci("./engine.sh") + + try: + await asyncio.wait_for(engine.communicate(IsReady()), 2) + except asyncio.TimeoutError: + print("timed out") await engine.communicate(IsReady()) + print("got second readyok") await engine.returncode From c1e65d81ae4adee738643ba5b04e1fd44c4b5398 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 18 Dec 2018 22:21:57 +0100 Subject: [PATCH 0022/1451] start working on more reliable command sequencing --- chess/engine.py | 78 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 62 insertions(+), 16 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 3cc23b50f..41329ddab 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1,6 +1,7 @@ import asyncio import logging import enum +import collections LOGGER = logging.getLogger(__name__) @@ -17,6 +18,7 @@ def __init__(self): } self.command = None + self.next_command = None self.returncode = self.loop.create_future() @@ -62,10 +64,24 @@ async def communicate(self, command): if command.state != CommandState.New: raise RuntimeError("command with invalid state passed to communicate") - previous_command = self.command - self.command = command - self.command._prepare(self, previous_command) - return await self.command.result + if self.next_command is not None: + self.next_command.result.cancel() + self.next_command.finished.cancel() + + self.next_command = command + + def previous_command_finished(_): + self.command = self.next_command + if self.command is not None: + self.command.finished.add_done_callback(previous_command_finished) + self.command.start() + + if self.command is None: + previous_command_finished(None) + else: + self.command.cancel() + + return await command.result def __repr__(self): pid = self.transport.get_pid() if self.transport is not None else None @@ -75,33 +91,66 @@ def __repr__(self): class CommandState(enum.Enum): New = 1 Started = 2 - Finished = 3 + Cancelling = 3 + Finished = 4 class BaseCommand: def __init__(self, loop=None): - self._previous_command = None self.state = CommandState.New self.loop = loop or asyncio.get_running_loop() self.result = self.loop.create_future() - self.idle = self.loop.create_future() + self.finished = self.loop.create_future() + + def _cancel(self, engine): + if self.state != CommandState.Finished: + assert self.state != CommandState.New + if self.result.done(): + else: + self.result.cancel() + self.finished.set_result(None) def _prepare(self, engine, previous_command): assert self.state == CommandState.New + self.state = CommandState.Prepared + + def on_result(result): + if result.cancelled(): + if self.state == CommandState.Sending: + self.state = CommandState.Cancelling + self.cancel() - def after_previous_command(_): + def on_idle(_): + self.state = CommandState.Finished + + def on_previous_command_idle(_): assert self._previous_command is None or self._previous_command.state == CommandState.Finished self._previous_command = None - if self.state == CommandState.New: + + assert self.state == CommandState.New + + if self.result.cancelled(): + self.idle.set_result(None) + else: self.state = CommandState.Started self.send(engine) + self.result.add_done_callback(on_result) + self.idle.add_done_callback(on_idle) + + if not self._previous_command or self._previous_command.state == CommandState.Finished: + on_previous_command_idle(None) + else: + self._previous_command.idle.add_done_callback(on_previous_command_idle) + self._previous_command.result.cancel() + + if not previous_command or previous_command.state == CommandState.Finished: - after_previous_command(None) + on_previous_command_idle(None) elif previous_command.state == CommandState.New: - previous_command.state = CommandState.Finished previous_command.result.cancel() + previous_command.state = CommandState.Finished after_previous_command(None) elif previous_command.state == CommandState.Started: self._previous_command = previous_command @@ -109,11 +158,8 @@ def after_previous_command(_): self._previous_command.cancel(engine) def _line_received(self, engine, line): - if self._previous_command: - self._previous_command._line_received(engine, line) - elif self.state != CommandState.Finished: - assert self.state == CommandState.Started - self.line_received(engine, line) + assert self.state in [CommandState.Sending, CommandState.Cancelling] + self.line_received(engine, line) def send(self, engine): pass From 595a91451ddbe508616d005a5836d480459d39a7 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 18 Dec 2018 22:37:52 +0100 Subject: [PATCH 0023/1451] wip command sequencing --- chess/engine.py | 83 ++++++++++++++----------------------------------- 1 file changed, 23 insertions(+), 60 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 41329ddab..8df0a06c7 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -73,13 +73,17 @@ async def communicate(self, command): def previous_command_finished(_): self.command = self.next_command if self.command is not None: - self.command.finished.add_done_callback(previous_command_finished) - self.command.start() + cmd = self.command + cmd.result.add_done_callback(lambda result: cmd._cancel(self) if cmd.result.cancelled() else None) + cmd.finished.add_done_callback(previous_command_finished) + cmd._start(self) if self.command is None: previous_command_finished(None) + elif not self.command.result.done(): + self.command.result.cancel() else: - self.command.cancel() + self.command._cancel(self) return await command.result @@ -90,9 +94,8 @@ def __repr__(self): class CommandState(enum.Enum): New = 1 - Started = 2 - Cancelling = 3 - Finished = 4 + Active = 2 + Cancelled = 3 class BaseCommand: @@ -104,69 +107,29 @@ def __init__(self, loop=None): self.finished = self.loop.create_future() def _cancel(self, engine): - if self.state != CommandState.Finished: - assert self.state != CommandState.New - if self.result.done(): - else: - self.result.cancel() - self.finished.set_result(None) + assert self.state == CommandState.Active + self.cancel(engine) + self.state = CommandState.Cancelled - def _prepare(self, engine, previous_command): + def _start(self, engine): assert self.state == CommandState.New - self.state = CommandState.Prepared - - def on_result(result): - if result.cancelled(): - if self.state == CommandState.Sending: - self.state = CommandState.Cancelling - self.cancel() - - def on_idle(_): - self.state = CommandState.Finished - - def on_previous_command_idle(_): - assert self._previous_command is None or self._previous_command.state == CommandState.Finished - self._previous_command = None - - assert self.state == CommandState.New - - if self.result.cancelled(): - self.idle.set_result(None) - else: - self.state = CommandState.Started - self.send(engine) - - self.result.add_done_callback(on_result) - self.idle.add_done_callback(on_idle) - - if not self._previous_command or self._previous_command.state == CommandState.Finished: - on_previous_command_idle(None) - else: - self._previous_command.idle.add_done_callback(on_previous_command_idle) - self._previous_command.result.cancel() - - - if not previous_command or previous_command.state == CommandState.Finished: - on_previous_command_idle(None) - elif previous_command.state == CommandState.New: - previous_command.result.cancel() - previous_command.state = CommandState.Finished - after_previous_command(None) - elif previous_command.state == CommandState.Started: - self._previous_command = previous_command - self._previous_command.idle.add_done_callback(after_previous_command) - self._previous_command.cancel(engine) + self.state = CommandState.Active + self.start(engine) def _line_received(self, engine, line): - assert self.state in [CommandState.Sending, CommandState.Cancelling] + assert self.state == CommandState.Active self.line_received(engine, line) - def send(self, engine): - pass + def set_finished(self): + assert self.state == CommandState.Active + self.finished.set_result(None) def cancel(self, engine): pass + def start(self, engine): + pass + def line_received(self, engine, line): pass @@ -179,7 +142,7 @@ def line_received(self, engine, line): if line == "readyok": if not self.result.cancelled(): self.result.set_result(None) - self.idle.set_result(None) + self.set_finished() else: LOGGER.warning("%s: Unexpected engine output: %s", engine, line) From ec98e20bfb403ec22ee8fad0fcb5eba061f4b621 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 18 Dec 2018 23:08:28 +0100 Subject: [PATCH 0024/1451] handle engine disconnections --- chess/engine.py | 54 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 8df0a06c7..030814795 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -7,6 +7,10 @@ LOGGER = logging.getLogger(__name__) +class EngineTerminatedError(RuntimeError): + pass + + class EngineProtocol(asyncio.SubprocessProtocol): def __init__(self): self.loop = asyncio.get_running_loop() @@ -32,6 +36,17 @@ def connection_lost(self, exc): self.returncode.set_result(code) + # Terminate commands. + if exc is None: + exc = EngineTerminatedError("engine process dead (exit code: {})".format(code)) + if self.command is not None: + self.command._terminate(exc) + self.command = None + if self.next_command is not None: + self.next_command._terminate(exc) + self.next_command = None + + def process_exited(self): LOGGER.debug("%s: Process exited", self) @@ -67,11 +82,15 @@ async def communicate(self, command): if self.next_command is not None: self.next_command.result.cancel() self.next_command.finished.cancel() + self.next_command._done() self.next_command = command def previous_command_finished(_): - self.command = self.next_command + if self.command is not None: + self.command._done() + + self.command, self.next_command = self.next_command, None if self.command is not None: cmd = self.command cmd.result.add_done_callback(lambda result: cmd._cancel(self) if cmd.result.cancelled() else None) @@ -82,7 +101,7 @@ def previous_command_finished(_): previous_command_finished(None) elif not self.command.result.done(): self.command.result.cancel() - else: + elif not self.command.result.cancelled(): self.command._cancel(self) return await command.result @@ -95,7 +114,8 @@ def __repr__(self): class CommandState(enum.Enum): New = 1 Active = 2 - Cancelled = 3 + Cancelling = 3 + Done = 4 class BaseCommand: @@ -106,22 +126,36 @@ def __init__(self, loop=None): self.result = self.loop.create_future() self.finished = self.loop.create_future() + def _terminate(self, exc): + if not self.result.done(): + self.result.set_exception(exc) + if not self.finished.done(): + self.finished.set_exception(exc) + try: + self.finished.result() + except: + pass + def _cancel(self, engine): assert self.state == CommandState.Active + self.state = CommandState.Cancelling self.cancel(engine) - self.state = CommandState.Cancelled def _start(self, engine): assert self.state == CommandState.New self.state = CommandState.Active self.start(engine) + def _done(self): + assert self.state != CommandState.Done + self.state = CommandState.Done + def _line_received(self, engine, line): - assert self.state == CommandState.Active + assert self.state in [CommandState.Active, CommandState.Cancelling] self.line_received(engine, line) def set_finished(self): - assert self.state == CommandState.Active + assert self.state in [CommandState.Active, CommandState.Cancelling] self.finished.set_result(None) def cancel(self, engine): @@ -135,7 +169,7 @@ def line_received(self, engine, line): class IsReady(BaseCommand): - def send(self, engine): + def start(self, engine): engine.send_line("isready") def line_received(self, engine, line): @@ -159,12 +193,16 @@ async def popen_uci(cmd): # TODO: Add unit tests instead async def main(): transport, engine = await popen_uci("./engine.sh") + #transport, engine = await popen_uci("stockfish") + isready = IsReady() try: - await asyncio.wait_for(engine.communicate(IsReady()), 2) + await asyncio.wait_for(engine.communicate(isready), 2) except asyncio.TimeoutError: print("timed out") + print(isready.state) + await engine.communicate(IsReady()) print("got second readyok") From 37b8c8fd04f5ba9b14c0b3baaafb92c94c60b6d4 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 18 Dec 2018 23:11:50 +0100 Subject: [PATCH 0025/1451] do not allow to start command on dead engine --- chess/engine.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 030814795..2c6ff1f56 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -33,12 +33,11 @@ def connection_made(self, transport): def connection_lost(self, exc): code = self.transport.get_returncode() LOGGER.debug("%s: Connection lost (exit code: %d, error: %s)", self, code, exc) - self.returncode.set_result(code) # Terminate commands. if exc is None: - exc = EngineTerminatedError("engine process dead (exit code: {})".format(code)) + exc = EngineTerminatedError("engine process died (exit code: {})".format(code)) if self.command is not None: self.command._terminate(exc) self.command = None @@ -76,6 +75,9 @@ def line_received(self, line): self.command._line_received(self, line) async def communicate(self, command): + if self.returncode.done(): + raise EngineTerminatedError("engine process dead (exit code: {})".format(self.returncode.result())) + if command.state != CommandState.New: raise RuntimeError("command with invalid state passed to communicate") @@ -201,13 +203,11 @@ async def main(): except asyncio.TimeoutError: print("timed out") - print(isready.state) + await engine.returncode await engine.communicate(IsReady()) print("got second readyok") - await engine.returncode - if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) From ef79c42f6a5bf47a1f2ce73f8b75b5fe23f03479 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 18 Dec 2018 23:15:49 +0100 Subject: [PATCH 0026/1451] implement UciProtocol.isready --- chess/engine.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 2c6ff1f56..92b1ce6bc 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -170,6 +170,11 @@ def line_received(self, engine, line): pass +class UciProtocol(EngineProtocol): + async def isready(self): + return await self.communicate(IsReady()) + + class IsReady(BaseCommand): def start(self, engine): engine.send_line("isready") @@ -183,8 +188,6 @@ def line_received(self, engine, line): LOGGER.warning("%s: Unexpected engine output: %s", engine, line) -class UciProtocol(EngineProtocol): - pass async def popen_uci(cmd): @@ -194,20 +197,18 @@ async def popen_uci(cmd): # TODO: Add unit tests instead async def main(): - transport, engine = await popen_uci("./engine.sh") - #transport, engine = await popen_uci("stockfish") + #transport, engine = await popen_uci("./engine.sh") + transport, engine = await popen_uci("stockfish") - isready = IsReady() try: - await asyncio.wait_for(engine.communicate(isready), 2) + await asyncio.wait_for(engine.isready(), 2) except asyncio.TimeoutError: print("timed out") + else: + print("got readyok") await engine.returncode - await engine.communicate(IsReady()) - print("got second readyok") - if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) From 3cba343f25ffab77f7a700c85fc8ea328a144632 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 18 Dec 2018 23:24:09 +0100 Subject: [PATCH 0027/1451] add simple play command --- chess/engine.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index 92b1ce6bc..72c19959f 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -174,6 +174,9 @@ class UciProtocol(EngineProtocol): async def isready(self): return await self.communicate(IsReady()) + async def play(self, board): + return await self.communicate(Go(board)) + class IsReady(BaseCommand): def start(self, engine): @@ -188,6 +191,23 @@ def line_received(self, engine, line): LOGGER.warning("%s: Unexpected engine output: %s", engine, line) +class Go(BaseCommand): + def __init__(self, board): + super().__init__() + self.fen = board.fen() + + def start(self, engine): + engine.send_line("position fen {}".format(self.fen)) + engine.send_line("go movetime 1000") + + def line_received(self, engine, line): + if line.startswith("bestmove "): + if not self.result.cancelled(): + self.result.set_result(line) + self.set_finished() + + def cancel(self, engine): + engine.send_line("stop") async def popen_uci(cmd): @@ -197,6 +217,8 @@ async def popen_uci(cmd): # TODO: Add unit tests instead async def main(): + import chess + #transport, engine = await popen_uci("./engine.sh") transport, engine = await popen_uci("stockfish") @@ -207,6 +229,10 @@ async def main(): else: print("got readyok") + board = chess.Board() + move = await engine.play(board) + print("played", move) + await engine.returncode From 5b317edacc6e281eac1243d1a7ee6684cd4bb489 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 18 Dec 2018 23:34:44 +0100 Subject: [PATCH 0028/1451] experiment with a synchronous wrapper --- chess/engine.py | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 72c19959f..bf13eb564 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -33,7 +33,6 @@ def connection_made(self, transport): def connection_lost(self, exc): code = self.transport.get_returncode() LOGGER.debug("%s: Connection lost (exit code: %d, error: %s)", self, code, exc) - self.returncode.set_result(code) # Terminate commands. if exc is None: @@ -45,6 +44,7 @@ def connection_lost(self, exc): self.next_command._terminate(exc) self.next_command = None + self.returncode.set_result(code) def process_exited(self): LOGGER.debug("%s: Process exited", self) @@ -215,8 +215,34 @@ async def popen_uci(cmd): return await loop.subprocess_shell(UciProtocol, cmd) +class SimpleEngine: + def __init__(self, transport, protocol): + self.transport = transport + self.protocol = protocol + + def isready(self): + self.protocol.loop.run_until_complete(self.protocol.isready()) + + def close(self): + self.transport.close() + self.protocol.loop.run_until_complete(self.protocol.returncode) + + @classmethod + def popen_uci(cls, cmd): + loop = asyncio.get_event_loop() + transport, protocol = loop.run_until_complete(popen_uci(cmd)) + return cls(transport, protocol) + + +def main(): + engine = SimpleEngine.popen_uci("stockfish") + engine.isready() + print("all good") + engine.quit() + + # TODO: Add unit tests instead -async def main(): +async def async_main(): import chess #transport, engine = await popen_uci("./engine.sh") @@ -236,10 +262,12 @@ async def main(): await engine.returncode +logging.basicConfig(level=logging.DEBUG) if __name__ == "__main__": - logging.basicConfig(level=logging.DEBUG) + main() +elif __name__ == "__main__": loop = asyncio.get_event_loop() try: - loop.run_until_complete(main()) + loop.run_until_complete(async_main()) finally: loop.close() From 3cb877997d00007f53b3be371ef4a4912fb09995 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 19 Dec 2018 00:27:05 +0100 Subject: [PATCH 0029/1451] tweak termination --- chess/engine.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index bf13eb564..5fadaccb3 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -38,10 +38,10 @@ def connection_lost(self, exc): if exc is None: exc = EngineTerminatedError("engine process died (exit code: {})".format(code)) if self.command is not None: - self.command._terminate(exc) + self.command._engine_terminated(self, exc) self.command = None if self.next_command is not None: - self.next_command._terminate(exc) + self.next_command._engine_terminated(self, exc) self.next_command = None self.returncode.set_result(code) @@ -128,7 +128,7 @@ def __init__(self, loop=None): self.result = self.loop.create_future() self.finished = self.loop.create_future() - def _terminate(self, exc): + def _engine_terminated(self, engine, exc): if not self.result.done(): self.result.set_exception(exc) if not self.finished.done(): @@ -138,6 +138,13 @@ def _terminate(self, exc): except: pass + if self.state in [CommandState.Active, CommandState.Cancelling]: + self.engine_terminated(self, engine, exc) + + def set_finished(self): + assert self.state in [CommandState.Active, CommandState.Cancelling] + self.finished.set_result(None) + def _cancel(self, engine): assert self.state == CommandState.Active self.state = CommandState.Cancelling @@ -156,10 +163,6 @@ def _line_received(self, engine, line): assert self.state in [CommandState.Active, CommandState.Cancelling] self.line_received(engine, line) - def set_finished(self): - assert self.state in [CommandState.Active, CommandState.Cancelling] - self.finished.set_result(None) - def cancel(self, engine): pass @@ -169,6 +172,9 @@ def start(self, engine): def line_received(self, engine, line): pass + def engine_terminated(self, engine, exc): + pass + class UciProtocol(EngineProtocol): async def isready(self): @@ -238,7 +244,7 @@ def main(): engine = SimpleEngine.popen_uci("stockfish") engine.isready() print("all good") - engine.quit() + engine.close() # TODO: Add unit tests instead From 6b81adb327e31507596c5ebbbab7c800a878409e Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 19 Dec 2018 00:49:37 +0100 Subject: [PATCH 0030/1451] initialize uci engine --- chess/engine.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index 5fadaccb3..10b4d42a1 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -133,6 +133,8 @@ def _engine_terminated(self, engine, exc): self.result.set_exception(exc) if not self.finished.done(): self.finished.set_exception(exc) + + # Prevent warning when the exception is not retrieved. try: self.finished.result() except: @@ -143,6 +145,8 @@ def _engine_terminated(self, engine, exc): def set_finished(self): assert self.state in [CommandState.Active, CommandState.Cancelling] + if not self.result.done(): + self.result.set_result(None) self.finished.set_result(None) def _cancel(self, engine): @@ -216,9 +220,20 @@ def cancel(self, engine): engine.send_line("stop") +class _Uci(BaseCommand): + def start(self, engine): + engine.send_line("uci") + + def line_received(self, engine, line): + if line == "uciok": + self.set_finished() + + async def popen_uci(cmd): loop = asyncio.get_running_loop() - return await loop.subprocess_shell(UciProtocol, cmd) + transport, protocol = await loop.subprocess_shell(UciProtocol, cmd) + await protocol.communicate(_Uci()) + return transport, protocol class SimpleEngine: From d6d674d88969a1b6eb671fa2835da7f073c26d4b Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 19 Dec 2018 00:58:13 +0100 Subject: [PATCH 0031/1451] some context when process dies while communicating --- chess/engine.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 10b4d42a1..f88ff13d0 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -35,13 +35,11 @@ def connection_lost(self, exc): LOGGER.debug("%s: Connection lost (exit code: %d, error: %s)", self, code, exc) # Terminate commands. - if exc is None: - exc = EngineTerminatedError("engine process died (exit code: {})".format(code)) if self.command is not None: - self.command._engine_terminated(self, exc) + self.command._engine_terminated(self, code) self.command = None if self.next_command is not None: - self.next_command._engine_terminated(self, exc) + self.next_command._engine_terminated(self, code) self.next_command = None self.returncode.set_result(code) @@ -128,7 +126,9 @@ def __init__(self, loop=None): self.result = self.loop.create_future() self.finished = self.loop.create_future() - def _engine_terminated(self, engine, exc): + def _engine_terminated(self, engine, code): + exc = EngineTerminatedError("engine process died while running {} (exit code: {})".format(repr(self), code)) + if not self.result.done(): self.result.set_exception(exc) if not self.finished.done(): @@ -141,7 +141,7 @@ def _engine_terminated(self, engine, exc): pass if self.state in [CommandState.Active, CommandState.Cancelling]: - self.engine_terminated(self, engine, exc) + self.engine_terminated(engine, exc) def set_finished(self): assert self.state in [CommandState.Active, CommandState.Cancelling] @@ -256,7 +256,7 @@ def popen_uci(cls, cmd): def main(): - engine = SimpleEngine.popen_uci("stockfish") + engine = SimpleEngine.popen_uci("./engine.sh") engine.isready() print("all good") engine.close() From 08718645b7ca89ce0c324af3488c08aae5cc82f7 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 22 Dec 2018 18:00:07 +0100 Subject: [PATCH 0032/1451] Remove IPython.display.SVG from examples --- chess/svg.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/chess/svg.py b/chess/svg.py index 14e3d9b28..a91cfccd6 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -105,9 +105,7 @@ def piece(piece, size=None): >>> import chess >>> import chess.svg >>> - >>> from IPython.display import SVG - >>> - >>> SVG(chess.svg.piece(chess.Piece.from_symbol("R"))) # doctest: +SKIP + >>> chess.svg.piece(chess.Piece.from_symbol("R")) # doctest: +SKIP .. image:: ../docs/wR.svg """ @@ -138,11 +136,9 @@ def board(board=None, *, squares=None, flipped=False, coordinates=True, lastmove >>> import chess >>> import chess.svg >>> - >>> from IPython.display import SVG - >>> >>> board = chess.Board("8/8/8/8/4N3/8/8/8 w - - 0 1") >>> squares = board.attacks(chess.E4) - >>> SVG(chess.svg.board(board=board, squares=squares)) # doctest: +SKIP + >>> chess.svg.board(board=board, squares=squares) # doctest: +SKIP .. image:: ../docs/Ne4.svg """ From 259544e05007eecbf731e1fcc3987e8981cc4cc4 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sun, 23 Dec 2018 14:15:42 +0100 Subject: [PATCH 0033/1451] Less confusing error if board.san() is misused --- chess/__init__.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/chess/__init__.py b/chess/__init__.py index c18421edb..a0cf36422 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -2545,22 +2545,23 @@ def _algebraic(self, move, long=False): else: return san - piece = self.piece_type_at(move.from_square) + piece_type = self.piece_type_at(move.from_square) + assert piece_type, "san() and lan() expect move to be legal or null, but got {} in {}".format(move, self.fen()) capture = self.is_capture(move) - if piece == PAWN: + if piece_type == PAWN: san = "" else: - san = PIECE_SYMBOLS[piece].upper() + san = PIECE_SYMBOLS[piece_type].upper() if long: san += SQUARE_NAMES[move.from_square] - elif piece != PAWN: + elif piece_type != PAWN: # Get ambiguous move candidates. # Relevant candidates: not exactly the current move, # but to the same square. others = 0 - from_mask = self.pieces_mask(piece, self.turn) + from_mask = self.pieces_mask(piece_type, self.turn) from_mask &= ~BB_SQUARES[move.from_square] to_mask = BB_SQUARES[move.to_square] for candidate in self.generate_legal_moves(from_mask, to_mask): From c92719a0cd2a92c92eae9a05a2207d051ecec6dc Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sun, 23 Dec 2018 15:13:50 +0100 Subject: [PATCH 0034/1451] engine configuration --- chess/engine.py | 72 ++++++++++++++++++++++++++++--------------------- 1 file changed, 42 insertions(+), 30 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index f88ff13d0..850f140b9 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -182,13 +182,44 @@ def engine_terminated(self, engine, exc): class UciProtocol(EngineProtocol): async def isready(self): - return await self.communicate(IsReady()) + return await self.communicate(_IsReady()) - async def play(self, board): - return await self.communicate(Go(board)) + async def configure(self, options): + return await self.communicate(UciConfigure(options)) -class IsReady(BaseCommand): +class UciConfigure(BaseCommand): + def __init__(self, options): + super().__init__() + + self.lines = [] + + for name, value in options.items(): + if name.lower() == "uci_chess960": + raise ValueError("cannot set UCI_Chess960 which is automatically managed") + elif name.lower() == "uci_variant": + raise ValueError("cannot set UCI_Variant which is automatically managed") + + builder = ["setoption name", name, "value"] + if value is True: + builder.append("true") + elif value is False: + builder.append("false") + elif value is None: + builder.append("none") + else: + builder.append(str(value)) + + self.lines.append(" ".join(builder)) + + def start(self, engine): + for line in self.lines: + engine.send_line(line) + + self.set_finished() + + +class _IsReady(BaseCommand): def start(self, engine): engine.send_line("isready") @@ -201,7 +232,7 @@ def line_received(self, engine, line): LOGGER.warning("%s: Unexpected engine output: %s", engine, line) -class Go(BaseCommand): +class _Go(BaseCommand): def __init__(self, board): super().__init__() self.fen = board.fen() @@ -255,40 +286,21 @@ def popen_uci(cls, cmd): return cls(transport, protocol) -def main(): - engine = SimpleEngine.popen_uci("./engine.sh") - engine.isready() - print("all good") - engine.close() - - -# TODO: Add unit tests instead async def async_main(): import chess #transport, engine = await popen_uci("./engine.sh") transport, engine = await popen_uci("stockfish") - try: - await asyncio.wait_for(engine.isready(), 2) - except asyncio.TimeoutError: - print("timed out") - else: - print("got readyok") + await engine.configure({ + "Contempt": 20, + }) - board = chess.Board() - move = await engine.play(board) - print("played", move) + transport.close() await engine.returncode -logging.basicConfig(level=logging.DEBUG) if __name__ == "__main__": - main() -elif __name__ == "__main__": - loop = asyncio.get_event_loop() - try: - loop.run_until_complete(async_main()) - finally: - loop.close() + logging.basicConfig(level=logging.DEBUG) + asyncio.run(async_main()) From 55d5317108b562c55723f3ab4273573d746a8c01 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sun, 23 Dec 2018 15:50:31 +0100 Subject: [PATCH 0035/1451] build map of available options --- chess/engine.py | 158 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 145 insertions(+), 13 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 850f140b9..a229e8d31 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -11,6 +11,10 @@ class EngineTerminatedError(RuntimeError): pass +class Option(collections.namedtuple("Option", "name type default min max var")): + """Information about an available engine option.""" + + class EngineProtocol(asyncio.SubprocessProtocol): def __init__(self): self.loop = asyncio.get_running_loop() @@ -69,9 +73,14 @@ def error_line_received(self, line): def line_received(self, line): LOGGER.debug("%s: >> %s", self, line) + self._line_received(line) + if self.command: self.command._line_received(self, line) + def _line_received(self, line): + pass + async def communicate(self, command): if self.returncode.done(): raise EngineTerminatedError("engine process dead (exit code: {})".format(self.returncode.result())) @@ -181,11 +190,143 @@ def engine_terminated(self, engine, exc): class UciProtocol(EngineProtocol): + def __init__(self): + super().__init__() + self.options = UciOptionMap() + self.config = UciOptionMap() + + def _line_received(self, line): + command_and_args = line.split(None, 1) + if len(command_and_args) >= 2: + if command_and_args[0] == "option": + self._option(command_and_args[1]) + + def _option(self, arg): + current_parameter = None + + name = [] + type = [] + default = [] + min = None + max = None + current_var = None + var = [] + + for token in arg.split(" "): + if token == "name" and not name: + current_parameter = "name" + elif token == "type" and not type: + current_parameter = "type" + elif token == "default" and not default: + current_parameter = "default" + elif token == "min" and min is None: + current_parameter = "min" + elif token == "max" and max is None: + current_parameter = "max" + elif token == "var": + current_parameter = "var" + if current_var is not None: + var.append(" ".join(current_var)) + current_var = [] + elif current_parameter == "name": + name.append(token) + elif current_parameter == "type": + type.append(token) + elif current_parameter == "default": + default.append(token) + elif current_parameter == "var": + current_var.append(token) + elif current_parameter == "min": + try: + min = int(token) + except ValueError: + LOGGER.exception("exception parsing option min") + elif current_parameter == "max": + try: + max = int(token) + except ValueError: + LOGGER.exception("exception parsing option max") + + if current_var is not None: + var.append(" ".join(current_var)) + + type = " ".join(type) + + default = " ".join(default) + if type == "check": + if default == "true": + default = True + elif default == "false": + default = False + else: + default = None + elif type == "spin": + try: + default = int(default) + except ValueError: + LOGGER.exception("exception parsing option spin default") + default = None + + option = Option(" ".join(name), type, default, min, max, var) + self.options[option.name] = option + async def isready(self): return await self.communicate(_IsReady()) - async def configure(self, options): - return await self.communicate(UciConfigure(options)) + async def configure(self, config): + return await self.communicate(UciConfigure(config)) + + +class UciOptionMap(collections.abc.MutableMapping): + def __init__(self, data=None, **kwargs): + self._store = dict() + if data is None: + data = {} + self.update(data, **kwargs) + + def __setitem__(self, key, value): + self._store[key.lower()] = (key, value) + + def __getitem__(self, key): + return self._store[key.lower()][1] + + def __delitem__(self, key): + del self._store[key.lower()] + + def __iter__(self): + return (casedkey for casedkey, mappedvalue in self._store.values()) + + def __len__(self): + return len(self._store) + + def __eq__(self, other): + for key, value in self.items(): + if key not in other or other[key] != value: + return False + + for key, value in other.items(): + if key not in self or self[key] != value: + return False + + return True + + def copy(self): + return type(self)(self._store.values()) + + def __copy__(self): + return self.copy() + + def __repr__(self): + return "{}({})".format(type(self).__name__, dict(self.items())) + + +class UciInit(BaseCommand): + def start(self, engine): + engine.send_line("uci") + + def line_received(self, engine, line): + if line == "uciok": + self.set_finished() class UciConfigure(BaseCommand): @@ -251,19 +392,10 @@ def cancel(self, engine): engine.send_line("stop") -class _Uci(BaseCommand): - def start(self, engine): - engine.send_line("uci") - - def line_received(self, engine, line): - if line == "uciok": - self.set_finished() - - async def popen_uci(cmd): loop = asyncio.get_running_loop() transport, protocol = await loop.subprocess_shell(UciProtocol, cmd) - await protocol.communicate(_Uci()) + await protocol.communicate(UciInit()) return transport, protocol @@ -296,7 +428,7 @@ async def async_main(): "Contempt": 20, }) - transport.close() + print(engine.options) await engine.returncode From c0986ca040b19eae1f1d8df965b8806b0fd01d4f Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sun, 23 Dec 2018 16:00:34 +0100 Subject: [PATCH 0036/1451] validate existence of option --- chess/engine.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index a229e8d31..832ceecb3 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -332,11 +332,13 @@ def line_received(self, engine, line): class UciConfigure(BaseCommand): def __init__(self, options): super().__init__() + self.options = options - self.lines = [] - - for name, value in options.items(): - if name.lower() == "uci_chess960": + def start(self, engine): + for name, value in self.options.items(): + if name not in engine.options: + raise ValueError("engine does not support option: {}".format(name)) + elif name.lower() == "uci_chess960": raise ValueError("cannot set UCI_Chess960 which is automatically managed") elif name.lower() == "uci_variant": raise ValueError("cannot set UCI_Variant which is automatically managed") @@ -351,11 +353,7 @@ def __init__(self, options): else: builder.append(str(value)) - self.lines.append(" ".join(builder)) - - def start(self, engine): - for line in self.lines: - engine.send_line(line) + engine.send_line(" ".join(builder)) self.set_finished() @@ -426,6 +424,7 @@ async def async_main(): await engine.configure({ "Contempt": 20, + "ContemptA": 20, }) print(engine.options) From 415cda5ff324aa15bd241afb8196b40df27ac42c Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 24 Dec 2018 17:05:41 +0100 Subject: [PATCH 0037/1451] Make _set_piece_at more robust --- chess/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/chess/__init__.py b/chess/__init__.py index a0cf36422..4e791a6ab 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -854,6 +854,8 @@ def _set_piece_at(self, square, piece_type, color, promoted=False): self.queens |= mask elif piece_type == KING: self.kings |= mask + else: + return self.occupied ^= mask self.occupied_co[color] ^= mask @@ -2009,7 +2011,7 @@ def push(self, move): self._set_piece_at(F1 if self.turn == WHITE else F8, ROOK, self.turn) # Put the piece on the target square. - if not castling and piece_type: + if not castling: was_promoted = self.promoted & to_bb self._set_piece_at(move.to_square, piece_type, self.turn, promoted) From ba92d663523b8b172aae5082869fdf81ddf9a8ee Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 27 Dec 2018 16:19:49 +0100 Subject: [PATCH 0038/1451] add setup_loop --- chess/engine.py | 60 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index 832ceecb3..22288de14 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -2,6 +2,9 @@ import logging import enum import collections +import warnings +import sys +import threading LOGGER = logging.getLogger(__name__) @@ -397,6 +400,63 @@ async def popen_uci(cmd): return transport, protocol +def setup_loop(): + """ + Creates and sets up a new asyncio event loop that is capable of spawning + and watching subprocesses. + + Uses polling to watch subprocesses when not running in the main thread. + + Note that this sets a global event loop policy. + """ + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + else: + asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy()) + + if sys.platform == "win32" or threading.current_thread() == threading.main_thread(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop + + class PollingChildWatcher(asyncio.SafeChildWatcher): + def __init__(self): + super().__init__() + self._poll_handle = None + + def attach_loop(self, loop): + assert loop is None or isinstance(loop, asyncio.AbstractEventLoop) + + if self._loop is not None and loop is None and self._callbacks: + warnings.warn("A loop is being detached from a child watcher with pending handlers", RuntimeWarning) + + if self._poll_handle is not None: + self._poll_handle.cancel() + + self._loop = loop + if loop is not None: + self._poll_handle = self._loop.call_soon(self._poll) + + # Prevent a race condition in case a child terminated + # during the switch. + self._do_waitpid_all() + + def _poll(self): + if self._loop: + self._do_waitpid_all() + self._poll_handle = self._loop.call_later(1.0, self._poll) + + policy = asyncio.get_event_loop_policy() + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + watcher = PollingChildWatcher() + watcher.attach_loop(loop) + policy.set_child_watcher(watcher) + + return loop + + class SimpleEngine: def __init__(self, transport, protocol): self.transport = transport From 91e897a9003221967e91213dcceb7d7cce9d4183 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 27 Dec 2018 16:44:55 +0100 Subject: [PATCH 0039/1451] use setup_loop for SimpleEngine --- chess/engine.py | 48 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 22288de14..cf549c8db 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1,4 +1,5 @@ import asyncio +import concurrent.futures import logging import enum import collections @@ -457,6 +458,10 @@ def _poll(self): return loop +def get_running_loop(): + return asyncio._get_running_loop() + + class SimpleEngine: def __init__(self, transport, protocol): self.transport = transport @@ -466,14 +471,37 @@ def isready(self): self.protocol.loop.run_until_complete(self.protocol.isready()) def close(self): - self.transport.close() - self.protocol.loop.run_until_complete(self.protocol.returncode) + event = threading.Event() + + def target(): + self.protocol.loop.stop() + event.set() + + self.protocol.loop.call_soon_threadsafe(target) + event.wait() @classmethod - def popen_uci(cls, cmd): - loop = asyncio.get_event_loop() - transport, protocol = loop.run_until_complete(popen_uci(cmd)) - return cls(transport, protocol) + def popen_uci(cls, cmd, *, timeout=10.0): + engine = concurrent.futures.Future() + + def target(): + loop = setup_loop() + + try: + transport, protocol = loop.run_until_complete(asyncio.wait_for(popen_uci(cmd), timeout)) + except Exception as exc: + engine.set_exception(exc) + else: + engine.set_result(cls(transport, protocol)) + + try: + loop.run_forever() + finally: + loop.close() + + threading.Thread(target=target).start() + + return engine.result() async def async_main(): @@ -492,6 +520,12 @@ async def async_main(): await engine.returncode +def main(): + engine = SimpleEngine.popen_uci(sys.argv[1]) + engine.close() + + if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) - asyncio.run(async_main()) + main() + #asyncio.run(async_main()) From 61022bc78d5cfe095b18e6f3dae1cec0cc100064 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 27 Dec 2018 16:48:05 +0100 Subject: [PATCH 0040/1451] remember timeout --- chess/engine.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index cf549c8db..e1ce76e9a 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -463,9 +463,10 @@ def get_running_loop(): class SimpleEngine: - def __init__(self, transport, protocol): + def __init__(self, transport, protocol, *, timeout=10.0): self.transport = transport self.protocol = protocol + self.timeout = timeout def isready(self): self.protocol.loop.run_until_complete(self.protocol.isready()) @@ -491,13 +492,14 @@ def target(): transport, protocol = loop.run_until_complete(asyncio.wait_for(popen_uci(cmd), timeout)) except Exception as exc: engine.set_exception(exc) - else: - engine.set_result(cls(transport, protocol)) + return - try: - loop.run_forever() - finally: - loop.close() + engine.set_result(cls(transport, protocol, timeout=timeout)) + + try: + loop.run_forever() + finally: + loop.close() threading.Thread(target=target).start() From 787a4e757b455dd978d01b24d64629adaf48d8de Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 27 Dec 2018 16:53:58 +0100 Subject: [PATCH 0041/1451] update SimpleEngine.isready --- chess/engine.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index e1ce76e9a..ffeb564d5 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -469,7 +469,7 @@ def __init__(self, transport, protocol, *, timeout=10.0): self.timeout = timeout def isready(self): - self.protocol.loop.run_until_complete(self.protocol.isready()) + return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.isready(), self.timeout), self.protocol.loop).result() def close(self): event = threading.Event() @@ -524,6 +524,7 @@ async def async_main(): def main(): engine = SimpleEngine.popen_uci(sys.argv[1]) + engine.isready() engine.close() From 296a00977e9aa3df7ab5f5927a48e731e5584130 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 27 Dec 2018 16:57:43 +0100 Subject: [PATCH 0042/1451] polyfill get_running_loop --- chess/engine.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index ffeb564d5..f530476d4 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -21,7 +21,7 @@ class Option(collections.namedtuple("Option", "name type default min max var")): class EngineProtocol(asyncio.SubprocessProtocol): def __init__(self): - self.loop = asyncio.get_running_loop() + self.loop = get_running_loop() self.transport = None self.buffer = { @@ -135,7 +135,7 @@ class BaseCommand: def __init__(self, loop=None): self.state = CommandState.New - self.loop = loop or asyncio.get_running_loop() + self.loop = loop or get_running_loop() self.result = self.loop.create_future() self.finished = self.loop.create_future() @@ -395,7 +395,7 @@ def cancel(self, engine): async def popen_uci(cmd): - loop = asyncio.get_running_loop() + loop = get_running_loop() transport, protocol = await loop.subprocess_shell(UciProtocol, cmd) await protocol.communicate(UciInit()) return transport, protocol @@ -458,8 +458,10 @@ def _poll(self): return loop -def get_running_loop(): - return asyncio._get_running_loop() +try: + from asyncio import get_running_loop +except ImportError: + from asyncio import _get_running_loop as get_running_loop class SimpleEngine: From 398193d3bc3ce4c6bda053ebeedb2e0018227841 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 27 Dec 2018 17:14:08 +0100 Subject: [PATCH 0043/1451] tweak SimpleEngine shutdown --- chess/engine.py | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index f530476d4..67332e03c 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -474,20 +474,13 @@ def isready(self): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.isready(), self.timeout), self.protocol.loop).result() def close(self): - event = threading.Event() - - def target(): - self.protocol.loop.stop() - event.set() - - self.protocol.loop.call_soon_threadsafe(target) - event.wait() + self.transport.close() @classmethod def popen_uci(cls, cmd, *, timeout=10.0): engine = concurrent.futures.Future() - def target(): + def background_thread(): loop = setup_loop() try: @@ -499,11 +492,11 @@ def target(): engine.set_result(cls(transport, protocol, timeout=timeout)) try: - loop.run_forever() + loop.run_until_complete(protocol.returncode) finally: loop.close() - threading.Thread(target=target).start() + threading.Thread(target=background_thread).start() return engine.result() @@ -525,9 +518,13 @@ async def async_main(): def main(): - engine = SimpleEngine.popen_uci(sys.argv[1]) - engine.isready() - engine.close() + engine_a = SimpleEngine.popen_uci(sys.argv[1]) + engine_a.isready() + + engine_b = SimpleEngine.popen_uci(sys.argv[1]) + engine_b.isready() + + engine_a.close() if __name__ == "__main__": From 8e892d56a3e12a9462ceccc746da5c6342d8a880 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 27 Dec 2018 17:16:54 +0100 Subject: [PATCH 0044/1451] allow pending tasks to finish --- chess/engine.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index 67332e03c..13f9a6024 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -494,6 +494,9 @@ def background_thread(): try: loop.run_until_complete(protocol.returncode) finally: + pending = asyncio.Task.all_tasks(loop) + loop.run_until_complete(asyncio.gather(*pending)) + loop.close() threading.Thread(target=background_thread).start() From e3185248e78180fc6e0ba9cc0e97890c6af0f0c9 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 27 Dec 2018 17:27:14 +0100 Subject: [PATCH 0045/1451] progress on graceful shutdown --- chess/engine.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 13f9a6024..fb255eca3 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -494,10 +494,32 @@ def background_thread(): try: loop.run_until_complete(protocol.returncode) finally: - pending = asyncio.Task.all_tasks(loop) - loop.run_until_complete(asyncio.gather(*pending)) - - loop.close() + try: + # Cancel all remaining tasks. + pending = asyncio.Task.all_tasks(loop) + for task in pending: + task.cancel() + + loop.run_until_complete(asyncio.gather(*pending, loop=loop, return_exceptions=True)) + + for task in pending: + if task.cancelled(): + continue + + if task.exception() is not None: + loop.call_exception_handler({ + "message": "unhandled exception during SimpleEngine shutdown", + "exception": task.exception(), + "task": task, + }) + + # Shutdown async generators. + try: + loop.run_until_complete(loop.shutdown_asyncgens()) + except NameError: + pass # < Python 3.6 + finally: + loop.close() threading.Thread(target=background_thread).start() From b753b3f0225870fc5cde01c80b7d08583ba97b69 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 27 Dec 2018 17:47:59 +0100 Subject: [PATCH 0046/1451] factor out run_in_background --- chess/engine.py | 225 +++++++++++++++++++++++++----------------------- 1 file changed, 117 insertions(+), 108 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index fb255eca3..1a41270ac 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -7,10 +7,122 @@ import sys import threading +try: + from asyncio import get_running_loop +except ImportError: + from asyncio import _get_running_loop as get_running_loop + LOGGER = logging.getLogger(__name__) +def setup_loop(): + """ + Creates and sets up a new asyncio event loop that is capable of spawning + and watching subprocesses. + + Uses polling to watch subprocesses when not running in the main thread. + + Note that this sets a global event loop policy. + """ + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + else: + asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy()) + + if sys.platform == "win32" or threading.current_thread() == threading.main_thread(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop + + class PollingChildWatcher(asyncio.SafeChildWatcher): + def __init__(self): + super().__init__() + self._poll_handle = None + + def attach_loop(self, loop): + assert loop is None or isinstance(loop, asyncio.AbstractEventLoop) + + if self._loop is not None and loop is None and self._callbacks: + warnings.warn("A loop is being detached from a child watcher with pending handlers", RuntimeWarning) + + if self._poll_handle is not None: + self._poll_handle.cancel() + + self._loop = loop + if loop is not None: + self._poll_handle = self._loop.call_soon(self._poll) + + # Prevent a race condition in case a child terminated + # during the switch. + self._do_waitpid_all() + + def _poll(self): + if self._loop: + self._do_waitpid_all() + self._poll_handle = self._loop.call_later(1.0, self._poll) + + policy = asyncio.get_event_loop_policy() + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + watcher = PollingChildWatcher() + watcher.attach_loop(loop) + policy.set_child_watcher(watcher) + + return loop + + +def run_in_background(coroutine): + """ + Runs ``coroutine(future)`` in a new event loop on a background thread. + + Returns the future result as soon as it is resolved. The coroutine + continues running in the background until it is complete. + """ + future = concurrent.futures.Future() + + def background(): + loop = setup_loop() + + try: + loop.run_until_complete(coroutine(future)) + future.cancel() + except Exception as exc: + future.set_exception(exc) + return + finally: + try: + # Cancel all remaining tasks. + pending = asyncio.Task.all_tasks(loop) + for task in pending: + task.cancel() + + loop.run_until_complete(asyncio.gather(*pending, loop=loop, return_exceptions=True)) + + for task in pending: + if task.cancelled(): + continue + + if task.exception() is not None: + loop.call_exception_handler({ + "message": "unhandled exception during SimpleEngine shutdown", + "exception": task.exception(), + "task": task, + }) + + # Shutdown async generators. + try: + loop.run_until_complete(loop.shutdown_asyncgens()) + except AttributeError: + pass # < Python 3.6 + finally: + loop.close() + + threading.Thread(target=background).start() + return future.result() + + class EngineTerminatedError(RuntimeError): pass @@ -401,69 +513,6 @@ async def popen_uci(cmd): return transport, protocol -def setup_loop(): - """ - Creates and sets up a new asyncio event loop that is capable of spawning - and watching subprocesses. - - Uses polling to watch subprocesses when not running in the main thread. - - Note that this sets a global event loop policy. - """ - if sys.platform == "win32": - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - else: - asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy()) - - if sys.platform == "win32" or threading.current_thread() == threading.main_thread(): - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - return loop - - class PollingChildWatcher(asyncio.SafeChildWatcher): - def __init__(self): - super().__init__() - self._poll_handle = None - - def attach_loop(self, loop): - assert loop is None or isinstance(loop, asyncio.AbstractEventLoop) - - if self._loop is not None and loop is None and self._callbacks: - warnings.warn("A loop is being detached from a child watcher with pending handlers", RuntimeWarning) - - if self._poll_handle is not None: - self._poll_handle.cancel() - - self._loop = loop - if loop is not None: - self._poll_handle = self._loop.call_soon(self._poll) - - # Prevent a race condition in case a child terminated - # during the switch. - self._do_waitpid_all() - - def _poll(self): - if self._loop: - self._do_waitpid_all() - self._poll_handle = self._loop.call_later(1.0, self._poll) - - policy = asyncio.get_event_loop_policy() - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - watcher = PollingChildWatcher() - watcher.attach_loop(loop) - policy.set_child_watcher(watcher) - - return loop - - -try: - from asyncio import get_running_loop -except ImportError: - from asyncio import _get_running_loop as get_running_loop - - class SimpleEngine: def __init__(self, transport, protocol, *, timeout=10.0): self.transport = transport @@ -478,52 +527,12 @@ def close(self): @classmethod def popen_uci(cls, cmd, *, timeout=10.0): - engine = concurrent.futures.Future() - - def background_thread(): - loop = setup_loop() - - try: - transport, protocol = loop.run_until_complete(asyncio.wait_for(popen_uci(cmd), timeout)) - except Exception as exc: - engine.set_exception(exc) - return + async def background(future): + transport, protocol = await asyncio.wait_for(popen_uci(cmd), timeout) + future.set_result(cls(transport, protocol, timeout=timeout)) + await protocol.returncode - engine.set_result(cls(transport, protocol, timeout=timeout)) - - try: - loop.run_until_complete(protocol.returncode) - finally: - try: - # Cancel all remaining tasks. - pending = asyncio.Task.all_tasks(loop) - for task in pending: - task.cancel() - - loop.run_until_complete(asyncio.gather(*pending, loop=loop, return_exceptions=True)) - - for task in pending: - if task.cancelled(): - continue - - if task.exception() is not None: - loop.call_exception_handler({ - "message": "unhandled exception during SimpleEngine shutdown", - "exception": task.exception(), - "task": task, - }) - - # Shutdown async generators. - try: - loop.run_until_complete(loop.shutdown_asyncgens()) - except NameError: - pass # < Python 3.6 - finally: - loop.close() - - threading.Thread(target=background_thread).start() - - return engine.result() + return run_in_background(background) async def async_main(): From c9f6f60a714b87ad0b584857f93eb633ae3b2c7f Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 27 Dec 2018 17:52:22 +0100 Subject: [PATCH 0047/1451] tweak docs --- chess/engine.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 1a41270ac..4ba27e725 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -77,7 +77,7 @@ def run_in_background(coroutine): """ Runs ``coroutine(future)`` in a new event loop on a background thread. - Returns the future result as soon as it is resolved. The coroutine + Returns the *future* result as soon as it is resolved. The coroutine continues running in the background until it is complete. """ future = concurrent.futures.Future() @@ -106,7 +106,7 @@ def background(): if task.exception() is not None: loop.call_exception_handler({ - "message": "unhandled exception during SimpleEngine shutdown", + "message": "unhandled exception during background event loop shutdown", "exception": task.exception(), "task": task, }) From 4d70040241bd81b06b4e1401e63f657e42c870e4 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 27 Dec 2018 17:58:37 +0100 Subject: [PATCH 0048/1451] assert argument is coroutine function --- chess/engine.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 4ba27e725..466ef9def 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -77,9 +77,11 @@ def run_in_background(coroutine): """ Runs ``coroutine(future)`` in a new event loop on a background thread. - Returns the *future* result as soon as it is resolved. The coroutine - continues running in the background until it is complete. + Blocks and returns the *future* result as soon as it is resolved. + The coroutine continues running in the background until it is complete. """ + assert asyncio.iscoroutinefunction(coroutine) + future = concurrent.futures.Future() def background(): @@ -123,7 +125,11 @@ def background(): return future.result() -class EngineTerminatedError(RuntimeError): +class EngineError(RuntimeError): + pass + + +class EngineTerminatedError(EngineError): pass From f4dbc8104591b75312551e8509713fd5c18a1fa6 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 27 Dec 2018 18:02:21 +0100 Subject: [PATCH 0049/1451] setup_loop -> setup_event_loop --- chess/engine.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 466ef9def..3ce0b268b 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -16,7 +16,7 @@ LOGGER = logging.getLogger(__name__) -def setup_loop(): +def setup_event_loop(): """ Creates and sets up a new asyncio event loop that is capable of spawning and watching subprocesses. @@ -85,7 +85,7 @@ def run_in_background(coroutine): future = concurrent.futures.Future() def background(): - loop = setup_loop() + loop = setup_event_loop() try: loop.run_until_complete(coroutine(future)) From 3fb5ba9a4077fcd26db2739082c8500f981de365 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 27 Dec 2018 18:28:43 +0100 Subject: [PATCH 0050/1451] do not cancel remaining tasks --- chess/engine.py | 41 ++++++++++++++++------------------------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 3ce0b268b..6fed404a0 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -95,11 +95,8 @@ def background(): return finally: try: - # Cancel all remaining tasks. + # Complete all remaining tasks. pending = asyncio.Task.all_tasks(loop) - for task in pending: - task.cancel() - loop.run_until_complete(asyncio.gather(*pending, loop=loop, return_exceptions=True)) for task in pending: @@ -185,22 +182,22 @@ def pipe_data_received(self, fd, data): line, self.buffer[fd] = self.buffer[fd].split(b"\n", 1) line = line.decode("utf-8") if fd == 1: - self.line_received(line) + self._line_received(line) else: self.error_line_received(line) def error_line_received(self, line): LOGGER.warning("%s: stderr >> %s", self, line) - def line_received(self, line): + def _line_received(self, line): LOGGER.debug("%s: >> %s", self, line) - self._line_received(line) + self.line_received(line) if self.command: self.command._line_received(self, line) - def _line_received(self, line): + def line_received(self, line): pass async def communicate(self, command): @@ -208,7 +205,7 @@ async def communicate(self, command): raise EngineTerminatedError("engine process dead (exit code: {})".format(self.returncode.result())) if command.state != CommandState.New: - raise RuntimeError("command with invalid state passed to communicate") + raise EngineError("command with invalid state passed to communicate") if self.next_command is not None: self.next_command.result.cancel() @@ -258,7 +255,7 @@ def __init__(self, loop=None): self.finished = self.loop.create_future() def _engine_terminated(self, engine, code): - exc = EngineTerminatedError("engine process died while running {} (exit code: {})".format(repr(self), code)) + exc = EngineTerminatedError("engine process died while running {} (exit code: {})".format(type(self).__name__, code)) if not self.result.done(): self.result.set_exception(exc) @@ -317,7 +314,7 @@ def __init__(self): self.options = UciOptionMap() self.config = UciOptionMap() - def _line_received(self, line): + def line_received(self, line): command_and_args = line.split(None, 1) if len(command_and_args) >= 2: if command_and_args[0] == "option": @@ -392,8 +389,8 @@ def _option(self, arg): option = Option(" ".join(name), type, default, min, max, var) self.options[option.name] = option - async def isready(self): - return await self.communicate(_IsReady()) + async def ping(self): + return await self.communicate(UciPing()) async def configure(self, config): return await self.communicate(UciConfigure(config)) @@ -480,14 +477,12 @@ def start(self, engine): self.set_finished() -class _IsReady(BaseCommand): +class UciPing(BaseCommand): def start(self, engine): engine.send_line("isready") def line_received(self, engine, line): if line == "readyok": - if not self.result.cancelled(): - self.result.set_result(None) self.set_finished() else: LOGGER.warning("%s: Unexpected engine output: %s", engine, line) @@ -525,8 +520,8 @@ def __init__(self, transport, protocol, *, timeout=10.0): self.protocol = protocol self.timeout = timeout - def isready(self): - return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.isready(), self.timeout), self.protocol.loop).result() + def ping(self): + return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.ping(), self.timeout), self.protocol.loop).result() def close(self): self.transport.close() @@ -558,13 +553,9 @@ async def async_main(): def main(): - engine_a = SimpleEngine.popen_uci(sys.argv[1]) - engine_a.isready() - - engine_b = SimpleEngine.popen_uci(sys.argv[1]) - engine_b.isready() - - engine_a.close() + engine = SimpleEngine.popen_uci(sys.argv[1]) + engine.ping() + engine.close() if __name__ == "__main__": From 729aebcaf89db8d81c776f9a516b514bd08fa8b2 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 27 Dec 2018 18:58:35 +0100 Subject: [PATCH 0051/1451] work on exception handling --- chess/engine.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 6fed404a0..dc88fa798 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -205,7 +205,7 @@ async def communicate(self, command): raise EngineTerminatedError("engine process dead (exit code: {})".format(self.returncode.result())) if command.state != CommandState.New: - raise EngineError("command with invalid state passed to communicate") + raise RuntimeError("command with invalid state passed to communicate") if self.next_command is not None: self.next_command.result.cancel() @@ -256,7 +256,12 @@ def __init__(self, loop=None): def _engine_terminated(self, engine, code): exc = EngineTerminatedError("engine process died while running {} (exit code: {})".format(type(self).__name__, code)) + self._handle_exception(exc) + if self.state in [CommandState.Active, CommandState.Cancelling]: + self.engine_terminated(engine, exc) + + def _handle_exception(self, exc): if not self.result.done(): self.result.set_exception(exc) if not self.finished.done(): @@ -268,9 +273,6 @@ def _engine_terminated(self, engine, code): except: pass - if self.state in [CommandState.Active, CommandState.Cancelling]: - self.engine_terminated(engine, exc) - def set_finished(self): assert self.state in [CommandState.Active, CommandState.Cancelling] if not self.result.done(): @@ -285,7 +287,10 @@ def _cancel(self, engine): def _start(self, engine): assert self.state == CommandState.New self.state = CommandState.Active - self.start(engine) + try: + self.start(engine) + except EngineError as err: + self._handle_exception(err) def _done(self): assert self.state != CommandState.Done @@ -456,11 +461,11 @@ def __init__(self, options): def start(self, engine): for name, value in self.options.items(): if name not in engine.options: - raise ValueError("engine does not support option: {}".format(name)) + raise EngineError("engine does not support option: {}".format(name)) elif name.lower() == "uci_chess960": - raise ValueError("cannot set UCI_Chess960 which is automatically managed") + raise EngineError("cannot set UCI_Chess960 which is automatically managed") elif name.lower() == "uci_variant": - raise ValueError("cannot set UCI_Variant which is automatically managed") + raise EngineError("cannot set UCI_Variant which is automatically managed") builder = ["setoption name", name, "value"] if value is True: @@ -520,6 +525,9 @@ def __init__(self, transport, protocol, *, timeout=10.0): self.protocol = protocol self.timeout = timeout + def configure(self, config): + return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.configure(config), self.timeout), self.protocol.loop).result() + def ping(self): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.ping(), self.timeout), self.protocol.loop).result() @@ -555,6 +563,12 @@ async def async_main(): def main(): engine = SimpleEngine.popen_uci(sys.argv[1]) engine.ping() + try: + engine.configure({ + "Contempt": 40, + }) + except: + print("Flow ... !!!!!!!!!!!!!!!!!!!!!!!!!!") engine.close() From f71c5ccd68e496c900ec139fd7cfed15d131793d Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 27 Dec 2018 19:08:26 +0100 Subject: [PATCH 0052/1451] initialize commands on loop --- chess/engine.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index dc88fa798..e9e6a50d1 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -247,10 +247,10 @@ class CommandState(enum.Enum): class BaseCommand: - def __init__(self, loop=None): + def __init__(self, loop): self.state = CommandState.New - self.loop = loop or get_running_loop() + self.loop = loop self.result = self.loop.create_future() self.finished = self.loop.create_future() @@ -395,10 +395,10 @@ def _option(self, arg): self.options[option.name] = option async def ping(self): - return await self.communicate(UciPing()) + return await self.communicate(UciPing(self.loop)) - async def configure(self, config): - return await self.communicate(UciConfigure(config)) + def configure(self, config): + return self.communicate(UciConfigure(self.loop, config)) class UciOptionMap(collections.abc.MutableMapping): @@ -454,13 +454,13 @@ def line_received(self, engine, line): class UciConfigure(BaseCommand): - def __init__(self, options): - super().__init__() - self.options = options + def __init__(self, loop, config): + super().__init__(loop) + self.config = config def start(self, engine): - for name, value in self.options.items(): - if name not in engine.options: + for name, value in self.config.items(): + if name not in engine.config: raise EngineError("engine does not support option: {}".format(name)) elif name.lower() == "uci_chess960": raise EngineError("cannot set UCI_Chess960 which is automatically managed") @@ -515,7 +515,7 @@ def cancel(self, engine): async def popen_uci(cmd): loop = get_running_loop() transport, protocol = await loop.subprocess_shell(UciProtocol, cmd) - await protocol.communicate(UciInit()) + await protocol.communicate(UciInit(get_running_loop())) return transport, protocol @@ -565,10 +565,10 @@ def main(): engine.ping() try: engine.configure({ - "Contempt": 40, + "ContemptB": 40, }) except: - print("Flow ... !!!!!!!!!!!!!!!!!!!!!!!!!!") + engine.close() engine.close() From 0d15e596621db60ab500f8c517eb2a0bf38207f9 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 27 Dec 2018 20:33:38 +0100 Subject: [PATCH 0053/1451] no need to log unhandled exceptions --- chess/engine.py | 48 +++++++++++++++++++----------------------------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index e9e6a50d1..9707d011f 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -95,20 +95,9 @@ def background(): return finally: try: - # Complete all remaining tasks. + # Finish all remaining tasks. pending = asyncio.Task.all_tasks(loop) - loop.run_until_complete(asyncio.gather(*pending, loop=loop, return_exceptions=True)) - - for task in pending: - if task.cancelled(): - continue - - if task.exception() is not None: - loop.call_exception_handler({ - "message": "unhandled exception during background event loop shutdown", - "exception": task.exception(), - "task": task, - }) + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) # Shutdown async generators. try: @@ -397,8 +386,8 @@ def _option(self, arg): async def ping(self): return await self.communicate(UciPing(self.loop)) - def configure(self, config): - return self.communicate(UciConfigure(self.loop, config)) + async def configure(self, config): + return await self.communicate(UciConfigure(self.loop, config)) class UciOptionMap(collections.abc.MutableMapping): @@ -545,30 +534,31 @@ async def background(future): async def async_main(): - import chess - - #transport, engine = await popen_uci("./engine.sh") - transport, engine = await popen_uci("stockfish") - - await engine.configure({ - "Contempt": 20, - "ContemptA": 20, - }) - - print(engine.options) + transport, engine = await popen_uci(sys.argv[1]) + await engine.ping() + try: + await engine.configure({ + "ContemptB": 40, + }) + except EngineError: + print("exception") + await engine.ping() + transport.close() await engine.returncode def main(): engine = SimpleEngine.popen_uci(sys.argv[1]) - engine.ping() + try: engine.configure({ "ContemptB": 40, }) - except: - engine.close() + except EngineError: + print("exception in configure") + + engine.ping() engine.close() From 4b5e7a4ee4b7d992d54a73a06737c802878b6930 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 27 Dec 2018 20:57:20 +0100 Subject: [PATCH 0054/1451] use simple engine as context manager --- chess/engine.py | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 9707d011f..2c83779c9 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -193,8 +193,7 @@ async def communicate(self, command): if self.returncode.done(): raise EngineTerminatedError("engine process dead (exit code: {})".format(self.returncode.result())) - if command.state != CommandState.New: - raise RuntimeError("command with invalid state passed to communicate") + assert command.state == CommandState.New if self.next_command is not None: self.next_command.result.cancel() @@ -482,10 +481,11 @@ def line_received(self, engine, line): LOGGER.warning("%s: Unexpected engine output: %s", engine, line) -class _Go(BaseCommand): - def __init__(self, board): - super().__init__() +class UciPlay(BaseCommand): + def __init__(self, loop, board, movetime=None): + super().__init__(loop) self.fen = board.fen() + self.movetime = movetime def start(self, engine): engine.send_line("position fen {}".format(self.fen)) @@ -532,6 +532,12 @@ async def background(future): return run_in_background(background) + def __enter__(self): + return self + + def __exit__(self, a, b, c): + self.close() + async def async_main(): transport, engine = await popen_uci(sys.argv[1]) @@ -549,17 +555,13 @@ async def async_main(): def main(): - engine = SimpleEngine.popen_uci(sys.argv[1]) - - try: - engine.configure({ - "ContemptB": 40, - }) - except EngineError: - print("exception in configure") - - engine.ping() - engine.close() + with SimpleEngine.popen_uci(sys.argv[1]) as engine: + try: + engine.configure({ + "ContemptB": 40, + }) + except EngineError: + print("exception in configure") if __name__ == "__main__": From 2823c68af7a05e270795f0180a5b03543c915ada Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 28 Dec 2018 20:39:50 +0100 Subject: [PATCH 0055/1451] reimplement setpgrp --- chess/engine.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 2c83779c9..81bc47373 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -4,8 +4,10 @@ import enum import collections import warnings +import subprocess import sys import threading +import os try: from asyncio import get_running_loop @@ -501,9 +503,29 @@ def cancel(self, engine): engine.send_line("stop") -async def popen_uci(cmd): +async def popen_uci(command, *, setpgrp=False, **kwargs): + """ + Opens an UCI engine. + + :param setpgrp: Open the engine process in a new process group. This will + stop signals (such as keyboard interrupts) from propagating from the + parent process. Defaults to ``False``. + """ + if not isinstance(command, list): + command = [command] + + popen_args = {} + if setpgrp: + try: + # Windows. + popen_args["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + except AttributeError: + # Unix. + popen_args["preexec_fn"] = os.setpgrp + popen_args.update(kwargs) + loop = get_running_loop() - transport, protocol = await loop.subprocess_shell(UciProtocol, cmd) + transport, protocol = await loop.subprocess_exec(UciProtocol, *command, **popen_args) await protocol.communicate(UciInit(get_running_loop())) return transport, protocol @@ -524,9 +546,9 @@ def close(self): self.transport.close() @classmethod - def popen_uci(cls, cmd, *, timeout=10.0): + def popen_uci(cls, command, *, timeout=10.0, **popen_args): async def background(future): - transport, protocol = await asyncio.wait_for(popen_uci(cmd), timeout) + transport, protocol = await asyncio.wait_for(popen_uci(command, **popen_args), timeout) future.set_result(cls(transport, protocol, timeout=timeout)) await protocol.returncode @@ -555,7 +577,7 @@ async def async_main(): def main(): - with SimpleEngine.popen_uci(sys.argv[1]) as engine: + with SimpleEngine.popen_uci(sys.argv[1], setpgrp=True) as engine: try: engine.configure({ "ContemptB": 40, From afdf4d22000b2680e1825d3117b13515c9eed022 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 28 Dec 2018 21:33:18 +0100 Subject: [PATCH 0056/1451] group commands under the respective functions --- chess/engine.py | 146 ++++++++++++++++++++++++------------------------ 1 file changed, 72 insertions(+), 74 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 81bc47373..39c4e6b04 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -191,7 +191,9 @@ def _line_received(self, line): def line_received(self, line): pass - async def communicate(self, command): + async def communicate(self, command_factory): + command = command_factory(self.loop) + if self.returncode.done(): raise EngineTerminatedError("engine process dead (exit code: {})".format(self.returncode.result())) @@ -385,10 +387,67 @@ def _option(self, arg): self.options[option.name] = option async def ping(self): - return await self.communicate(UciPing(self.loop)) + class Command(BaseCommand): + def start(self, engine): + engine.send_line("isready") + + def line_received(self, engine, line): + if line == "readyok": + self.set_finished() + else: + LOGGER.warning("%s: Unexpected engine output: %s", engine, line) + + return await self.communicate(Command) + async def configure(self, config): - return await self.communicate(UciConfigure(self.loop, config)) + class Command(BaseCommand): + def start(self, engine): + for name, value in config.items(): + if name not in engine.config: + raise EngineError("engine does not support option: {}".format(name)) + elif name.lower() == "uci_chess960": + raise EngineError("cannot set UCI_Chess960 which is automatically managed") + elif name.lower() == "uci_variant": + raise EngineError("cannot set UCI_Variant which is automatically managed") + + builder = ["setoption name", name, "value"] + if value is True: + builder.append("true") + elif value is False: + builder.append("false") + elif value is None: + builder.append("none") + else: + builder.append(str(value)) + + engine.send_line(" ".join(builder)) + + self.set_finished() + + return await self.communicate(Command) + + async def play(self): + class Command(BaseCommand): + def __init__(self, loop, board, movetime=None): + super().__init__(loop) + self.fen = board.fen() + self.movetime = movetime + + def start(self, engine): + engine.send_line("position fen {}".format(self.fen)) + engine.send_line("go movetime 1000") + + def line_received(self, engine, line): + if line.startswith("bestmove "): + if not self.result.cancelled(): + self.result.set_result(line) + self.set_finished() + + def cancel(self, engine): + engine.send_line("stop") + + return await self.communicate(Command) class UciOptionMap(collections.abc.MutableMapping): @@ -434,75 +493,6 @@ def __repr__(self): return "{}({})".format(type(self).__name__, dict(self.items())) -class UciInit(BaseCommand): - def start(self, engine): - engine.send_line("uci") - - def line_received(self, engine, line): - if line == "uciok": - self.set_finished() - - -class UciConfigure(BaseCommand): - def __init__(self, loop, config): - super().__init__(loop) - self.config = config - - def start(self, engine): - for name, value in self.config.items(): - if name not in engine.config: - raise EngineError("engine does not support option: {}".format(name)) - elif name.lower() == "uci_chess960": - raise EngineError("cannot set UCI_Chess960 which is automatically managed") - elif name.lower() == "uci_variant": - raise EngineError("cannot set UCI_Variant which is automatically managed") - - builder = ["setoption name", name, "value"] - if value is True: - builder.append("true") - elif value is False: - builder.append("false") - elif value is None: - builder.append("none") - else: - builder.append(str(value)) - - engine.send_line(" ".join(builder)) - - self.set_finished() - - -class UciPing(BaseCommand): - def start(self, engine): - engine.send_line("isready") - - def line_received(self, engine, line): - if line == "readyok": - self.set_finished() - else: - LOGGER.warning("%s: Unexpected engine output: %s", engine, line) - - -class UciPlay(BaseCommand): - def __init__(self, loop, board, movetime=None): - super().__init__(loop) - self.fen = board.fen() - self.movetime = movetime - - def start(self, engine): - engine.send_line("position fen {}".format(self.fen)) - engine.send_line("go movetime 1000") - - def line_received(self, engine, line): - if line.startswith("bestmove "): - if not self.result.cancelled(): - self.result.set_result(line) - self.set_finished() - - def cancel(self, engine): - engine.send_line("stop") - - async def popen_uci(command, *, setpgrp=False, **kwargs): """ Opens an UCI engine. @@ -524,9 +514,17 @@ async def popen_uci(command, *, setpgrp=False, **kwargs): popen_args["preexec_fn"] = os.setpgrp popen_args.update(kwargs) + class InitCommand(BaseCommand): + def start(self, engine): + engine.send_line("uci") + + def line_received(self, engine, line): + if line == "uciok": + self.set_finished() + loop = get_running_loop() transport, protocol = await loop.subprocess_exec(UciProtocol, *command, **popen_args) - await protocol.communicate(UciInit(get_running_loop())) + await protocol.communicate(InitCommand) return transport, protocol @@ -580,7 +578,7 @@ def main(): with SimpleEngine.popen_uci(sys.argv[1], setpgrp=True) as engine: try: engine.configure({ - "ContemptB": 40, + "Contempt": 40, }) except EngineError: print("exception in configure") From ec360f21fa5327bb838baf4a22d232ece810ffa0 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 28 Dec 2018 21:55:32 +0100 Subject: [PATCH 0057/1451] factor out engine initialization --- chess/engine.py | 160 ++++++++++++++++++++++++------------------------ 1 file changed, 81 insertions(+), 79 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 39c4e6b04..ae4ec9c6e 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -114,11 +114,11 @@ def background(): class EngineError(RuntimeError): - pass + """Runtime error caused by a misbehaving engine or incorrect usage.""" class EngineTerminatedError(EngineError): - pass + """The engine process exited unexpectedly.""" class Option(collections.namedtuple("Option", "name type default min max var")): @@ -311,80 +311,89 @@ def __init__(self): self.options = UciOptionMap() self.config = UciOptionMap() - def line_received(self, line): - command_and_args = line.split(None, 1) - if len(command_and_args) >= 2: - if command_and_args[0] == "option": - self._option(command_and_args[1]) - - def _option(self, arg): - current_parameter = None - - name = [] - type = [] - default = [] - min = None - max = None - current_var = None - var = [] - - for token in arg.split(" "): - if token == "name" and not name: - current_parameter = "name" - elif token == "type" and not type: - current_parameter = "type" - elif token == "default" and not default: - current_parameter = "default" - elif token == "min" and min is None: - current_parameter = "min" - elif token == "max" and max is None: - current_parameter = "max" - elif token == "var": - current_parameter = "var" + async def _initialize(self): + protocol = self + + class Command(BaseCommand): + def start(self, engine): + engine.send_line("uci") + + def line_received(self, engine, line): + if line == "uciok": + self.set_finished() + elif line.startswith("option "): + self._option(line.split(" ", 1)[1]) + + def _option(self, arg): + current_parameter = None + + name = [] + type = [] + default = [] + min = None + max = None + current_var = None + var = [] + + for token in arg.split(" "): + if token == "name" and not name: + current_parameter = "name" + elif token == "type" and not type: + current_parameter = "type" + elif token == "default" and not default: + current_parameter = "default" + elif token == "min" and min is None: + current_parameter = "min" + elif token == "max" and max is None: + current_parameter = "max" + elif token == "var": + current_parameter = "var" + if current_var is not None: + var.append(" ".join(current_var)) + current_var = [] + elif current_parameter == "name": + name.append(token) + elif current_parameter == "type": + type.append(token) + elif current_parameter == "default": + default.append(token) + elif current_parameter == "var": + current_var.append(token) + elif current_parameter == "min": + try: + min = int(token) + except ValueError: + LOGGER.exception("exception parsing option min") + elif current_parameter == "max": + try: + max = int(token) + except ValueError: + LOGGER.exception("exception parsing option max") + if current_var is not None: var.append(" ".join(current_var)) - current_var = [] - elif current_parameter == "name": - name.append(token) - elif current_parameter == "type": - type.append(token) - elif current_parameter == "default": - default.append(token) - elif current_parameter == "var": - current_var.append(token) - elif current_parameter == "min": - try: - min = int(token) - except ValueError: - LOGGER.exception("exception parsing option min") - elif current_parameter == "max": - try: - max = int(token) - except ValueError: - LOGGER.exception("exception parsing option max") - if current_var is not None: - var.append(" ".join(current_var)) + type = " ".join(type) - type = " ".join(type) + default = " ".join(default) + if type == "check": + if default == "true": + default = True + elif default == "false": + default = False + else: + default = None + elif type == "spin": + try: + default = int(default) + except ValueError: + LOGGER.exception("exception parsing option spin default") + default = None - default = " ".join(default) - if type == "check": - if default == "true": - default = True - elif default == "false": - default = False - else: - default = None - elif type == "spin": - try: - default = int(default) - except ValueError: - LOGGER.exception("exception parsing option spin default") - default = None + option = Option(" ".join(name), type, default, min, max, var) + protocol.options[option.name] = option - option = Option(" ".join(name), type, default, min, max, var) - self.options[option.name] = option + return await self.communicate(Command) async def ping(self): class Command(BaseCommand): @@ -514,17 +523,9 @@ async def popen_uci(command, *, setpgrp=False, **kwargs): popen_args["preexec_fn"] = os.setpgrp popen_args.update(kwargs) - class InitCommand(BaseCommand): - def start(self, engine): - engine.send_line("uci") - - def line_received(self, engine, line): - if line == "uciok": - self.set_finished() - loop = get_running_loop() transport, protocol = await loop.subprocess_exec(UciProtocol, *command, **popen_args) - await protocol.communicate(InitCommand) + await protocol._initialize() return transport, protocol @@ -576,6 +577,7 @@ async def async_main(): def main(): with SimpleEngine.popen_uci(sys.argv[1], setpgrp=True) as engine: + print(engine.protocol.options) try: engine.configure({ "Contempt": 40, From 2232b8c817677873d2b6c6babfcf43d15ff69190 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 28 Dec 2018 22:19:08 +0100 Subject: [PATCH 0058/1451] make popen_uci a classmethod --- chess/engine.py | 58 ++++++++++++++++++++++++------------------------- 1 file changed, 28 insertions(+), 30 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index ae4ec9c6e..6ec7ce215 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -105,7 +105,8 @@ def background(): try: loop.run_until_complete(loop.shutdown_asyncgens()) except AttributeError: - pass # < Python 3.6 + # Before Python 3.6. + pass finally: loop.close() @@ -230,6 +231,26 @@ def __repr__(self): pid = self.transport.get_pid() if self.transport is not None else None return "<{} at {} (pid={})>".format(type(self).__name__, hex(id(self)), pid) + @classmethod + async def popen(cls, command, *, setpgrp=False, **kwargs): + if not isinstance(command, list): + command = [command] + + popen_args = {} + if setpgrp: + try: + # Windows. + popen_args["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + except AttributeError: + # Unix. + popen_args["preexec_fn"] = os.setpgrp + popen_args.update(kwargs) + + loop = get_running_loop() + transport, protocol = await loop.subprocess_exec(cls, *command, **popen_args) + await protocol._initialize() + return transport, protocol + class CommandState(enum.Enum): New = 1 @@ -296,7 +317,7 @@ def cancel(self, engine): pass def start(self, engine): - pass + raise NotImplementedError def line_received(self, engine, line): pass @@ -408,8 +429,9 @@ def line_received(self, engine, line): return await self.communicate(Command) - async def configure(self, config): + protocol = self + class Command(BaseCommand): def start(self, engine): for name, value in config.items(): @@ -431,6 +453,7 @@ def start(self, engine): builder.append(str(value)) engine.send_line(" ".join(builder)) + protocol.config[name] = value self.set_finished() @@ -502,31 +525,6 @@ def __repr__(self): return "{}({})".format(type(self).__name__, dict(self.items())) -async def popen_uci(command, *, setpgrp=False, **kwargs): - """ - Opens an UCI engine. - - :param setpgrp: Open the engine process in a new process group. This will - stop signals (such as keyboard interrupts) from propagating from the - parent process. Defaults to ``False``. - """ - if not isinstance(command, list): - command = [command] - - popen_args = {} - if setpgrp: - try: - # Windows. - popen_args["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP - except AttributeError: - # Unix. - popen_args["preexec_fn"] = os.setpgrp - popen_args.update(kwargs) - - loop = get_running_loop() - transport, protocol = await loop.subprocess_exec(UciProtocol, *command, **popen_args) - await protocol._initialize() - return transport, protocol class SimpleEngine: @@ -547,7 +545,7 @@ def close(self): @classmethod def popen_uci(cls, command, *, timeout=10.0, **popen_args): async def background(future): - transport, protocol = await asyncio.wait_for(popen_uci(command, **popen_args), timeout) + transport, protocol = await asyncio.wait_for(UciProtocol.popen(command, **popen_args), timeout) future.set_result(cls(transport, protocol, timeout=timeout)) await protocol.returncode @@ -561,7 +559,7 @@ def __exit__(self, a, b, c): async def async_main(): - transport, engine = await popen_uci(sys.argv[1]) + transport, engine = await UciProtocol.popen(sys.argv[1]) await engine.ping() try: await engine.configure({ From 6e43743c6031cdb55307b4a304d135a78c3181f2 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 29 Dec 2018 16:53:37 +0100 Subject: [PATCH 0059/1451] update UciOptionMapTestCase --- chess/engine.py | 4 ++-- test.py | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 6ec7ce215..c344ee0ab 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -483,6 +483,8 @@ def cancel(self, engine): class UciOptionMap(collections.abc.MutableMapping): + """Dictionary with case-insensitive keys.""" + def __init__(self, data=None, **kwargs): self._store = dict() if data is None: @@ -525,8 +527,6 @@ def __repr__(self): return "{}({})".format(type(self).__name__, dict(self.items())) - - class SimpleEngine: def __init__(self, transport, protocol, *, timeout=10.0): self.transport = transport diff --git a/test.py b/test.py index 6639a3218..d1c22f874 100755 --- a/test.py +++ b/test.py @@ -31,6 +31,7 @@ import chess import chess.gaviota +import chess.engine import chess.pgn import chess.polyglot import chess.svg @@ -3027,9 +3028,9 @@ def test_hakkapeliitta_double_spaces(self): class UciOptionMapTestCase(unittest.TestCase): def test_equality(self): - a = chess.uci.OptionMap() - b = chess.uci.OptionMap() - c = chess.uci.OptionMap() + a = chess.engine.UciOptionMap() + b = chess.engine.UciOptionMap() + c = chess.engine.UciOptionMap() self.assertEqual(a, b) a["fOO"] = "bAr" From 799aeb1f223c9be81af8b92651b3ad32e1facf5f Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 29 Dec 2018 17:10:02 +0100 Subject: [PATCH 0060/1451] implement UciProtocol._position --- chess/engine.py | 79 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 59 insertions(+), 20 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index c344ee0ab..ec6e2f10d 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -333,8 +333,6 @@ def __init__(self): self.config = UciOptionMap() async def _initialize(self): - protocol = self - class Command(BaseCommand): def start(self, engine): engine.send_line("uci") @@ -343,9 +341,9 @@ def line_received(self, engine, line): if line == "uciok": self.set_finished() elif line.startswith("option "): - self._option(line.split(" ", 1)[1]) + self._option(engine, line.split(" ", 1)[1]) - def _option(self, arg): + def _option(self, engine, arg): current_parameter = None name = [] @@ -412,14 +410,17 @@ def _option(self, arg): default = None option = Option(" ".join(name), type, default, min, max, var) - protocol.options[option.name] = option + engine.options[option.name] = option return await self.communicate(Command) + def _isready(self): + self.send_line("isready") + async def ping(self): class Command(BaseCommand): def start(self, engine): - engine.send_line("isready") + engine._isready() def line_received(self, engine, line): if line == "readyok": @@ -429,9 +430,21 @@ def line_received(self, engine, line): return await self.communicate(Command) - async def configure(self, config): - protocol = self + def _setoption(self, name, value): + builder = ["setoption name", name, "value"] + if value is True: + builder.append("true") + elif value is False: + builder.append("false") + elif value is None: + builder.append("none") + else: + builder.append(str(value)) + + self.send_line(" ".join(builder)) + self.config[name] = value + async def configure(self, config): class Command(BaseCommand): def start(self, engine): for name, value in config.items(): @@ -441,24 +454,50 @@ def start(self, engine): raise EngineError("cannot set UCI_Chess960 which is automatically managed") elif name.lower() == "uci_variant": raise EngineError("cannot set UCI_Variant which is automatically managed") - - builder = ["setoption name", name, "value"] - if value is True: - builder.append("true") - elif value is False: - builder.append("false") - elif value is None: - builder.append("none") else: - builder.append(str(value)) - - engine.send_line(" ".join(builder)) - protocol.config[name] = value + engine._setoption(name, value) self.set_finished() return await self.communicate(Command) + def _get_config(self, option, default=None): + if option in self.config: + return self.config[option] + if option in self.options: + return self.options[option].default + return default + + def _position(self, board): + # Select UCI_Variant and UCI_Chess960. + uci_variant = type(board).uci_variant + if uci_variant != self._get_config("UCI_Variant", "chess"): + if "UCI_Variant" not in self.options: + raise EngineError("engine does not support UCI_Variant") + self._setoption("UCI_Variant", uci_variant) + + if board.chess960 != self._get_config("UCI_Chess960", False): + if "UCI_Chess960" not in self.options: + raise EngineError("engine does not support UCI_Chess960") + self._setoption("UCI_Chess960", board.chess960) + + # Send starting position. + builder = ["position"] + root = board.root() + fen = root.fen() + if uci_variant == "chess" and fen == chess.STARTING_FEN: + builder.append("startpos") + else: + builder.append("fen") + builder.append(root.shredder_fen() if board.chess960 else fen) + + # Send moves. + if board.move_stack: + builder.append("moves") + builder.extend(move.uci() for move in board.move_stack) + + self.send_line(" ".join(builder)) + async def play(self): class Command(BaseCommand): def __init__(self, loop, board, movetime=None): From e9c3277088a6ce02113af307e6adeccadc0948b4 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 29 Dec 2018 17:17:13 +0100 Subject: [PATCH 0061/1451] use UciProtocol._position in play --- chess/engine.py | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index ec6e2f10d..5da7fb542 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -14,6 +14,8 @@ except ImportError: from asyncio import _get_running_loop as get_running_loop +import chess + LOGGER = logging.getLogger(__name__) @@ -448,7 +450,7 @@ async def configure(self, config): class Command(BaseCommand): def start(self, engine): for name, value in config.items(): - if name not in engine.config: + if name not in engine.options: raise EngineError("engine does not support option: {}".format(name)) elif name.lower() == "uci_chess960": raise EngineError("cannot set UCI_Chess960 which is automatically managed") @@ -498,15 +500,10 @@ def _position(self, board): self.send_line(" ".join(builder)) - async def play(self): + async def play(self, board): class Command(BaseCommand): - def __init__(self, loop, board, movetime=None): - super().__init__(loop) - self.fen = board.fen() - self.movetime = movetime - def start(self, engine): - engine.send_line("position fen {}".format(self.fen)) + engine._position(board) engine.send_line("go movetime 1000") def line_received(self, engine, line): @@ -578,6 +575,9 @@ def configure(self, config): def ping(self): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.ping(), self.timeout), self.protocol.loop).result() + def play(self, board): + return asyncio.run_coroutine_threadsafe(self.protocol.play(board), self.protocol.loop).result() + def close(self): self.transport.close() @@ -615,12 +615,18 @@ async def async_main(): def main(): with SimpleEngine.popen_uci(sys.argv[1], setpgrp=True) as engine: print(engine.protocol.options) - try: - engine.configure({ - "Contempt": 40, - }) - except EngineError: - print("exception in configure") + + print("PING") + engine.ping() + print("PONG") + + engine.configure({ + "Contempt": 40, + }) + + board = chess.Board() + play_result = engine.play(board) + print("PLAYED", play_result) if __name__ == "__main__": From 03f76f71f9f1a634de600178cea00ad4526e2577 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 29 Dec 2018 17:20:04 +0100 Subject: [PATCH 0062/1451] update async_main --- chess/engine.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 5da7fb542..e77759d07 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -599,16 +599,18 @@ def __exit__(self, a, b, c): async def async_main(): transport, engine = await UciProtocol.popen(sys.argv[1]) + print(engine.options) + await engine.ping() - try: - await engine.configure({ - "ContemptB": 40, - }) - except EngineError: - print("exception") - await engine.ping() - transport.close() + await engine.configure({ + "Contempt": 40, + }) + + board = chess.Board() + play_result = await engine.play(board) + print("PLAYED ASYNC", play_result) + await engine.returncode @@ -631,5 +633,5 @@ def main(): if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) - main() - #asyncio.run(async_main()) + #main() + asyncio.run(async_main()) From 032eea7f90fe45c45fc130fa257ecfa9a2731abc Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 29 Dec 2018 17:23:37 +0100 Subject: [PATCH 0063/1451] add UciProtocol.quit() --- chess/engine.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index e77759d07..4cba2aea8 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -517,6 +517,10 @@ def cancel(self, engine): return await self.communicate(Command) + async def quit(self): + self.send_line("quit") + await self.returncode + class UciOptionMap(collections.abc.MutableMapping): """Dictionary with case-insensitive keys.""" @@ -578,6 +582,9 @@ def ping(self): def play(self, board): return asyncio.run_coroutine_threadsafe(self.protocol.play(board), self.protocol.loop).result() + def quit(self): + return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.quit(), self.timeout), self.protocol.loop).result() + def close(self): self.transport.close() @@ -611,7 +618,7 @@ async def async_main(): play_result = await engine.play(board) print("PLAYED ASYNC", play_result) - await engine.returncode + await engine.quit() def main(): @@ -630,8 +637,11 @@ def main(): play_result = engine.play(board) print("PLAYED", play_result) + engine.quit() + print("QUIT") + if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) - #main() - asyncio.run(async_main()) + main() + #asyncio.run(async_main()) From 5a880d61050b77eeeb9d5cf69c38b6b3dfb90f9f Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 29 Dec 2018 17:35:13 +0100 Subject: [PATCH 0064/1451] implement builder for go command --- chess/engine.py | 60 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index 4cba2aea8..103f93cdf 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -128,6 +128,12 @@ class Option(collections.namedtuple("Option", "name type default min max var")): """Information about an available engine option.""" +class PlayResult: + def __init__(self, move, ponder): + self.move = move + self.ponder = ponder + + class EngineProtocol(asyncio.SubprocessProtocol): def __init__(self): self.loop = get_running_loop() @@ -500,11 +506,63 @@ def _position(self, board): self.send_line(" ".join(builder)) + def _go(self, *, searchmoves=None, ponder=False, wtime=None, btime=None, winc=None, binc=None, movestogo=None, depth=None, nodes=None, mate=None, movetime=None, infinite=False): + builder = ["go"] + + if ponder: + builder.append("ponder") + + if wtime is not None: + builder.append("wtime") + builder.append(str(int(wtime))) + + if btime is not None: + builder.append("btime") + builder.append(str(int(btime))) + + if winc is not None: + builder.append("winc") + builder.append(str(int(winc))) + + if binc is not None: + builder.append("binc") + builder.append(str(int(binc))) + + if movestogo is not None and movestogo > 0: + builder.append("movestogo") + builder.append(str(int(movestogo))) + + if depth is not None: + builder.append("depth") + builder.append(str(int(depth))) + + if nodes is not None: + builder.append("nodes") + builder.append(str(int(nodes))) + + if mate is not None: + builder.append("mate") + builder.append(str(int(mate))) + + if movetime is not None: + builder.append("movetime") + builder.append(str(int(movetime))) + + if infinite: + builder.append("infinite") + + if searchmoves: + builder.append("searchmoves") + for move in searchmoves: + builder.append(move.uci()) + + self.send_line(" ".join(builder)) + async def play(self, board): class Command(BaseCommand): def start(self, engine): engine._position(board) - engine.send_line("go movetime 1000") + engine._go(movetime=1000) def line_received(self, engine, line): if line.startswith("bestmove "): From a44697e2cf51fa4ad520026492cd51c313a835dd Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 29 Dec 2018 17:56:16 +0100 Subject: [PATCH 0065/1451] return play result --- chess/engine.py | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 103f93cdf..d87e7113c 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -133,6 +133,9 @@ def __init__(self, move, ponder): self.move = move self.ponder = ponder + def __repr__(self): + return "<{} at {} (move={}, ponder={})>".format(type(self).__name__, hex(id(self)), self.move, self.ponder) + class EngineProtocol(asyncio.SubprocessProtocol): def __init__(self): @@ -339,6 +342,7 @@ def __init__(self): super().__init__() self.options = UciOptionMap() self.config = UciOptionMap() + self.board = chess.Board() async def _initialize(self): class Command(BaseCommand): @@ -505,6 +509,7 @@ def _position(self, board): builder.extend(move.uci() for move in board.move_stack) self.send_line(" ".join(builder)) + self.board = board.copy(stack=False) def _go(self, *, searchmoves=None, ponder=False, wtime=None, btime=None, winc=None, binc=None, movestogo=None, depth=None, nodes=None, mate=None, movetime=None, infinite=False): builder = ["go"] @@ -554,7 +559,7 @@ def _go(self, *, searchmoves=None, ponder=False, wtime=None, btime=None, winc=No if searchmoves: builder.append("searchmoves") for move in searchmoves: - builder.append(move.uci()) + builder.append(self.board.uci(move)) self.send_line(" ".join(builder)) @@ -566,8 +571,35 @@ def start(self, engine): def line_received(self, engine, line): if line.startswith("bestmove "): + self._bestmove(engine, line.split(" ", 1)[1]) + elif not line.startswith("info "): + LOGGER.warning("%s: Unexpected engine output: %s", engine, line) + + def _bestmove(self, engine, arg): + try: if not self.result.cancelled(): - self.result.set_result(line) + tokens = arg.split(None, 2) + + bestmove = None + if tokens[0] != "(none)": + try: + bestmove = engine.board.parse_uci(tokens[0]) + except ValueError as err: + self.result.set_exception(EngineError(err)) + return + + ponder = None + if bestmove is not None and len(tokens) >= 3 and tokens[1] == "ponder" and tokens[2] != "(none)": + board.push(bestmove) + try: + ponder = board.parse_uci(tokens[2]) + except ValueError as err: + LOGGER.exception("engine sent invalid ponder move") + finally: + board.pop() + + self.result.set_result(PlayResult(bestmove, ponder)) + finally: self.set_finished() def cancel(self, engine): From 163ea285cf55561c70f467cbfc16c9f97240da4a Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 29 Dec 2018 18:03:06 +0100 Subject: [PATCH 0066/1451] pid uniquely identifies EngineProtocol --- chess/engine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index d87e7113c..cd7826865 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -240,7 +240,7 @@ def previous_command_finished(_): def __repr__(self): pid = self.transport.get_pid() if self.transport is not None else None - return "<{} at {} (pid={})>".format(type(self).__name__, hex(id(self)), pid) + return "<{} (pid={})>".format(type(self).__name__, pid) @classmethod async def popen(cls, command, *, setpgrp=False, **kwargs): From 81080575fac9df56ee45443752306c1d93c765ca Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 29 Dec 2018 18:12:09 +0100 Subject: [PATCH 0067/1451] test playing an entire game --- chess/engine.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index cd7826865..1eb974aa2 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -567,7 +567,7 @@ async def play(self, board): class Command(BaseCommand): def start(self, engine): engine._position(board) - engine._go(movetime=1000) + engine._go(nodes=10000) def line_received(self, engine, line): if line.startswith("bestmove "): @@ -724,8 +724,12 @@ def main(): }) board = chess.Board() - play_result = engine.play(board) - print("PLAYED", play_result) + while not board.is_game_over(): + play_result = engine.play(board) + print("PLAYED", play_result) + board.push(play_result.move) + + engine.protocol.send_line("d") engine.quit() print("QUIT") From 985fad8c77f499ed010f1cb796dd398e2ec5394a Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 29 Dec 2018 18:18:43 +0100 Subject: [PATCH 0068/1451] directly configure play --- chess/engine.py | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 1eb974aa2..796a848c7 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -456,19 +456,21 @@ def _setoption(self, name, value): self.send_line(" ".join(builder)) self.config[name] = value + def _configure(self, config): + for name, value in config.items(): + if name not in self.options: + raise EngineError("engine does not support option: {}".format(name)) + elif name.lower() == "uci_chess960": + raise EngineError("cannot set UCI_Chess960 which is automatically managed") + elif name.lower() == "uci_variant": + raise EngineError("cannot set UCI_Variant which is automatically managed") + else: + self._setoption(name, value) + async def configure(self, config): class Command(BaseCommand): def start(self, engine): - for name, value in config.items(): - if name not in engine.options: - raise EngineError("engine does not support option: {}".format(name)) - elif name.lower() == "uci_chess960": - raise EngineError("cannot set UCI_Chess960 which is automatically managed") - elif name.lower() == "uci_variant": - raise EngineError("cannot set UCI_Variant which is automatically managed") - else: - engine._setoption(name, value) - + engine._configure(config) self.set_finished() return await self.communicate(Command) @@ -563,9 +565,10 @@ def _go(self, *, searchmoves=None, ponder=False, wtime=None, btime=None, winc=No self.send_line(" ".join(builder)) - async def play(self, board): + async def play(self, board, *, config={}): class Command(BaseCommand): def start(self, engine): + engine._configure(config) engine._position(board) engine._go(nodes=10000) @@ -669,8 +672,8 @@ def configure(self, config): def ping(self): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.ping(), self.timeout), self.protocol.loop).result() - def play(self, board): - return asyncio.run_coroutine_threadsafe(self.protocol.play(board), self.protocol.loop).result() + def play(self, board, *, config={}): + return asyncio.run_coroutine_threadsafe(self.protocol.play(board, config=config), self.protocol.loop).result() def quit(self): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.quit(), self.timeout), self.protocol.loop).result() @@ -725,9 +728,10 @@ def main(): board = chess.Board() while not board.is_game_over(): - play_result = engine.play(board) + play_result = engine.play(board, config={"Contempt": 20}) print("PLAYED", play_result) board.push(play_result.move) + break engine.protocol.send_line("d") From aa514c448b46cb8f8ffc86620febaf125c8e8df2 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 29 Dec 2018 18:27:59 +0100 Subject: [PATCH 0069/1451] automatically manage UCI_AnalyseMode --- chess/engine.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index 796a848c7..f416140de 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -459,11 +459,13 @@ def _setoption(self, name, value): def _configure(self, config): for name, value in config.items(): if name not in self.options: - raise EngineError("engine does not support option: {}".format(name)) + raise EngineError("engine does not support option: {} (available options: {})".format(name, ", ".join(self.options))) elif name.lower() == "uci_chess960": raise EngineError("cannot set UCI_Chess960 which is automatically managed") elif name.lower() == "uci_variant": raise EngineError("cannot set UCI_Variant which is automatically managed") + elif name.lower() == "uci_analysemode": + raise EngineError("cannot set UCI_AnalyseMode which is automatically managed") else: self._setoption(name, value) @@ -568,6 +570,9 @@ def _go(self, *, searchmoves=None, ponder=False, wtime=None, btime=None, winc=No async def play(self, board, *, config={}): class Command(BaseCommand): def start(self, engine): + if "UCI_AnalyseMode" in engine.options: + engine._setoption("UCI_AnalyseMode", False) + engine._configure(config) engine._position(board) engine._go(nodes=10000) From 3f7a27e674c5091d88fd41b6219d70f30e6b8f3a Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 29 Dec 2018 19:56:33 +0100 Subject: [PATCH 0070/1451] improve option handling --- chess/engine.py | 104 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 74 insertions(+), 30 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index f416140de..eec6fd991 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -127,6 +127,42 @@ class EngineTerminatedError(EngineError): class Option(collections.namedtuple("Option", "name type default min max var")): """Information about an available engine option.""" + def parse(self, value): + if self.type == "check": + return value and value != "false" + elif self.type == "spin": + try: + value = int(value) + except ValueError: + raise EngineError("expected integer for spin option {}, got: {}".format(self.name, repr(value))) + if self.min is not None and value < self.min: + raise EngineError("expected value for option {} to be at least {}, got: {}".format(self.name, self.min, value)) + if self.max is not None and self.max < value: + raise EngineError("expected value for option {} to be at most {}, got: {}".format(self.name, self.max, value)) + return value + elif self.type == "combo": + value = str(value) + if value not in (self.var or []): + raise EngineError("invalid value for combo option {}, got: {} (available: {})".format(self.name, value, ", ".join(self.var))) + return value + elif self.type == "button": + return None + elif self.type == "string": + value = str(value) + if "\n" in value or "\r" in value: + raise EngineError("invalid line-break in string option {}".format(self.name)) + return value + else: + # Unknown option type. + if value is True: + return "true" + elif value is False: + return "false" + elif value is None: + return None + else: + return str(value) + class PlayResult: def __init__(self, move, ponder): @@ -279,7 +315,7 @@ def __init__(self, loop): self.finished = self.loop.create_future() def _engine_terminated(self, engine, code): - exc = EngineTerminatedError("engine process died while running {} (exit code: {})".format(type(self).__name__, code)) + exc = EngineTerminatedError("engine process died unexpectedly (exit code: {})".format(type(self).__name__, code)) self._handle_exception(exc) if self.state in [CommandState.Active, CommandState.Cancelling]: @@ -442,25 +478,35 @@ def line_received(self, engine, line): return await self.communicate(Command) - def _setoption(self, name, value): - builder = ["setoption name", name, "value"] - if value is True: - builder.append("true") - elif value is False: - builder.append("false") - elif value is None: - builder.append("none") - else: - builder.append(str(value)) - - self.send_line(" ".join(builder)) - self.config[name] = value + def _getoption(self, option, default=None): + if option in self.config: + return self.config[option] + if option in self.options: + return self.options[option].default + return default - def _configure(self, config): - for name, value in config.items(): - if name not in self.options: - raise EngineError("engine does not support option: {} (available options: {})".format(name, ", ".join(self.options))) - elif name.lower() == "uci_chess960": + def _setoption(self, name, value): + try: + value = self.options[name].parse(value) + except KeyError: + raise EngineError("engine does not support option {} (available options: {})".format(name, ", ".join(self.options))) + + if value is None or value != self._getoption(name): + builder = ["setoption name", name] + if value is False: + builder.append("value false") + elif value is True: + builder.append("value true") + elif value is not None: + builder.append("value") + builder.append(str(value)) + + self.send_line(" ".join(builder)) + self.config[name] = value + + def _configure(self, options): + for name, value in options.items(): + if name.lower() == "uci_chess960": raise EngineError("cannot set UCI_Chess960 which is automatically managed") elif name.lower() == "uci_variant": raise EngineError("cannot set UCI_Variant which is automatically managed") @@ -469,30 +515,23 @@ def _configure(self, config): else: self._setoption(name, value) - async def configure(self, config): + async def configure(self, options): class Command(BaseCommand): def start(self, engine): - engine._configure(config) + engine._configure(options) self.set_finished() return await self.communicate(Command) - def _get_config(self, option, default=None): - if option in self.config: - return self.config[option] - if option in self.options: - return self.options[option].default - return default - def _position(self, board): # Select UCI_Variant and UCI_Chess960. uci_variant = type(board).uci_variant - if uci_variant != self._get_config("UCI_Variant", "chess"): + if uci_variant != self._getoption("UCI_Variant", "chess"): if "UCI_Variant" not in self.options: raise EngineError("engine does not support UCI_Variant") self._setoption("UCI_Variant", uci_variant) - if board.chess960 != self._get_config("UCI_Chess960", False): + if board.chess960 != self._getoption("UCI_Chess960", False): if "UCI_Chess960" not in self.options: raise EngineError("engine does not support UCI_Chess960") self._setoption("UCI_Chess960", board.chess960) @@ -568,6 +607,8 @@ def _go(self, *, searchmoves=None, ponder=False, wtime=None, btime=None, winc=No self.send_line(" ".join(builder)) async def play(self, board, *, config={}): + previous_config = self.config.copy() + class Command(BaseCommand): def start(self, engine): if "UCI_AnalyseMode" in engine.options: @@ -608,6 +649,9 @@ def _bestmove(self, engine, arg): self.result.set_result(PlayResult(bestmove, ponder)) finally: + for name, value in previous_config.items(): + engine._setoption(name, value) + self.set_finished() def cancel(self, engine): From 85edb596e1efce2f66eb8db3f3072cfee7724bfb Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 29 Dec 2018 20:31:20 +0100 Subject: [PATCH 0071/1451] rename config argument to options --- chess/engine.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index eec6fd991..f542098c8 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -606,7 +606,7 @@ def _go(self, *, searchmoves=None, ponder=False, wtime=None, btime=None, winc=No self.send_line(" ".join(builder)) - async def play(self, board, *, config={}): + async def play(self, board, *, options={}): previous_config = self.config.copy() class Command(BaseCommand): @@ -614,7 +614,7 @@ def start(self, engine): if "UCI_AnalyseMode" in engine.options: engine._setoption("UCI_AnalyseMode", False) - engine._configure(config) + engine._configure(options) engine._position(board) engine._go(nodes=10000) @@ -715,14 +715,14 @@ def __init__(self, transport, protocol, *, timeout=10.0): self.protocol = protocol self.timeout = timeout - def configure(self, config): - return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.configure(config), self.timeout), self.protocol.loop).result() + def configure(self, options): + return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.configure(options), self.timeout), self.protocol.loop).result() def ping(self): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.ping(), self.timeout), self.protocol.loop).result() - def play(self, board, *, config={}): - return asyncio.run_coroutine_threadsafe(self.protocol.play(board, config=config), self.protocol.loop).result() + def play(self, board, *, options={}): + return asyncio.run_coroutine_threadsafe(self.protocol.play(board, options=options), self.protocol.loop).result() def quit(self): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.quit(), self.timeout), self.protocol.loop).result() @@ -764,20 +764,23 @@ async def async_main(): def main(): - with SimpleEngine.popen_uci(sys.argv[1], setpgrp=True) as engine: + with SimpleEngine.popen_uci(sys.argv[1:], setpgrp=True) as engine: print(engine.protocol.options) - print("PING") - engine.ping() - print("PONG") + #print("PING") + #try: + # engine.ping() + #except asyncio.TimeoutError: + # print("timeout !!!!!") + #print("PONG") - engine.configure({ - "Contempt": 40, - }) + #engine.configure({ + # "Contempt": 40, + #}) board = chess.Board() while not board.is_game_over(): - play_result = engine.play(board, config={"Contempt": 20}) + play_result = engine.play(board) # , config={"Contempt": 20}) print("PLAYED", play_result) board.push(play_result.move) break From c75314bc38f906c572aeed30616f8f00c6e5fe9a Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 31 Dec 2018 12:21:30 +0100 Subject: [PATCH 0072/1451] automatically send ucinewgame --- chess/engine.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index f542098c8..816d58b97 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -379,6 +379,7 @@ def __init__(self): self.options = UciOptionMap() self.config = UciOptionMap() self.board = chess.Board() + self.game = None async def _initialize(self): class Command(BaseCommand): @@ -465,6 +466,9 @@ def _option(self, engine, arg): def _isready(self): self.send_line("isready") + def _ucinewgame(self): + self.send_line("ucinewgame") + async def ping(self): class Command(BaseCommand): def start(self, engine): @@ -606,7 +610,7 @@ def _go(self, *, searchmoves=None, ponder=False, wtime=None, btime=None, winc=No self.send_line(" ".join(builder)) - async def play(self, board, *, options={}): + async def play(self, board, *, game=None, options={}): previous_config = self.config.copy() class Command(BaseCommand): @@ -615,8 +619,13 @@ def start(self, engine): engine._setoption("UCI_AnalyseMode", False) engine._configure(options) + + if engine.game != game: + engine._ucinewgame() + engine.game = game + engine._position(board) - engine._go(nodes=10000) + engine._go(nodes=10000, movetime=1000) def line_received(self, engine, line): if line.startswith("bestmove "): @@ -721,8 +730,8 @@ def configure(self, options): def ping(self): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.ping(), self.timeout), self.protocol.loop).result() - def play(self, board, *, options={}): - return asyncio.run_coroutine_threadsafe(self.protocol.play(board, options=options), self.protocol.loop).result() + def play(self, board, *, game=None, options={}): + return asyncio.run_coroutine_threadsafe(self.protocol.play(board, game=game, options=options), self.protocol.loop).result() def quit(self): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.quit(), self.timeout), self.protocol.loop).result() @@ -780,7 +789,7 @@ def main(): board = chess.Board() while not board.is_game_over(): - play_result = engine.play(board) # , config={"Contempt": 20}) + play_result = engine.play(board, game="foo") # , config={"Contempt": 20}) print("PLAYED", play_result) board.push(play_result.move) break From d199a854c03e810ea6423f246bff563bfcaac5d5 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 31 Dec 2018 12:33:13 +0100 Subject: [PATCH 0073/1451] add chess.engine.popen_uci --- chess/engine.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 816d58b97..f9ab81083 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -718,6 +718,10 @@ def __repr__(self): return "{}({})".format(type(self).__name__, dict(self.items())) +async def popen_uci(command, **kwargs): + return await UciProtocol.popen(command, **kwargs) + + class SimpleEngine: def __init__(self, transport, protocol, *, timeout=10.0): self.transport = transport @@ -756,7 +760,7 @@ def __exit__(self, a, b, c): async def async_main(): - transport, engine = await UciProtocol.popen(sys.argv[1]) + transport, engine = await popen_uci(sys.argv[1:]) print(engine.options) await engine.ping() @@ -802,5 +806,5 @@ def main(): if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) - main() - #asyncio.run(async_main()) + #main() + asyncio.run(async_main()) From 63e24d7dca0107ebf5b3ff8b93550db1a19dafe6 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 31 Dec 2018 13:40:20 +0100 Subject: [PATCH 0074/1451] multipv is also managed --- chess/engine.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index f9ab81083..798becaa7 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -510,12 +510,8 @@ def _setoption(self, name, value): def _configure(self, options): for name, value in options.items(): - if name.lower() == "uci_chess960": - raise EngineError("cannot set UCI_Chess960 which is automatically managed") - elif name.lower() == "uci_variant": - raise EngineError("cannot set UCI_Variant which is automatically managed") - elif name.lower() == "uci_analysemode": - raise EngineError("cannot set UCI_AnalyseMode which is automatically managed") + if name.lower() in ["uci_chess960", "uci_variant", "uci_analysemode", "multipv"]: + raise EngineError("cannot set {} which is automatically managed".format(name)) else: self._setoption(name, value) @@ -769,7 +765,8 @@ async def async_main(): "Contempt": 40, }) - board = chess.Board() + import chess.variant + board = chess.variant.ThreeCheckBoard() play_result = await engine.play(board) print("PLAYED ASYNC", play_result) From 118d5c55bd7c3f4dc36345aed18c894e7a6bd0ec Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 31 Dec 2018 13:43:44 +0100 Subject: [PATCH 0075/1451] draft Cp and Mate --- chess/engine.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index 798becaa7..9be10c310 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -173,6 +173,44 @@ def __repr__(self): return "<{} at {} (move={}, ponder={})>".format(type(self).__name__, hex(id(self)), self.move, self.ponder) +class Cp: + def __init__(self, cp): + self.cp = cp + + def __repr__(self): + return "Cp({})".format(self.cp) + + def __str__(self): + return "+{}".format(self.cp) if self.cp > 0 else str(self.cp) + + +class Mate: + def __init__(self, moves, winning): + self.moves = abs(moves) + self.winning = winning ^ (moves < 0) + + @classmethod + def from_moves(cls, moves): + return Mate(abs(moves), moves > 0) + + @classmethod + def plus(self, moves): + return Mate(moves, True) + + @classmethod + def minus(self, moves): + return Mate(moves, False) + + def __repr__(self): + if self.winning: + return "Mate.plus({})".format(self.moves) + else: + return "Mate.minus({})".format(self.moves) + + def __str__(self): + return "#{}".format(self.moves) if self.winning else "#-{}".format(self.moves) + + class EngineProtocol(asyncio.SubprocessProtocol): def __init__(self): self.loop = get_running_loop() From 772d44d593ba23eec2641ed45e6b6d8bc8a81ce1 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 31 Dec 2018 14:23:39 +0100 Subject: [PATCH 0076/1451] arithmetic for Cp and Mate --- chess/engine.py | 60 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index 9be10c310..7d10ea7b7 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -183,6 +183,53 @@ def __repr__(self): def __str__(self): return "+{}".format(self.cp) if self.cp > 0 else str(self.cp) + def __add__(self, other): + try: + return Cp(self.cp + other.cp) + except AttributeError: + return NotImplemented + + def __sub__(self, other): + try: + return Cp(self.cp - other.cp) + except AttributeError: + return NotImplemented + + def __mul__(self, scalar): + try: + return Cp(self.cp * scalar) + except TypeError: + return NotImplemented + + __rmul__ = __mul__ + + def __truediv__(self, scalar): + try: + return Cp(self.cp / scalar) + except TypeError: + return NotImplemented + + def __floordiv__(self, scalar): + try: + return Cp(self.cp // scalar) + except TypeError: + return NotImplemented + + def __neg__(self): + return Cp(-self.cp) + + def __pos__(self): + return Cp(self.cp) + + def __int__(self): + return self.cp + + def __abs__(self): + return Cp(abs(self.cp)) + + def __float__(self): + return float(self.cp) + class Mate: def __init__(self, moves, winning): @@ -210,6 +257,19 @@ def __repr__(self): def __str__(self): return "#{}".format(self.moves) if self.winning else "#-{}".format(self.moves) + def __neg__(self): + return Mate(self.moves, not self.winning) + + def __pos__(self): + return Mate(self.moves, self.winning) + + def __int__(self): + # Careful: Conflates Mate.plus(0) and Mate.minus(0)! + return self.moves + + def __float__(self): + return float(int(self)) + class EngineProtocol(asyncio.SubprocessProtocol): def __init__(self): From a9e266b4794a3645683eb76309f665721e461c94 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 31 Dec 2018 14:53:06 +0100 Subject: [PATCH 0077/1451] test and implement score ordering --- chess/engine.py | 58 +++++++++++++++++++++++++++++++++++++++++++++++++ test.py | 30 ++++++++++++++++++++++--- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 7d10ea7b7..49b4b4d06 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1,5 +1,6 @@ import asyncio import concurrent.futures +import functools import logging import enum import collections @@ -173,6 +174,7 @@ def __repr__(self): return "<{} at {} (move={}, ponder={})>".format(type(self).__name__, hex(id(self)), self.move, self.ponder) +@functools.total_ordering class Cp: def __init__(self, cp): self.cp = cp @@ -230,7 +232,35 @@ def __abs__(self): def __float__(self): return float(self.cp) + def __eq__(self, other): + try: + return self.cp == other.cp + except AttributeError: + pass + + try: + other.winning + return False + except AttributeError: + pass + + return NotImplemented + + def __lt__(self, other): + try: + return other.winning + except AttributeError: + pass + + try: + return self.cp < other.cp + except AttributeError: + pass + + return NotImplemented + +@functools.total_ordering class Mate: def __init__(self, moves, winning): self.moves = abs(moves) @@ -270,6 +300,34 @@ def __int__(self): def __float__(self): return float(int(self)) + def __eq__(self, other): + try: + return self.moves == other.moves and self.winning == other.winning + except AttributeError: + pass + + try: + other.cp + return False + except AttributeError: + pass + + return NotImplemented + + def __lt__(self, other): + try: + if self.winning != other.winning: + return self.winning < other.winning + + if self.winning: + return self.moves > other.moves + else: + return self.moves < other.moves + except AttributeError: + pass + + return other > self + class EngineProtocol(asyncio.SubprocessProtocol): def __init__(self): diff --git a/test.py b/test.py index d1c22f874..ac78c05fc 100755 --- a/test.py +++ b/test.py @@ -3025,9 +3025,9 @@ def test_hakkapeliitta_double_spaces(self): self.assertEqual(info["tbhits"], 0) -class UciOptionMapTestCase(unittest.TestCase): +class EngineTestCase(unittest.TestCase): - def test_equality(self): + def test_uci_option_map_equality(self): a = chess.engine.UciOptionMap() b = chess.engine.UciOptionMap() c = chess.engine.UciOptionMap() @@ -3046,7 +3046,7 @@ def test_equality(self): self.assertNotEqual(a, b) self.assertNotEqual(b, a) - def test_len(self): + def test_uci_option_map_len(self): a = chess.uci.OptionMap() self.assertEqual(len(a), 0) @@ -3056,6 +3056,30 @@ def test_len(self): del a["key"] self.assertEqual(len(a), 0) + def test_score_ordering(self): + order = [ + chess.engine.Mate.minus(0), + chess.engine.Mate.minus(1), + chess.engine.Mate.minus(99), + chess.engine.Cp(-123), + chess.engine.Cp(-50), + chess.engine.Cp(0), + chess.engine.Cp(30), + chess.engine.Cp(800), + chess.engine.Mate.plus(77), + chess.engine.Mate.plus(1), + chess.engine.Mate.plus(0), + ] + + for i, a in enumerate(order): + for j, b in enumerate(order): + self.assertEqual(i < j, a < b) + self.assertEqual(i <= j, a <= b) + self.assertEqual(i == j, a == b) + self.assertEqual(i != j, a != b) + self.assertEqual(i > j, a > b) + self.assertEqual(i >= j, a >= b) + class SyzygyTestCase(unittest.TestCase): From 0b51c22c69c517832c436b2ce93986b7a2a11b0c Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 31 Dec 2018 14:59:05 +0100 Subject: [PATCH 0078/1451] add score.is_mate() --- chess/engine.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index 49b4b4d06..0368a56ba 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -179,6 +179,9 @@ class Cp: def __init__(self, cp): self.cp = cp + def is_mate(self): + return False + def __repr__(self): return "Cp({})".format(self.cp) @@ -266,6 +269,9 @@ def __init__(self, moves, winning): self.moves = abs(moves) self.winning = winning ^ (moves < 0) + def is_mate(self): + return True + @classmethod def from_moves(cls, moves): return Mate(abs(moves), moves > 0) From 5862b3973dd60a49d16783830c2d73be0b28086a Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 31 Dec 2018 15:04:28 +0100 Subject: [PATCH 0079/1451] add score.score() --- chess/engine.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index 0368a56ba..9fed367d5 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -182,6 +182,9 @@ def __init__(self, cp): def is_mate(self): return False + def score(self, mate_score=None): + return self.cp + def __repr__(self): return "Cp({})".format(self.cp) @@ -272,6 +275,14 @@ def __init__(self, moves, winning): def is_mate(self): return True + def score(self, mate_score=None): + if mate_score is None: + return None + elif self.winning: + return mate_score - self.moves + else: + return -mate_score + self.moves + @classmethod def from_moves(cls, moves): return Mate(abs(moves), moves > 0) From e03ae29626797465ede5d7773d30ace356356cd1 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 1 Jan 2019 14:11:06 +0100 Subject: [PATCH 0080/1451] Add chess.pgn.BaseVisitor.visit_board() (closes #343) --- chess/pgn.py | 44 +++++++++++++++++++++++++++++++++++++++++++- docs/pgn.rst | 2 ++ test.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/chess/pgn.py b/chess/pgn.py index 1b0cf3d88..ca48d5edf 100644 --- a/chess/pgn.py +++ b/chess/pgn.py @@ -320,6 +320,10 @@ def _accept_node(self, parent_board, visitor): visitor.visit_move(parent_board, self.move) + parent_board.push(self.move) + visitor.visit_board(parent_board) + parent_board.pop() + for nag in sorted(self.nags): visitor.visit_nag(nag) @@ -374,6 +378,8 @@ def accept_subgame(self, visitor): visitor.visit_header(tagname, tagvalue) if visitor.end_headers() is not SKIP: + visitor.visit_board(board) + if self.variations: self.variations[0].accept(visitor, _parent_board=board) @@ -450,11 +456,14 @@ def accept(self, visitor): for tagname, tagvalue in self.headers.items(): visitor.visit_header(tagname, tagvalue) if visitor.end_headers() is not SKIP: + board = self.board() + visitor.visit_board(board) + if self.comment: visitor.visit_comment(self.comment) if self.variations: - self.variations[0].accept(visitor, _parent_board=self.board()) + self.variations[0].accept(visitor, _parent_board=board) visitor.visit_result(self.headers.get("Result", "*")) @@ -692,6 +701,13 @@ def visit_move(self, board, move): """ pass + def visit_board(self, board): + """ + Called for the starting position of the game and after each move. + + The board state must be restored before the traversal continues. + """ + def visit_comment(self, comment): """Called for each comment.""" pass @@ -812,6 +828,30 @@ def result(self): return self.headers +class BoardCreator(BaseVisitor): + """ + Returns the final position of the game. The mainline of the game is + on the move stack. + """ + + def begin_game(self): + self.skip_variation_depth = 0 + + def begin_variation(self): + self.skip_variation_depth += 1 + return SKIP + + def end_variation(self): + self.skip_variation_depth = max(self.skip_variation_depth - 1, 0) + + def visit_board(self, board): + if not self.skip_variation_depth: + self.board = board + + def result(self): + return self.board + + class SkipVisitor(BaseVisitor): """Skips a game.""" @@ -1123,6 +1163,7 @@ def read_game(handle, *, Visitor=GameModelCreator): except ValueError as error: visitor.handle_error(error) board_stack = [VariantBoard(chess960=headers.is_chess960())] + visitor.visit_board(board_stack[0]) # Parse movetext. skip_variation_depth = 0 @@ -1215,6 +1256,7 @@ def read_game(handle, *, Visitor=GameModelCreator): else: visitor.visit_move(board_stack[-1], move) board_stack[-1].push(move) + visitor.visit_board(board_stack[-1]) if read_next_line: line = handle.readline() diff --git a/docs/pgn.rst b/docs/pgn.rst index 4000f67d1..2b6b3261f 100644 --- a/docs/pgn.rst +++ b/docs/pgn.rst @@ -116,6 +116,8 @@ The following visitors are readily available. .. autoclass:: chess.pgn.HeaderCreator +.. autoclass:: chess.pgn.BoardCreator + .. autoclass:: chess.pgn.SkipVisitor .. autoclass:: chess.pgn.StringExporter diff --git a/test.py b/test.py index 6639a3218..b8bb34af8 100755 --- a/test.py +++ b/test.py @@ -2153,6 +2153,50 @@ def test_read_headers(self): self.assertEqual(first_drawn_game.headers["Site"], "03") self.assertEqual(first_drawn_game[0].move, chess.Move.from_uci("d2d3")) + def test_visit_board(self): + class TraceVisitor(chess.pgn.BaseVisitor): + def __init__(self): + self.trace = [] + + def visit_board(self, board): + self.trace.append(board.fen()) + + def visit_move(self, board, move): + self.trace.append(board.san(move)) + + def result(self): + return self.trace + + pgn = io.StringIO(textwrap.dedent("""\ + [FEN "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1"] + + 1... e5 (1... d5 2. exd5) (1... c5) 2. Nf3 Nc6 + """)) + + trace = [ + "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1", + "e5", + "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2", + "d5", + "rnbqkbnr/ppp1pppp/8/3p4/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2", + "exd5", + "rnbqkbnr/ppp1pppp/8/3P4/8/8/PPPP1PPP/RNBQKBNR b KQkq - 0 2", + "c5", + "rnbqkbnr/pp1ppppp/8/2p5/4P3/8/PPPP1PPP/RNBQKBNR w KQkq - 0 2", + "Nf3", + "rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2", + "Nc6", + "r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 2 3", + ] + + self.assertEqual(trace, chess.pgn.read_game(pgn, Visitor=TraceVisitor)) + + pgn.seek(0) + self.assertEqual(trace, chess.pgn.read_game(pgn).accept(TraceVisitor())) + + pgn.seek(0) + self.assertEqual(chess.Board(trace[-1]), chess.pgn.read_game(pgn, Visitor=chess.pgn.BoardCreator)) + def test_black_to_move(self): game = chess.pgn.Game() game.setup("8/8/4k3/8/4P3/4K3/8/8 b - - 0 17") From d9b68672b5164987ab1d142a32ee901480629c14 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 14:32:32 +0100 Subject: [PATCH 0081/1451] add chess.engine.Limit --- chess/engine.py | 68 ++++++++++++++++++++++++++++++------------------- 1 file changed, 42 insertions(+), 26 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 9fed367d5..2080b18ac 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -165,6 +165,19 @@ def parse(self, value): return str(value) +class Limit: + def __init__(self, wtime=None, btime=None, winc=None, binc=None, movestogo=None, depth=None, nodes=None, mate=None, movetime=None): + self.wtime = wtime + self.btime = btime + self.winc = winc + self.binc = binc + self.movestogo = movestogo + self.depth = depth + self.nodes = nodes + self.mate = mate + self.movetime = movetime + + class PlayResult: def __init__(self, move, ponder): self.move = move @@ -727,47 +740,47 @@ def _position(self, board): self.send_line(" ".join(builder)) self.board = board.copy(stack=False) - def _go(self, *, searchmoves=None, ponder=False, wtime=None, btime=None, winc=None, binc=None, movestogo=None, depth=None, nodes=None, mate=None, movetime=None, infinite=False): + def _go(self, limit, *, searchmoves=None, ponder=False, infinite=False): builder = ["go"] if ponder: builder.append("ponder") - if wtime is not None: + if limit.wtime is not None: builder.append("wtime") - builder.append(str(int(wtime))) + builder.append(str(int(limit.wtime))) - if btime is not None: + if limit.btime is not None: builder.append("btime") - builder.append(str(int(btime))) + builder.append(str(int(limit.btime))) - if winc is not None: + if limit.winc is not None: builder.append("winc") - builder.append(str(int(winc))) + builder.append(str(int(limit.winc))) - if binc is not None: + if limit.binc is not None: builder.append("binc") - builder.append(str(int(binc))) + builder.append(str(int(limit.binc))) - if movestogo is not None and movestogo > 0: + if limit.movestogo is not None and int(limit.movestogo) > 0: builder.append("movestogo") - builder.append(str(int(movestogo))) + builder.append(str(int(limit.movestogo))) - if depth is not None: + if limit.depth is not None: builder.append("depth") - builder.append(str(int(depth))) + builder.append(str(int(limit.depth))) - if nodes is not None: + if limit.nodes is not None: builder.append("nodes") - builder.append(str(int(nodes))) + builder.append(str(int(limit.nodes))) - if mate is not None: + if limit.mate is not None: builder.append("mate") - builder.append(str(int(mate))) + builder.append(str(int(limit.mate))) - if movetime is not None: + if limit.movetime is not None: builder.append("movetime") - builder.append(str(int(movetime))) + builder.append(str(int(limit.movetime))) if infinite: builder.append("infinite") @@ -779,7 +792,7 @@ def _go(self, *, searchmoves=None, ponder=False, wtime=None, btime=None, winc=No self.send_line(" ".join(builder)) - async def play(self, board, *, game=None, options={}): + async def play(self, board, limit, *, game=None, options={}): previous_config = self.config.copy() class Command(BaseCommand): @@ -794,7 +807,7 @@ def start(self, engine): engine.game = game engine._position(board) - engine._go(nodes=10000, movetime=1000) + engine._go(limit) def line_received(self, engine, line): if line.startswith("bestmove "): @@ -903,8 +916,8 @@ def configure(self, options): def ping(self): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.ping(), self.timeout), self.protocol.loop).result() - def play(self, board, *, game=None, options={}): - return asyncio.run_coroutine_threadsafe(self.protocol.play(board, game=game, options=options), self.protocol.loop).result() + def play(self, board, limit, *, game=None, options={}): + return asyncio.run_coroutine_threadsafe(self.protocol.play(board, limit, game=game, options=options), self.protocol.loop).result() def quit(self): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.quit(), self.timeout), self.protocol.loop).result() @@ -939,8 +952,9 @@ async def async_main(): }) import chess.variant - board = chess.variant.ThreeCheckBoard() - play_result = await engine.play(board) + #board = chess.variant.ThreeCheckBoard() + board = chess.Board() + play_result = await engine.play(board, Limit(movetime=1000)) print("PLAYED ASYNC", play_result) await engine.quit() @@ -961,9 +975,11 @@ def main(): # "Contempt": 40, #}) + limit = Limit(movetime=1000) + board = chess.Board() while not board.is_game_over(): - play_result = engine.play(board, game="foo") # , config={"Contempt": 20}) + play_result = engine.play(board, limit, game="foo") # , config={"Contempt": 20}) print("PLAYED", play_result) board.push(play_result.move) break From b1bc54c8eb19460f1b6835d19ef191b83b80f7d9 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 14:47:02 +0100 Subject: [PATCH 0082/1451] add UciProtocol.analyse --- chess/engine.py | 45 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 2080b18ac..81f5523e2 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -850,6 +850,44 @@ def cancel(self, engine): return await self.communicate(Command) + async def analyse(self, board, limit, *, game=None, options={}): + previous_config = self.config.copy() + + class Command(BaseCommand): + def start(self, engine): + self.info = {} + + if "UCI_AnalyseMode" in engine.options: + engine._setoption("UCI_AnalyseMode", True) + + engine._configure(options) + + if engine.game != game: + engine._ucinewgame() + engine.game = game + + engine._position(board) + engine._go(limit) + + def line_received(self, engine, line): + if line.startswith("bestmove "): + self._bestmove(engine, line.split(" ", 1)[1]) + elif not line.startswith("info "): + LOGGER.warning("%s: Unexpected engine output: %s", engine, line) + + def _bestmove(self, engine, arg): + self.result.set_result(self.info) + + for name, value in previous_config.items(): + engine._setoption(name, value) + + self.set_finished() + + def cancel(self, engine): + engine.send_line("stop") + + return await self.communicate(Command) + async def quit(self): self.send_line("quit") await self.returncode @@ -919,6 +957,9 @@ def ping(self): def play(self, board, limit, *, game=None, options={}): return asyncio.run_coroutine_threadsafe(self.protocol.play(board, limit, game=game, options=options), self.protocol.loop).result() + def analyse(self, board, limit, *, game=None, options={}): + return asyncio.run_coroutine_threadsafe(self.protocol.analyse(board, limit, game=game, options=options), self.protocol.loop).result() + def quit(self): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.quit(), self.timeout), self.protocol.loop).result() @@ -952,9 +993,9 @@ async def async_main(): }) import chess.variant - #board = chess.variant.ThreeCheckBoard() + board = chess.Board() - play_result = await engine.play(board, Limit(movetime=1000)) + play_result = await asyncio.wait_for(engine.play(board, Limit(movetime=30000)), 2.000) print("PLAYED ASYNC", play_result) await engine.quit() From b97b1855b745fb21a7a75f54e7b0c7575f7d459c Mon Sep 17 00:00:00 2001 From: gbtami Date: Wed, 2 Jan 2019 15:03:42 +0100 Subject: [PATCH 0083/1451] Use PIECE_SYMBOLS in _set_board_fen --- chess/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chess/__init__.py b/chess/__init__.py index 4e791a6ab..fa36e66e1 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -934,7 +934,7 @@ def _set_board_fen(self, fen): raise ValueError("~ not after piece in position part of fen: {}".format(repr(fen))) previous_was_digit = False previous_was_piece = False - elif c.lower() in ["p", "n", "b", "r", "q", "k"]: + elif c.lower() in PIECE_SYMBOLS[1:]: field_sum += 1 previous_was_digit = False previous_was_piece = True @@ -952,7 +952,7 @@ def _set_board_fen(self, fen): for c in fen: if c in ["1", "2", "3", "4", "5", "6", "7", "8"]: square_index += int(c) - elif c.lower() in ["p", "n", "b", "r", "q", "k"]: + elif c.lower() in PIECE_SYMBOLS[1:]: piece = Piece.from_symbol(c) self._set_piece_at(SQUARES_180[square_index], piece.piece_type, piece.color) square_index += 1 From 3514ec81dfa1ba23d75c5753344cb6e28e8792e6 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 15:14:56 +0100 Subject: [PATCH 0084/1451] Optimization: c.lower() will never be None --- chess/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chess/__init__.py b/chess/__init__.py index fa36e66e1..fcf0f87f6 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -934,7 +934,7 @@ def _set_board_fen(self, fen): raise ValueError("~ not after piece in position part of fen: {}".format(repr(fen))) previous_was_digit = False previous_was_piece = False - elif c.lower() in PIECE_SYMBOLS[1:]: + elif c.lower() in PIECE_SYMBOLS: field_sum += 1 previous_was_digit = False previous_was_piece = True @@ -952,7 +952,7 @@ def _set_board_fen(self, fen): for c in fen: if c in ["1", "2", "3", "4", "5", "6", "7", "8"]: square_index += int(c) - elif c.lower() in PIECE_SYMBOLS[1:]: + elif c.lower() in PIECE_SYMBOLS: piece = Piece.from_symbol(c) self._set_piece_at(SQUARES_180[square_index], piece.piece_type, piece.color) square_index += 1 From 15fe1bce3a628529d71877c58d0fef6ea32139f5 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 16:12:37 +0100 Subject: [PATCH 0085/1451] implement AnalysisResult --- chess/engine.py | 105 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 2 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 81f5523e2..d861dd601 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -21,6 +21,9 @@ LOGGER = logging.getLogger(__name__) +KORK = object() + + def setup_event_loop(): """ Creates and sets up a new asyncio event loop that is capable of spawning @@ -888,6 +891,50 @@ def cancel(self, engine): return await self.communicate(Command) + async def analysis(self, board, limit=None, *, game=None, options={}): + previous_config = self.config.copy() + + class Command(BaseCommand): + def start(self, engine): + self.analysis = AnalysisResult(stop=lambda: self.cancel(engine)) + + if "UCI_AnalyseMode" in engine.options: + engine._setoption("UCI_AnalyseMode", True) + + engine._configure(options) + + if engine.game != game: + engine._ucinewgame() + engine.game = game + + engine._position(board) + engine._go(limit) + + self.result.set_result(self.analysis) + + def line_received(self, engine, line): + if line.startswith("info "): + self._info(engine, line.split(" ", 1)[1]) + elif line.startswith("bestmove "): + self._bestmove(engine, line.split(" ", 1)[1]) + else: + LOGGER.warning("%s: Unexpected engine output: %s", engine, line) + + def _info(self, engine, arg): + self.analysis.post(arg) + + def _bestmove(self, engine, arg): + for name, value in previous_config.items(): + engine._setoption(name, value) + + self.analysis.set_finished() + self.set_finished() + + def cancel(self, engine): + engine.send_line("stop") + + return await self.communicate(Command) + async def quit(self): self.send_line("quit") await self.returncode @@ -938,6 +985,54 @@ def __repr__(self): return "{}({})".format(type(self).__name__, dict(self.items())) +class AnalysisResult: + def __init__(self, stop=None): + self._stop = stop + self._queue = asyncio.Queue() + self._seen_kork = False + self._finished = asyncio.Event() + self.multipv = [{}] + + def post(self, info): + self._queue.put_nowait(info) + + def set_finished(self): + self._queue.put_nowait(KORK) + self._finished.set() + + @property + def info(self): + return self.multipv[0] + + def stop(self): + if self._stop and not self._finished.is_set(): + self._stop() + self._stop = None + + async def wait(self): + return await self._finished.wait() + + def __aiter__(self): + return self + + async def __anext__(self): + if self._seen_kork: + raise StopAsyncIteration + + info = await self._queue.get() + if info is KORK: + self._seen_kork = True + raise StopAsyncIteration + + return info + + def __enter__(self): + return self + + def __exit__(self, a, b, c): + self.stop() + + async def popen_uci(command, **kwargs): return await UciProtocol.popen(command, **kwargs) @@ -995,8 +1090,14 @@ async def async_main(): import chess.variant board = chess.Board() - play_result = await asyncio.wait_for(engine.play(board, Limit(movetime=30000)), 2.000) - print("PLAYED ASYNC", play_result) + limit = Limit(depth=20) + with await engine.analysis(board, limit) as analysis: + async for info in analysis: + print("!", info) + if "depth 14" in info: + break + + await analysis.wait() await engine.quit() From 1b2fc89c49145747063f3df6103e49fb161948cc Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 16:23:33 +0100 Subject: [PATCH 0086/1451] implement analyse in terms of analysis --- chess/engine.py | 69 ++++++++++++++++++------------------------------- 1 file changed, 25 insertions(+), 44 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index d861dd601..9a0eaac25 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -853,44 +853,6 @@ def cancel(self, engine): return await self.communicate(Command) - async def analyse(self, board, limit, *, game=None, options={}): - previous_config = self.config.copy() - - class Command(BaseCommand): - def start(self, engine): - self.info = {} - - if "UCI_AnalyseMode" in engine.options: - engine._setoption("UCI_AnalyseMode", True) - - engine._configure(options) - - if engine.game != game: - engine._ucinewgame() - engine.game = game - - engine._position(board) - engine._go(limit) - - def line_received(self, engine, line): - if line.startswith("bestmove "): - self._bestmove(engine, line.split(" ", 1)[1]) - elif not line.startswith("info "): - LOGGER.warning("%s: Unexpected engine output: %s", engine, line) - - def _bestmove(self, engine, arg): - self.result.set_result(self.info) - - for name, value in previous_config.items(): - engine._setoption(name, value) - - self.set_finished() - - def cancel(self, engine): - engine.send_line("stop") - - return await self.communicate(Command) - async def analysis(self, board, limit=None, *, game=None, options={}): previous_config = self.config.copy() @@ -935,6 +897,16 @@ def cancel(self, engine): return await self.communicate(Command) + async def analyse(self, board, limit, *, multipv=None, game=None, options={}): + analysis = await self.analysis(board, limit, game=game, options=options) + + try: + await analysis.wait() + except asyncio.CancelledError: + analysis.stop() + + return analysis.info if multipv is None else analysis.multipv + async def quit(self): self.send_line("quit") await self.returncode @@ -1091,13 +1063,22 @@ async def async_main(): board = chess.Board() limit = Limit(depth=20) - with await engine.analysis(board, limit) as analysis: - async for info in analysis: - print("!", info) - if "depth 14" in info: - break - await analysis.wait() + #with await engine.analysis(board, limit) as analysis: + # async for info in analysis: + # print("!", info) + # if "depth 14" in info: + # break + #await analysis.wait() + + try: + analysis = await asyncio.wait_for(engine.analyse(board, limit), 0.1) + print("ANALYSIS", analysis) + except asyncio.TimeoutError: + print("TIMEOUT ERROR") + + #move = await engine.play(board, limit) + #print("PLAY", move) await engine.quit() From d992deb273dc705d34bfd928c17bcf6b85354614 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 16:26:50 +0100 Subject: [PATCH 0087/1451] fix MultiPV --- chess/engine.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 9a0eaac25..a44cc0b05 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -699,7 +699,7 @@ def _setoption(self, name, value): def _configure(self, options): for name, value in options.items(): - if name.lower() in ["uci_chess960", "uci_variant", "uci_analysemode", "multipv"]: + if name.lower() in ["uci_chess960", "uci_variant", "uci_analysemode", "multipv", "ponder"]: raise EngineError("cannot set {} which is automatically managed".format(name)) else: self._setoption(name, value) @@ -853,7 +853,7 @@ def cancel(self, engine): return await self.communicate(Command) - async def analysis(self, board, limit=None, *, game=None, options={}): + async def analysis(self, board, limit=None, *, multipv=None, game=None, options={}): previous_config = self.config.copy() class Command(BaseCommand): @@ -863,6 +863,9 @@ def start(self, engine): if "UCI_AnalyseMode" in engine.options: engine._setoption("UCI_AnalyseMode", True) + if multipv and multipv > 1: + engine._setoption("MultiPV", multipv) + engine._configure(options) if engine.game != game: @@ -1024,8 +1027,8 @@ def ping(self): def play(self, board, limit, *, game=None, options={}): return asyncio.run_coroutine_threadsafe(self.protocol.play(board, limit, game=game, options=options), self.protocol.loop).result() - def analyse(self, board, limit, *, game=None, options={}): - return asyncio.run_coroutine_threadsafe(self.protocol.analyse(board, limit, game=game, options=options), self.protocol.loop).result() + def analyse(self, board, limit, *, multipv=None, game=None, options={}): + return asyncio.run_coroutine_threadsafe(self.protocol.analyse(board, limit, multipv=multipv, game=game, options=options), self.protocol.loop).result() def quit(self): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.quit(), self.timeout), self.protocol.loop).result() From afabeaa927d64d28f8e36af8960987f39f77e1fb Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 16:32:32 +0100 Subject: [PATCH 0088/1451] use AnalysisResult as context manager --- chess/engine.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index a44cc0b05..7c7f233b7 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -903,10 +903,8 @@ def cancel(self, engine): async def analyse(self, board, limit, *, multipv=None, game=None, options={}): analysis = await self.analysis(board, limit, game=game, options=options) - try: + with analysis: await analysis.wait() - except asyncio.CancelledError: - analysis.stop() return analysis.info if multipv is None else analysis.multipv From 33812a8252032e7aa710af3d5a61925b2cdd2401 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 16:41:02 +0100 Subject: [PATCH 0089/1451] support infinite analysis --- chess/engine.py | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 7c7f233b7..7af60b099 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -873,7 +873,11 @@ def start(self, engine): engine.game = game engine._position(board) - engine._go(limit) + + if limit: + engine._go(limit) + else: + engine._go(Limit(), infinite=True) self.result.set_result(self.analysis) @@ -1065,18 +1069,18 @@ async def async_main(): board = chess.Board() limit = Limit(depth=20) - #with await engine.analysis(board, limit) as analysis: - # async for info in analysis: - # print("!", info) - # if "depth 14" in info: - # break - #await analysis.wait() - - try: - analysis = await asyncio.wait_for(engine.analyse(board, limit), 0.1) - print("ANALYSIS", analysis) - except asyncio.TimeoutError: - print("TIMEOUT ERROR") + with await engine.analysis(board) as analysis: + async for info in analysis: + print("!", info) + if "123" in info: + break + await analysis.wait() + + #try: + # analysis = await asyncio.wait_for(engine.analyse(board, limit), 0.1) + # print("ANALYSIS", analysis) + #except asyncio.TimeoutError: + # print("TIMEOUT ERROR") #move = await engine.play(board, limit) #print("PLAY", move) From 4a0deaa21a4d1741b5ed97845ad384caa7a8ff3f Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 16:45:57 +0100 Subject: [PATCH 0090/1451] implement searchmoves --- chess/engine.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 7af60b099..72d4e8b80 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -795,7 +795,7 @@ def _go(self, limit, *, searchmoves=None, ponder=False, infinite=False): self.send_line(" ".join(builder)) - async def play(self, board, limit, *, game=None, options={}): + async def play(self, board, limit, *, game=None, searchmoves=None, options={}): previous_config = self.config.copy() class Command(BaseCommand): @@ -810,7 +810,7 @@ def start(self, engine): engine.game = game engine._position(board) - engine._go(limit) + engine._go(limit, searchmoves=searchmoves) def line_received(self, engine, line): if line.startswith("bestmove "): @@ -853,7 +853,7 @@ def cancel(self, engine): return await self.communicate(Command) - async def analysis(self, board, limit=None, *, multipv=None, game=None, options={}): + async def analysis(self, board, limit=None, *, multipv=None, game=None, searchmoves=None, options={}): previous_config = self.config.copy() class Command(BaseCommand): @@ -875,9 +875,9 @@ def start(self, engine): engine._position(board) if limit: - engine._go(limit) + engine._go(limit, searchmoves=searchmoves) else: - engine._go(Limit(), infinite=True) + engine._go(Limit(), searchmoves=searchmoves, infinite=True) self.result.set_result(self.analysis) @@ -904,8 +904,8 @@ def cancel(self, engine): return await self.communicate(Command) - async def analyse(self, board, limit, *, multipv=None, game=None, options={}): - analysis = await self.analysis(board, limit, game=game, options=options) + async def analyse(self, board, limit, *, multipv=None, game=None, searchmoves=None, options={}): + analysis = await self.analysis(board, limit, game=game, searchmoves=searchmoves, options=options) with analysis: await analysis.wait() @@ -1026,11 +1026,11 @@ def configure(self, options): def ping(self): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.ping(), self.timeout), self.protocol.loop).result() - def play(self, board, limit, *, game=None, options={}): - return asyncio.run_coroutine_threadsafe(self.protocol.play(board, limit, game=game, options=options), self.protocol.loop).result() + def play(self, board, limit, *, game=None, searchmoves=None, options={}): + return asyncio.run_coroutine_threadsafe(self.protocol.play(board, limit, game=game, searchmoves=searchmoves, options=options), self.protocol.loop).result() - def analyse(self, board, limit, *, multipv=None, game=None, options={}): - return asyncio.run_coroutine_threadsafe(self.protocol.analyse(board, limit, multipv=multipv, game=game, options=options), self.protocol.loop).result() + def analyse(self, board, limit, *, multipv=None, game=None, searchmoves=None, options={}): + return asyncio.run_coroutine_threadsafe(self.protocol.analyse(board, limit, multipv=multipv, game=game, searchmoves=searchmoves, options=options), self.protocol.loop).result() def quit(self): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.quit(), self.timeout), self.protocol.loop).result() From 1078d97b71fedfb6b9098db5af4b5d2aad12f331 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 17:09:48 +0100 Subject: [PATCH 0091/1451] implement sync wrapper around analysis result --- chess/engine.py | 73 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 50 insertions(+), 23 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 72d4e8b80..cab69f6e4 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1032,6 +1032,11 @@ def play(self, board, limit, *, game=None, searchmoves=None, options={}): def analyse(self, board, limit, *, multipv=None, game=None, searchmoves=None, options={}): return asyncio.run_coroutine_threadsafe(self.protocol.analyse(board, limit, multipv=multipv, game=game, searchmoves=searchmoves, options=options), self.protocol.loop).result() + def analysis(self, board, limit=None, *, multipv=None, game=None, searchmoves=None, options={}): + return SimpleAnalysisResult( + asyncio.run_coroutine_threadsafe(self.protocol.analysis(board, limit, multipv=multipv, game=game, searchmoves=searchmoves, options=options), self.protocol.loop).result(), + self.protocol.loop) + def quit(self): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.quit(), self.timeout), self.protocol.loop).result() @@ -1054,6 +1059,45 @@ def __exit__(self, a, b, c): self.close() +class SimpleAnalysisResult: + def __init__(self, inner, loop): + self.inner = inner + self.loop = loop + + @property + def info(self): + async def _get(): + return self.inner.info.copy() + return asyncio.run_coroutine_threadsafe(_get(), self.loop).result() + + @property + def multipv(self): + async def _get(): + return [info.copy() for info in self.inner.multipv] + return asyncio.run_coroutine_threadsafe(_get(), self.loop).result() + + def stop(self): + self.loop.call_soon_threadsafe(self.inner.stop) + + def wait(self): + return asyncio.run_coroutine_threadsafe(self.inner.wait(), self.loop).result() + + def __iter__(self): + return self + + def __next__(self): + try: + return asyncio.run_coroutine_threadsafe(self.inner.__anext__(), self.loop).result() + except StopAsyncIteration: + raise StopIteration + + def __enter__(self): + return self + + def __exit__(self, a, b, c): + self.stop() + + async def async_main(): transport, engine = await popen_uci(sys.argv[1:]) print(engine.options) @@ -1092,33 +1136,16 @@ def main(): with SimpleEngine.popen_uci(sys.argv[1:], setpgrp=True) as engine: print(engine.protocol.options) - #print("PING") - #try: - # engine.ping() - #except asyncio.TimeoutError: - # print("timeout !!!!!") - #print("PONG") - - #engine.configure({ - # "Contempt": 40, - #}) - - limit = Limit(movetime=1000) - board = chess.Board() - while not board.is_game_over(): - play_result = engine.play(board, limit, game="foo") # , config={"Contempt": 20}) - print("PLAYED", play_result) - board.push(play_result.move) - break - - engine.protocol.send_line("d") + with engine.analysis(board) as analysis: + for info in analysis: + if "123" in info: + break engine.quit() - print("QUIT") if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) - #main() - asyncio.run(async_main()) + main() + #asyncio.run(async_main()) From 58dd4efe613a83bd90587663ccc2fd4efacab87a Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 18:29:41 +0100 Subject: [PATCH 0092/1451] document and simplify Cp and Mate --- chess/engine.py | 168 ++++++++++++++++++++++++++++++------------------ 1 file changed, 106 insertions(+), 62 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index cab69f6e4..1c57e62bc 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1,3 +1,4 @@ +import abc import asyncio import concurrent.futures import functools @@ -157,18 +158,12 @@ def parse(self, value): raise EngineError("invalid line-break in string option {}".format(self.name)) return value else: - # Unknown option type. - if value is True: - return "true" - elif value is False: - return "false" - elif value is None: - return None - else: - return str(value) + raise EngineError("unknown option type: {}", self.type) class Limit: + """Search termination condition.""" + def __init__(self, wtime=None, btime=None, winc=None, binc=None, movestogo=None, depth=None, nodes=None, mate=None, movetime=None): self.wtime = wtime self.btime = btime @@ -190,13 +185,99 @@ def __repr__(self): return "<{} at {} (move={}, ponder={})>".format(type(self).__name__, hex(id(self)), self.move, self.ponder) +class Score(abc.ABC): + """ + Evaluation of a position. + + The score can be Cp (centi-pawns) or Mate. A positive value indicates an + advantage. + + >>> cp = Cp(20) + >>> cp.score() + 20 + >>> cp.mate() is None + True + >>> cp.is_mate() + False + + >>> mate = Mate.from_moves(-3) + >>> mate.score() is None + True + >>> mate.mate() + -3 + >>> mate.is_mate() + True + + There is a total order defined on centi-pawn and mate scores. + + >>> from chess.engine import Cp, Mate + >>> + >>> Mate.minus(0) < Mate.minus(1) < Cp(-50) < Cp(200) < Mate.plus(4) < Mate.plus(0) + True + + Scores are usually given from the point of view of the side to move. They + can be negated to change the point of view: + + >>> -Cp(20) + Cp(-20) + + >>> -Mate.from_moves(4) + Mate.minus(4) + + >>> # Careful with Mate.from_moves(0)! + >>> Mate.minus(0) != Mate.plus(0) + True + >>> Mate.from_moves(0) + Mate.minus(0) + >>> -Mate.from_moves(0) + Mate.plus(0) + """ + + @abc.abstractmethod + def score(self, mate_score=None): + """ + Returns a centi-pawn score or ``None``. + + You can optionally pass a large value to convert mate scores to + centi-pawn scores. + + >>> from chess.engine import Cp, Mate + >>> + >>> cp = Cp(-300) + >>> cp.score() + -300 + >>> + >>> mate = Mate.from_moves(5) + >>> mate.score() is None + True + >>> mate.score(100000) + 99995 + """ + raise NotImplementedError + + @abc.abstractmethod + def mate(self): + """Returns a mate score or ``None``.""" + raise NotImplementedError + + def is_mate(self): + """Tests if this is a mate score.""" + return self.mate() is not None + + @abc.abstractmethod + def __neg__(self): + raise NotImplementedError + + @functools.total_ordering -class Cp: +class Cp(Score): + """Centi-pawn score.""" + def __init__(self, cp): self.cp = cp - def is_mate(self): - return False + def mate(self): + return None def score(self, mate_score=None): return self.cp @@ -227,46 +308,20 @@ def __mul__(self, scalar): __rmul__ = __mul__ - def __truediv__(self, scalar): - try: - return Cp(self.cp / scalar) - except TypeError: - return NotImplemented - - def __floordiv__(self, scalar): - try: - return Cp(self.cp // scalar) - except TypeError: - return NotImplemented - def __neg__(self): return Cp(-self.cp) def __pos__(self): return Cp(self.cp) - def __int__(self): - return self.cp - def __abs__(self): return Cp(abs(self.cp)) - def __float__(self): - return float(self.cp) - def __eq__(self, other): try: - return self.cp == other.cp + return self.cp == other.score() except AttributeError: - pass - - try: - other.winning - return False - except AttributeError: - pass - - return NotImplemented + return NotImplemented def __lt__(self, other): try: @@ -283,14 +338,13 @@ def __lt__(self, other): @functools.total_ordering -class Mate: +class Mate(Score): + """Mate score.""" + def __init__(self, moves, winning): self.moves = abs(moves) self.winning = winning ^ (moves < 0) - def is_mate(self): - return True - def score(self, mate_score=None): if mate_score is None: return None @@ -299,6 +353,10 @@ def score(self, mate_score=None): else: return -mate_score + self.moves + def mate(self): + # Careful: Conflates Mate.plus(0) and Mate.minus(0)! + return self.moves if self.winning else -self.moves + @classmethod def from_moves(cls, moves): return Mate(abs(moves), moves > 0) @@ -326,26 +384,11 @@ def __neg__(self): def __pos__(self): return Mate(self.moves, self.winning) - def __int__(self): - # Careful: Conflates Mate.plus(0) and Mate.minus(0)! - return self.moves - - def __float__(self): - return float(int(self)) - def __eq__(self, other): try: - return self.moves == other.moves and self.winning == other.winning + return other.is_mate() and self.moves == other.moves and self.winning == other.winning except AttributeError: - pass - - try: - other.cp - return False - except AttributeError: - pass - - return NotImplemented + return NotImplemented def __lt__(self, other): try: @@ -1137,8 +1180,9 @@ def main(): print(engine.protocol.options) board = chess.Board() - with engine.analysis(board) as analysis: + with engine.analysis(board, Limit(movetime=3000)) as analysis: for info in analysis: + print("!!!", analysis.multipv) if "123" in info: break From 7905dc0ce4250808db84b7de7a62097fcb6b5ec6 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 18:31:40 +0100 Subject: [PATCH 0093/1451] add Mate.__abs__ --- chess/engine.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index 1c57e62bc..c4e0111d2 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -384,6 +384,9 @@ def __neg__(self): def __pos__(self): return Mate(self.moves, self.winning) + def __abs__(self): + return Mate(self.moves, True) + def __eq__(self, other): try: return other.is_mate() and self.moves == other.moves and self.winning == other.winning From 7cbdbef975cc3abf631aeaf319ccbe20be3a9c7d Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 18:52:26 +0100 Subject: [PATCH 0094/1451] start engine docs --- chess/engine.py | 35 ++++++++--------------------------- docs/engine.rst | 5 +++++ docs/index.rst | 1 + 3 files changed, 14 insertions(+), 27 deletions(-) create mode 100644 docs/engine.rst diff --git a/chess/engine.py b/chess/engine.py index c4e0111d2..e89553bbd 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -189,24 +189,8 @@ class Score(abc.ABC): """ Evaluation of a position. - The score can be Cp (centi-pawns) or Mate. A positive value indicates an - advantage. - - >>> cp = Cp(20) - >>> cp.score() - 20 - >>> cp.mate() is None - True - >>> cp.is_mate() - False - - >>> mate = Mate.from_moves(-3) - >>> mate.score() is None - True - >>> mate.mate() - -3 - >>> mate.is_mate() - True + The score can be :class:`~chess.engine.Cp` (centi-pawns) or + :class:`~chess.engine.Mate`. A positive value indicates an advantage. There is a total order defined on centi-pawn and mate scores. @@ -223,14 +207,6 @@ class Score(abc.ABC): >>> -Mate.from_moves(4) Mate.minus(4) - - >>> # Careful with Mate.from_moves(0)! - >>> Mate.minus(0) != Mate.plus(0) - True - >>> Mate.from_moves(0) - Mate.minus(0) - >>> -Mate.from_moves(0) - Mate.plus(0) """ @abc.abstractmethod @@ -257,7 +233,12 @@ def score(self, mate_score=None): @abc.abstractmethod def mate(self): - """Returns a mate score or ``None``.""" + """ + Returns a mate score or ``None``. + + :warning: This conflates ``Mate.minus(0)`` (we are mated) and + ``Mate.plus(0)`` (we have given mate) to ``0``. + """ raise NotImplementedError def is_mate(self): diff --git a/docs/engine.rst b/docs/engine.rst new file mode 100644 index 000000000..ca41b228d --- /dev/null +++ b/docs/engine.rst @@ -0,0 +1,5 @@ +Experimental Engine API +======================= + +.. autoclass:: chess.engine.Score + :members: diff --git a/docs/index.rst b/docs/index.rst index 1bc779728..a9c1159b5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -11,6 +11,7 @@ Contents polyglot gaviota syzygy + engine uci svg variant From 8b62756f619bc1e23ba4141d7c7e05cf5aec4803 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 19:05:34 +0100 Subject: [PATCH 0095/1451] temporarily add reference docs --- docs/engine.rst | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/engine.rst b/docs/engine.rst index ca41b228d..4ca4e2b0e 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -3,3 +3,23 @@ Experimental Engine API .. autoclass:: chess.engine.Score :members: + +Reference +--------- + +.. autofunction:: chess.engine.popen_uci + +.. autoclass:: chess.engine.UciProtocol + :members: + +.. autoclass:: chess.engine.PlayResult + :members: + +.. autoclass:: chess.engine.AnalysisResult + :members: + +.. autoclass:: chess.engine.SimpleEngine + :members: + +.. autoclass:: chess.engine.SimpleAnalysisResult + :members: From ad7d579b477a798c2127bef3b998c280db75be3d Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 19:23:57 +0100 Subject: [PATCH 0096/1451] make chess.engine.EngineProtocol an ABC --- chess/engine.py | 48 +++++++++++++++++++++++++++++++++++++++--------- docs/engine.rst | 3 +++ 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index e89553bbd..1916c40de 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -389,7 +389,7 @@ def __lt__(self, other): return other > self -class EngineProtocol(asyncio.SubprocessProtocol): +class EngineProtocol(asyncio.SubprocessProtocol, metaclass=abc.ABCMeta): def __init__(self): self.loop = get_running_loop() self.transport = None @@ -494,6 +494,43 @@ def __repr__(self): pid = self.transport.get_pid() if self.transport is not None else None return "<{} (pid={})>".format(type(self).__name__, pid) + @abc.abstractmethod + async def ping(self): + """ + Ping the engine and wait for a response. Used to ensure the engine + is still alive and idle. + """ + raise NotImplementedError + + async def configure(self, options): + """Configure global engine options.""" + raise NotImplementedError + + @abc.abstractmethod + async def play(self, board, limit, *, game=None, searchmoves=None, options={}): + """ + Search for the best move in a position. + """ + raise NotImplementedError + + async def analyse(self, board, limit, *, multipv=None, game=None, searchmoves=None, options={}): + """ + Analyse a position. + """ + analysis = await self.analysis(board, limit, game=game, searchmoves=searchmoves, options=options) + + with analysis: + await analysis.wait() + + return analysis.info if multipv is None else analysis.multipv + + @abc.abstractmethod + async def analysis(self, board, limit=None, *, multipv=None, game=None, searchmoves=None, options={}): + """ + Start analysing a position. + """ + raise NotImplementedError + @classmethod async def popen(cls, command, *, setpgrp=False, **kwargs): if not isinstance(command, list): @@ -931,14 +968,6 @@ def cancel(self, engine): return await self.communicate(Command) - async def analyse(self, board, limit, *, multipv=None, game=None, searchmoves=None, options={}): - analysis = await self.analysis(board, limit, game=game, searchmoves=searchmoves, options=options) - - with analysis: - await analysis.wait() - - return analysis.info if multipv is None else analysis.multipv - async def quit(self): self.send_line("quit") await self.returncode @@ -1072,6 +1101,7 @@ def close(self): @classmethod def popen_uci(cls, command, *, timeout=10.0, **popen_args): + """Spawn an UCI engine.""" async def background(future): transport, protocol = await asyncio.wait_for(UciProtocol.popen(command, **popen_args), timeout) future.set_result(cls(transport, protocol, timeout=timeout)) diff --git a/docs/engine.rst b/docs/engine.rst index 4ca4e2b0e..04a00f250 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -9,6 +9,9 @@ Reference .. autofunction:: chess.engine.popen_uci +.. autoclass:: chess.engine.EngineProtocol + :members: + .. autoclass:: chess.engine.UciProtocol :members: From 40f63ca1c9e2a22b85361720e663b149c3a75fd1 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 20:34:23 +0100 Subject: [PATCH 0097/1451] document chess.engine.Option --- chess/engine.py | 22 ++++++++++++++++----- docs/engine.rst | 52 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 1916c40de..c4539baa2 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -25,6 +25,9 @@ KORK = object() +MANAGED_UCI_OPTIONS = ["uci_chess960", "uci_variant", "uci_analysemode", "multipv", "ponder"] + + def setup_event_loop(): """ Creates and sets up a new asyncio event loop that is capable of spawning @@ -32,7 +35,7 @@ def setup_event_loop(): Uses polling to watch subprocesses when not running in the main thread. - Note that this sets a global event loop policy. + Note that this sets a global event loop policy for the entire process. """ if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) @@ -87,7 +90,8 @@ def run_in_background(coroutine): Runs ``coroutine(future)`` in a new event loop on a background thread. Blocks and returns the *future* result as soon as it is resolved. - The coroutine continues running in the background until it is complete. + The coroutine and all remaining tasks continue running in the background + until it is complete. """ assert asyncio.iscoroutinefunction(coroutine) @@ -150,9 +154,9 @@ def parse(self, value): if value not in (self.var or []): raise EngineError("invalid value for combo option {}, got: {} (available: {})".format(self.name, value, ", ".join(self.var))) return value - elif self.type == "button": + elif self.type in ["button", "reset", "save"]: return None - elif self.type == "string": + elif self.type in ["string", "file", "path"]: value = str(value) if "\n" in value or "\r" in value: raise EngineError("invalid line-break in string option {}".format(self.name)) @@ -160,6 +164,9 @@ def parse(self, value): else: raise EngineError("unknown option type: {}", self.type) + def is_managed_uci(self): + return self.name.lower() in MANAGED_UCI_OPTIONS + class Limit: """Search termination condition.""" @@ -175,8 +182,13 @@ def __init__(self, wtime=None, btime=None, winc=None, binc=None, movestogo=None, self.mate = mate self.movetime = movetime + def __repr__(self): + return "{}({})".format(type(self).__name__, ", ".join("{}={}".format(attr, repr(getattr(self, attr))) for attr in ["wtime", "btime", "winc", "binc", "movestogo", "depth", "nodes", "mate", "movetime" if getattr(self, attr) is not None])) + class PlayResult: + """Best move according to the engine.""" + def __init__(self, move, ponder): self.move = move self.ponder = ponder @@ -763,7 +775,7 @@ def _setoption(self, name, value): def _configure(self, options): for name, value in options.items(): - if name.lower() in ["uci_chess960", "uci_variant", "uci_analysemode", "multipv", "ponder"]: + if name.lower() in MANAGED_UCI_OPTIONS: raise EngineError("cannot set {} which is automatically managed".format(name)) else: self._setoption(name, value) diff --git a/docs/engine.rst b/docs/engine.rst index 04a00f250..baa3d9f9f 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -1,12 +1,60 @@ Experimental Engine API ======================= +.. autoclass:: chess.engine.Option + :members: + + .. py:attribute:: name + + The name of the option. + + .. py:attribute:: type + + The type of the option. + + +--------+-----+------+------------------------------------------------+ + | type | UCI | CECP | value | + +========+=====+======+================================================+ + | check | X | X | ``True`` or ``False`` | + +--------+-----+------+------------------------------------------------+ + | button | X | X | ``None`` | + +--------+-----+------+------------------------------------------------+ + | reset | | X | ``None`` | + +--------+-----+------+------------------------------------------------+ + | save | | X | ``None`` | + +--------+-----+------+------------------------------------------------+ + | string | X | X | string without line breaks | + +--------+-----+------+------------------------------------------------+ + | file | | X | string, interpreted as the path to a file | + +--------+-----+------+------------------------------------------------+ + | path | | X | string, interpreted as the path to a directory | + +--------+-----+------+------------------------------------------------+ + + .. py:attribute:: default + + The default value of the option. + + .. py:attribute:: min + + The minimum integer value of a *spin* option. + + .. py:attribute:: max + + The maximum integer value of a *spin* option. + + .. py:attribute:: var + + A list of allowed string values for a *combo* option. + .. autoclass:: chess.engine.Score :members: Reference --------- +http://hgm.nubati.net/CECP.html +https://www.chessprogramming.org/UCI + .. autofunction:: chess.engine.popen_uci .. autoclass:: chess.engine.EngineProtocol @@ -26,3 +74,7 @@ Reference .. autoclass:: chess.engine.SimpleAnalysisResult :members: + +.. autofunction:: chess.engine.setup_event_loop + +.. autofunction:: chess.engine.run_in_background From 37add7b116e4a57f16b9b3cac01eb79579e7d434 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 20:38:18 +0100 Subject: [PATCH 0098/1451] fix syntax error --- chess/engine.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index c4539baa2..94cd812cf 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -183,7 +183,11 @@ def __init__(self, wtime=None, btime=None, winc=None, binc=None, movestogo=None, self.movetime = movetime def __repr__(self): - return "{}({})".format(type(self).__name__, ", ".join("{}={}".format(attr, repr(getattr(self, attr))) for attr in ["wtime", "btime", "winc", "binc", "movestogo", "depth", "nodes", "mate", "movetime" if getattr(self, attr) is not None])) + return "{}({})".format( + type(self).__name__, + ", ".join("{}={}".format(attr, repr(getattr(self, attr))) + for attr in ["wtime", "btime", "winc", "binc", "movestogo", "depth", "nodes", "mate", "movetime"] + if getattr(self, attr) is not None)) class PlayResult: From 4cedf953b09792716214855d1a6990c92b73a610 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 21:03:05 +0100 Subject: [PATCH 0099/1451] more engine api docs --- chess/engine.py | 34 ++++++++++++++++++++++++++++++++++ docs/engine.rst | 37 ++++++++++++++++++++++++++++++++----- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 94cd812cf..c259a015f 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -643,6 +643,12 @@ def engine_terminated(self, engine, exc): class UciProtocol(EngineProtocol): + """ + An implementation of the + `Universal Chess Interface `_ + protocol. + """ + def __init__(self): super().__init__() self.options = UciOptionMap() @@ -1034,6 +1040,14 @@ def __repr__(self): return "{}({})".format(type(self).__name__, dict(self.items())) +class XBoardProtocol(EngineProtocol): + """ + An implementation of the + `XBoard protocol `_ (CECP). + """ + pass + + class AnalysisResult: def __init__(self, stop=None): self._stop = stop @@ -1086,12 +1100,22 @@ async def popen_uci(command, **kwargs): return await UciProtocol.popen(command, **kwargs) +async def popen_xboard(command, **kwargs): + return await XBoardProtocol.popen(command, **kwargs) + + class SimpleEngine: def __init__(self, transport, protocol, *, timeout=10.0): self.transport = transport self.protocol = protocol self.timeout = timeout + @property + def options(self): + async def _get(): + return self.protocol.options.copy() + return asyncio.run_coroutine_threadsafe(_get(), self.protocol.loop).result() + def configure(self, options): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.configure(options), self.timeout), self.protocol.loop).result() @@ -1125,6 +1149,16 @@ async def background(future): return run_in_background(background) + @classmethod + def popen_xboard(cls, command, *, timeout=10.0, **popen_args): + """Spawn an XBoard engine.""" + async def background(future): + transport, protocol = await asyncio.wait_for(XBoardProtocol.popen(command, **popen_args), timeout) + future.set_result(cls(transport, protocol, timeout=timeout)) + await protocol.returncode + + return run_in_background(background) + def __enter__(self): return self diff --git a/docs/engine.rst b/docs/engine.rst index baa3d9f9f..915239e92 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -1,6 +1,25 @@ Experimental Engine API ======================= +Options +------- + +:func:`~chess.EngineProtocol.configure()`, +:func:`~chess.EngineProtocol.play()`, +:func:`~chess.EngineProtocol.analyse()` and +:func:`~chess.EngineProtocol.analysis()` accept a dictionary of options. + +>>> import chess.engine +>>> +>>> engine = chess.engine.SimpleEngine.popen_uci("stockfish") +>>> +>>> # Check available options. +>>> engine.options["Hash"] +Option(name='Hash', type='spin', default=16, min=1, max=131072, var=[]) +>>> +>>> # Set an option. +>>> engine.configure({"Hash": 32}) + .. autoclass:: chess.engine.Option :members: @@ -52,16 +71,24 @@ Experimental Engine API Reference --------- -http://hgm.nubati.net/CECP.html -https://www.chessprogramming.org/UCI - .. autofunction:: chess.engine.popen_uci +.. autofunction:: chess.engine.popen_xboard + .. autoclass:: chess.engine.EngineProtocol - :members: + :members: configure, play, analyse, analysis, ping + + .. py:attribute:: options + + Dictionary of available options. + + .. py:attribute:: returncode + + Exit code of the process. Future. .. autoclass:: chess.engine.UciProtocol - :members: + +.. autoclass:: chess.engine.XBoardProtocol .. autoclass:: chess.engine.PlayResult :members: From e97769805ff636dc3ec307392c5cb4c483f6e519 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 21:21:07 +0100 Subject: [PATCH 0100/1451] document analyse --- docs/engine.rst | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/engine.rst b/docs/engine.rst index 915239e92..678cd2dda 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -1,6 +1,23 @@ Experimental Engine API ======================= +Evaluate a position +------------------- + +>>> import chess.engine +>>> +>>> engine = chess.engine.SimpleEngine.popen_uci("stockfish") +>>> +>>> board = chess.Board("r1bqkbnr/p1pp1ppp/1pn5/4p3/2B1P3/5Q2/PPPP1PPP/RNB1K1NR w KQkq - 2 4") +>>> limit = chess.engine.Limit(depth=20) +>>> +>>> info = engine.analyse(board, limit) +>>> info["score"] +Mate.plus(1) + +.. autoclass:: chess.engine.Score + :members: + Options ------- @@ -65,9 +82,6 @@ Option(name='Hash', type='spin', default=16, min=1, max=131072, var=[]) A list of allowed string values for a *combo* option. -.. autoclass:: chess.engine.Score - :members: - Reference --------- From 29a1bffae99fadb52d823650fa7309c3d170c9bb Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 21:33:45 +0100 Subject: [PATCH 0101/1451] document analyse --- chess/engine.py | 18 +++++++++++++++++- docs/engine.rst | 3 +++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index c259a015f..146225180 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -531,7 +531,23 @@ async def play(self, board, limit, *, game=None, searchmoves=None, options={}): async def analyse(self, board, limit, *, multipv=None, game=None, searchmoves=None, options={}): """ - Analyse a position. + Analyses a position and returns an info dictionary. + + :param board: The position to analyse. The entire move stack will be + sent to the engine. + :param limit: An instance of :class:`~chess.engine.Limit` that + determines when to stop the analysis. + :param multipv: Optional. Analyse multiple root moves. Will return a list of + at most *multipv* dictionaries rather than just a single + info dictionary. + :param game: Optional. An arbitrary object that identifies the game. + Will automatically clear hashtables if the object is not equal + to the previous game. + :param searchmoves: Optional. Limit analysis to a list of root moves. + :param options: Optional. A dictionary of engine options for the + analysis. The previous configuration will be restored after the + analysis is complete. You can permanently apply a configuration + with :func:`~chess.engine.EngineProtocol.configure()`. """ analysis = await self.analysis(board, limit, game=game, searchmoves=searchmoves, options=options) diff --git a/docs/engine.rst b/docs/engine.rst index 678cd2dda..5192a0648 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -15,6 +15,9 @@ Evaluate a position >>> info["score"] Mate.plus(1) +.. autoclass:: chess.engine.EngineProtocol + :members: analyse + .. autoclass:: chess.engine.Score :members: From c0d7aeadb4f516ca8786bafb29ae5f74ffde10e1 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 21:38:32 +0100 Subject: [PATCH 0102/1451] also show analysis with cp score --- docs/engine.rst | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/engine.rst b/docs/engine.rst index 5192a0648..994819e4b 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -8,10 +8,13 @@ Evaluate a position >>> >>> engine = chess.engine.SimpleEngine.popen_uci("stockfish") >>> ->>> board = chess.Board("r1bqkbnr/p1pp1ppp/1pn5/4p3/2B1P3/5Q2/PPPP1PPP/RNB1K1NR w KQkq - 2 4") ->>> limit = chess.engine.Limit(depth=20) +>>> board = chess.Board() +>>> info = engine.analyse(board, chess.engine.Limit(movetime=100)) +>>> info["score"] +Cp(20) >>> ->>> info = engine.analyse(board, limit) +>>> board = chess.Board("r1bqkbnr/p1pp1ppp/1pn5/4p3/2B1P3/5Q2/PPPP1PPP/RNB1K1NR w KQkq - 2 4") +>>> info = engine.analyse(board, chess.engine.Limit(depth=20)) >>> info["score"] Mate.plus(1) From 9047ef0cf2cc288464696fad75428fc9cd884d7d Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 21:42:55 +0100 Subject: [PATCH 0103/1451] improve parsing of engine option defaults --- chess/engine.py | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 146225180..7faa7e57a 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -732,24 +732,12 @@ def _option(self, engine, arg): if current_var is not None: var.append(" ".join(current_var)) + name = " ".join(name) type = " ".join(type) - default = " ".join(default) - if type == "check": - if default == "true": - default = True - elif default == "false": - default = False - else: - default = None - elif type == "spin": - try: - default = int(default) - except ValueError: - LOGGER.exception("exception parsing option spin default") - default = None - - option = Option(" ".join(name), type, default, min, max, var) + + without_default = Option(name, type, None, min, max, var) + option = Option(name, type, without_default.parse(default), min, max, var) engine.options[option.name] = option return await self.communicate(Command) From 548ef459f7248d79a305608f9fdda9d576a6cbe6 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 22:08:44 +0100 Subject: [PATCH 0104/1451] more engine docs --- chess/engine.py | 16 +++++++++++++-- docs/engine.rst | 54 +++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 7faa7e57a..90fee50a7 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -191,7 +191,7 @@ def __repr__(self): class PlayResult: - """Best move according to the engine.""" + """Result of :func:`chess.engine.EngineProtocol.play()`.""" def __init__(self, move, ponder): self.move = move @@ -525,7 +525,19 @@ async def configure(self, options): @abc.abstractmethod async def play(self, board, limit, *, game=None, searchmoves=None, options={}): """ - Search for the best move in a position. + Play a position. + + :param board: The position. + :param limit: An instance of :class:`~chess.engine.Limit` that + determines when to stop thinking. + :param game: Optional. An arbitrary object that identifies the game. + Will automatically clear hashtables if the object is not equal + to the previous game. + :param searchmoves: Optional. Consider only root moves from this list. + :param options: Optional. A dictionary of engine options for the + analysis. The previous configuration will be restored after the + analysis is complete. You can permanently apply a configuration + with :func:`~chess.engine.EngineProtocol.configure()`. """ raise NotImplementedError diff --git a/docs/engine.rst b/docs/engine.rst index 994819e4b..ec8fdedde 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -1,8 +1,54 @@ Experimental Engine API ======================= -Evaluate a position -------------------- +Playing +------- + +Example: Let Stockfish play against itself, 100 milliseconds per move. + +>>> import chess.engine +>>> +>>> engine = chess.engine.SimpleEngine.popen_uci("stockfish") +>>> +>>> board = chess.Board() +>>> while not board.is_game_over(): +... result = engine.play(board, chess.engine.Limit(movetime=100)) +... board.push(result.move) + +.. code:: python + + import asyncio + import chess.engine + + async def main(): + transport, engine = await chess.engine.popen_uci("stockfish") + + board = chess.Board() + while not board.is_game_over(): + result = await engine.play(board, chess.engine.Limit(movetime=100)) + board.push(result.move) + + chess.engine.setup_event_loop() + asyncio.run(main()) + +.. autoclass:: chess.engine.EngineProtocol + :members: play + +.. autoclass:: chess.engine.PlayResult + :members: + + .. py:attribute:: move + + The best move accordig to the engine. + + .. py:attribute:: ponder + + The response that the engine expects after *move*, or ``None``. + +Analyse +------- + +Example: Analyse and evaluate positions. >>> import chess.engine >>> @@ -24,8 +70,8 @@ Mate.plus(1) .. autoclass:: chess.engine.Score :members: -Options -------- +Configure +--------- :func:`~chess.EngineProtocol.configure()`, :func:`~chess.EngineProtocol.play()`, From af831c0414bb2b7fc42c96e124c933cff204f83e Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 22:12:52 +0100 Subject: [PATCH 0105/1451] add another async example --- docs/engine.rst | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/engine.rst b/docs/engine.rst index ec8fdedde..6959675e2 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -14,6 +14,8 @@ Example: Let Stockfish play against itself, 100 milliseconds per move. >>> while not board.is_game_over(): ... result = engine.play(board, chess.engine.Limit(movetime=100)) ... board.push(result.move) +... +>>> engine.quit() .. code:: python @@ -28,6 +30,8 @@ Example: Let Stockfish play against itself, 100 milliseconds per move. result = await engine.play(board, chess.engine.Limit(movetime=100)) board.push(result.move) + await engine.quit() + chess.engine.setup_event_loop() asyncio.run(main()) @@ -64,6 +68,23 @@ Cp(20) >>> info["score"] Mate.plus(1) +.. code:: python + + import asyncio + import chess.engine + + async def main(): + transport, engine = await chess.engine.popen_uci("stockfish") + + board = chess.Board() + info = await engine.analyse(board, chess.engine.Limit(movetime=100)) + print(info["score"]) + + await engine.quit() + + chess.engine.setup_event_loop() + asyncio.run(main()) + .. autoclass:: chess.engine.EngineProtocol :members: analyse From 7ac37be5c384c12e3a0c04faff33bb693188582c Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 2 Jan 2019 23:23:29 +0100 Subject: [PATCH 0106/1451] more detailed docs --- chess/engine.py | 52 +++++++++++++++++++++++++++++--- docs/engine.rst | 80 +++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 115 insertions(+), 17 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 90fee50a7..b26c54ba5 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -191,7 +191,7 @@ def __repr__(self): class PlayResult: - """Result of :func:`chess.engine.EngineProtocol.play()`.""" + """Returned by :func:`chess.engine.EngineProtocol.play()`.""" def __init__(self, move, ponder): self.move = move @@ -406,6 +406,8 @@ def __lt__(self, other): class EngineProtocol(asyncio.SubprocessProtocol, metaclass=abc.ABCMeta): + """Protocol for communicating with a chess engine process.""" + def __init__(self): self.loop = get_running_loop() self.transport = None @@ -527,7 +529,8 @@ async def play(self, board, limit, *, game=None, searchmoves=None, options={}): """ Play a position. - :param board: The position. + :param board: The position. The entire move stack will be sent to the + engine. :param limit: An instance of :class:`~chess.engine.Limit` that determines when to stop thinking. :param game: Optional. An arbitrary object that identifies the game. @@ -575,6 +578,11 @@ async def analysis(self, board, limit=None, *, multipv=None, game=None, searchmo """ raise NotImplementedError + @abc.abstractmethod + async def quit(self): + """Ask the engine to shut down.""" + raise NotImplementedError + @classmethod async def popen(cls, command, *, setpgrp=False, **kwargs): if not isinstance(command, list): @@ -1065,6 +1073,15 @@ class XBoardProtocol(EngineProtocol): class AnalysisResult: + """ + Handle to ongoing engine analysis. + + Can be used to asynchronously iterate over information sent by the engine. + + Automatically stops the analysis when used as a context manager. + + Returned by :func:`chess.engine.EngineProtocol.analysis()`. + """ def __init__(self, stop=None): self._stop = stop self._queue = asyncio.Queue() @@ -1084,11 +1101,13 @@ def info(self): return self.multipv[0] def stop(self): + """Stop the analysis as soon as possible.""" if self._stop and not self._finished.is_set(): self._stop() self._stop = None async def wait(self): + """Waits until the analysis is complete (or stopped).""" return await self._finished.wait() def __aiter__(self): @@ -1113,14 +1132,33 @@ def __exit__(self, a, b, c): async def popen_uci(command, **kwargs): + """ + Spawns and initializes an UCI engine. Returns a subprocess transport + and engine protocol pair. + """ return await UciProtocol.popen(command, **kwargs) async def popen_xboard(command, **kwargs): + """ + Swpans and initializes an XBoard engine. Returns a subprocess transport + and engine protocol pair. + """ return await XBoardProtocol.popen(command, **kwargs) class SimpleEngine: + """ + Synchronous wrapper around a transport and engine protocol pair. Provides + the same methods and attributes as :class:`~chess.engine.EngineProtocol`, + with blocking functions instead of coroutines. + + If *timeout* is specified, methods will raise :class:`asyncio.TimeoutError` + if an operation takes *timeout* seconds longer than expected. + + Automatically closes the transport when used as a context manager. + """ + def __init__(self, transport, protocol, *, timeout=10.0): self.transport = transport self.protocol = protocol @@ -1153,11 +1191,12 @@ def quit(self): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.quit(), self.timeout), self.protocol.loop).result() def close(self): + """Close the transport.""" self.transport.close() @classmethod def popen_uci(cls, command, *, timeout=10.0, **popen_args): - """Spawn an UCI engine.""" + """Spawn and initialize an UCI engine.""" async def background(future): transport, protocol = await asyncio.wait_for(UciProtocol.popen(command, **popen_args), timeout) future.set_result(cls(transport, protocol, timeout=timeout)) @@ -1167,7 +1206,7 @@ async def background(future): @classmethod def popen_xboard(cls, command, *, timeout=10.0, **popen_args): - """Spawn an XBoard engine.""" + """Spawn and initialize an XBoard engine.""" async def background(future): transport, protocol = await asyncio.wait_for(XBoardProtocol.popen(command, **popen_args), timeout) future.set_result(cls(transport, protocol, timeout=timeout)) @@ -1183,6 +1222,11 @@ def __exit__(self, a, b, c): class SimpleAnalysisResult: + """ + Synchronous wrapper around :class:`~chess.engine.AnalysisResult`. Returned + by :func:`chess.engine.SimpleEngine.analysis()`. + """ + def __init__(self, inner, loop): self.inner = inner self.loop = loop diff --git a/docs/engine.rst b/docs/engine.rst index 6959675e2..48d3040bd 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -1,6 +1,21 @@ Experimental Engine API ======================= +UCI and XBoard are protocols for communicating with chess engines. This module +implements a thin abstraction for playing moves and analysing positions +with both kinds of engines. + +:warning: The XBoard implementation is currently incomplete. + +:warning: This is an experimental module that may change in semver incompatible + ways. Please weigh in on the design if the provided APIs do not cover + your use case. + +The preferred way to use the API is in an ``asyncio`` event loop. +The examples also show a simple synchronous wrapper +:class:`~chess.engine.SimpleEngine` that automatically spawns an event loop +in the background. + Playing ------- @@ -38,6 +53,13 @@ Example: Let Stockfish play against itself, 100 milliseconds per move. .. autoclass:: chess.engine.EngineProtocol :members: play +.. autoclass:: chess.engine.Limit + :members: + + .. py:attribute:: depth + + Maximum search depth. + .. autoclass:: chess.engine.PlayResult :members: @@ -49,10 +71,10 @@ Example: Let Stockfish play against itself, 100 milliseconds per move. The response that the engine expects after *move*, or ``None``. -Analyse -------- +Analysing and evaluating a position +----------------------------------- -Example: Analyse and evaluate positions. +Example: >>> import chess.engine >>> @@ -67,6 +89,8 @@ Cp(20) >>> info = engine.analyse(board, chess.engine.Limit(depth=20)) >>> info["score"] Mate.plus(1) +>>> +>>> engine.quit() .. code:: python @@ -80,6 +104,10 @@ Mate.plus(1) info = await engine.analyse(board, chess.engine.Limit(movetime=100)) print(info["score"]) + board = chess.Board("r1bqkbnr/p1pp1ppp/1pn5/4p3/2B1P3/5Q2/PPPP1PPP/RNB1K1NR w KQkq - 2 4") + info = await engine.analyse(board, chess.engine.Limit(depth=20)) + print(info["score"]) + await engine.quit() chess.engine.setup_event_loop() @@ -91,8 +119,8 @@ Mate.plus(1) .. autoclass:: chess.engine.Score :members: -Configure ---------- +Options +------- :func:`~chess.EngineProtocol.configure()`, :func:`~chess.EngineProtocol.play()`, @@ -110,6 +138,29 @@ Option(name='Hash', type='spin', default=16, min=1, max=131072, var=[]) >>> # Set an option. >>> engine.configure({"Hash": 32}) +.. code:: python + + import asyncio + import chess.engine + + async def main(): + transport, protocol = await chess.engine.popen_uci("stockfish") + + # Check available options. + print(engine.options["Hash"]) + + # Set an option. + await engine.configure({"Hash": 32}) + + chess.engine.setup_event_loop() + asyncio.run(main()) + +.. autoclass:: chess.engine.EngineProtocol + + .. py:attribute:: options + + Dictionary of available options. + .. autoclass:: chess.engine.Option :members: @@ -163,11 +214,7 @@ Reference .. autofunction:: chess.engine.popen_xboard .. autoclass:: chess.engine.EngineProtocol - :members: configure, play, analyse, analysis, ping - - .. py:attribute:: options - - Dictionary of available options. + :members: ping, quit .. py:attribute:: returncode @@ -177,12 +224,19 @@ Reference .. autoclass:: chess.engine.XBoardProtocol -.. autoclass:: chess.engine.PlayResult - :members: - .. autoclass:: chess.engine.AnalysisResult :members: + .. py:attribute:: info + + A dictionary of aggregated information sent by the engine. This is + actually an alias for ``multipv[0]``. + + .. py:attribute:: multipv + + A list of dictionaries with aggregated information sent by the engine. + One item for each root move. + .. autoclass:: chess.engine.SimpleEngine :members: From ab706909f6f1cb9c1232ecaf935c3db95b974c1b Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 3 Jan 2019 00:06:09 +0100 Subject: [PATCH 0107/1451] tweak docs --- docs/engine.rst | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/engine.rst b/docs/engine.rst index 48d3040bd..94b17318a 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -1,16 +1,20 @@ -Experimental Engine API -======================= +Engine communication [experimental] +=================================== UCI and XBoard are protocols for communicating with chess engines. This module implements a thin abstraction for playing moves and analysing positions with both kinds of engines. -:warning: The XBoard implementation is currently incomplete. - :warning: This is an experimental module that may change in semver incompatible ways. Please weigh in on the design if the provided APIs do not cover your use case. + The module will eventually replace ``chess.uci`` and ``chess.xboard``, + but not before things have settled down and there has been a transition + period. + + The XBoard implementation is currently only a skeleton. + The preferred way to use the API is in an ``asyncio`` event loop. The examples also show a simple synchronous wrapper :class:`~chess.engine.SimpleEngine` that automatically spawns an event loop @@ -156,6 +160,7 @@ Option(name='Hash', type='spin', default=16, min=1, max=131072, var=[]) asyncio.run(main()) .. autoclass:: chess.engine.EngineProtocol + :members: configure .. py:attribute:: options From 3f1d7d41fbfbfb2f43bc31db631b5bdfa792256f Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 3 Jan 2019 00:50:32 +0100 Subject: [PATCH 0108/1451] document analysis --- chess/engine.py | 19 ++++++ docs/engine.rst | 151 +++++++++++++++++++++++++++++++++++------------- 2 files changed, 131 insertions(+), 39 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index b26c54ba5..63971fc22 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -575,6 +575,25 @@ async def analyse(self, board, limit, *, multipv=None, game=None, searchmoves=No async def analysis(self, board, limit=None, *, multipv=None, game=None, searchmoves=None, options={}): """ Start analysing a position. + + :param board: The position to analyse. The entire move stack will be + sent to the engine. + :param limit: Optional. An instance of :class:`~chess.engine.Limit` + that determines when to stop the analysis. Analysis is infinite + by default. + :param multipv: Optional. Analyse multiple root moves. + :param game: Optional. An arbitrary object that identifies the game. + Will automatically clear hashtables if the object is not equal + to the previous game. + :param searchmoves: Optional. Limit analysis to a list of root moves. + :param options: Optional. A dictionary of engine options for the + analysis. The previous configuration will be restored after the + analysis is complete. You can permanently apply a configuration + with :func:`~chess.engine.EngineProtocol.configure()`. + + Returns :class:`~chess.engine.AnalysisResult`, a handle that allows + asynchronously iterating over the information sent by the engine + and stopping the the analysis at any time. """ raise NotImplementedError diff --git a/docs/engine.rst b/docs/engine.rst index 94b17318a..fffcf1f5a 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -15,7 +15,8 @@ with both kinds of engines. The XBoard implementation is currently only a skeleton. -The preferred way to use the API is in an ``asyncio`` event loop. +The preferred way to use the API is with an +`asyncio `_ event loop. The examples also show a simple synchronous wrapper :class:`~chess.engine.SimpleEngine` that automatically spawns an event loop in the background. @@ -25,20 +26,24 @@ Playing Example: Let Stockfish play against itself, 100 milliseconds per move. ->>> import chess.engine ->>> ->>> engine = chess.engine.SimpleEngine.popen_uci("stockfish") ->>> ->>> board = chess.Board() ->>> while not board.is_game_over(): -... result = engine.play(board, chess.engine.Limit(movetime=100)) -... board.push(result.move) -... ->>> engine.quit() +.. code:: python + + import chess + import chess.engine + + engine = chess.engine.SimpleEngine.popen_uci("stockfish") + + board = chess.Board() + while not board.is_game_over(): + result = engine.play(board, chess.engine.Limit(movetime=100)) + board.push(result.move) + + engine.quit() .. code:: python import asyncio + import chess import chess.engine async def main(): @@ -80,25 +85,29 @@ Analysing and evaluating a position Example: ->>> import chess.engine ->>> ->>> engine = chess.engine.SimpleEngine.popen_uci("stockfish") ->>> ->>> board = chess.Board() ->>> info = engine.analyse(board, chess.engine.Limit(movetime=100)) ->>> info["score"] -Cp(20) ->>> ->>> board = chess.Board("r1bqkbnr/p1pp1ppp/1pn5/4p3/2B1P3/5Q2/PPPP1PPP/RNB1K1NR w KQkq - 2 4") ->>> info = engine.analyse(board, chess.engine.Limit(depth=20)) ->>> info["score"] -Mate.plus(1) ->>> ->>> engine.quit() +.. code:: python + + import chess + import chess.engine + + engine = chess.engine.SimpleEngine.popen_uci("stockfish") + + board = chess.Board() + info = engine.analyse(board, chess.engine.Limit(movetime=100)) + print("Score:", info["score"]) + # Score: +20 + + board = chess.Board("r1bqkbnr/p1pp1ppp/1pn5/4p3/2B1P3/5Q2/PPPP1PPP/RNB1K1NR w KQkq - 2 4") + info = engine.analyse(board, chess.engine.Limit(depth=20)) + print("Score:", info["score"]) + # Score: #1 + + engine.quit() .. code:: python import asyncio + import chess import chess.engine async def main(): @@ -107,10 +116,12 @@ Mate.plus(1) board = chess.Board() info = await engine.analyse(board, chess.engine.Limit(movetime=100)) print(info["score"]) + # Score: +20 board = chess.Board("r1bqkbnr/p1pp1ppp/1pn5/4p3/2B1P3/5Q2/PPPP1PPP/RNB1K1NR w KQkq - 2 4") info = await engine.analyse(board, chess.engine.Limit(depth=20)) print(info["score"]) + # Score: #1 await engine.quit() @@ -123,6 +134,67 @@ Mate.plus(1) .. autoclass:: chess.engine.Score :members: +Indefinite or infinite analysis +------------------------------- + +Example: Stream information from the engine and stop on an arbitrary condition. + +.. code:: python + + import chess + import chess.engine + + engine = chess.engine.SimpleEngine.popen_uci("stockfish") + + with engine.analysis(chess.Board()) as analysis: + for info in analysis: + print(info.get("score"), info.get("pv")) + + # Unusual stop condition. + if info.get("hashfull", 0) > 900: + break + + engine.quit() + +.. code:: python + + import asyncio + import chess + import chess.engine + + async def main(): + transport, engine = chess.engine.popen_uci("stockfish") + + analysis = await engine.analysis(chess.Board()) + with analysis: + async for info in analysis: + print(info.get("score"), info.get("pv")) + + # Unusual stop condition. + if info.get("hashfull", 0) > 900: + break + + await engine.quit() + + chess.engine.setup_event_loop() + asyncio.run(main()) + +.. autoclass:: chess.engine.EngineProtocol + :members: analysis + +.. autoclass:: chess.engine.AnalysisResult + :members: + + .. py:attribute:: info + + A dictionary of aggregated information sent by the engine. This is + actually an alias for ``multipv[0]``. + + .. py:attribute:: multipv + + A list of dictionaries with aggregated information sent by the engine. + One item for each root move. + Options ------- @@ -152,6 +224,7 @@ Option(name='Hash', type='spin', default=16, min=1, max=131072, var=[]) # Check available options. print(engine.options["Hash"]) + # Option(name='Hash', type='spin', default=16, min=1, max=131072, var=[]) # Set an option. await engine.configure({"Hash": 32}) @@ -211,6 +284,19 @@ Option(name='Hash', type='spin', default=16, min=1, max=131072, var=[]) A list of allowed string values for a *combo* option. +Logging +------- + +Communication is logged with debug level on a logger named ``chess.engine``. +Debug logs are useful while troubleshooting or in bug reports. + +.. code:: python + + import logging + + # Enable debug logging. + logging.basicConfig(level=logging.DEBUG) + Reference --------- @@ -229,19 +315,6 @@ Reference .. autoclass:: chess.engine.XBoardProtocol -.. autoclass:: chess.engine.AnalysisResult - :members: - - .. py:attribute:: info - - A dictionary of aggregated information sent by the engine. This is - actually an alias for ``multipv[0]``. - - .. py:attribute:: multipv - - A list of dictionaries with aggregated information sent by the engine. - One item for each root move. - .. autoclass:: chess.engine.SimpleEngine :members: From 71df2041531b26e634fcf8f8816c526acd00e75f Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 3 Jan 2019 01:01:59 +0100 Subject: [PATCH 0109/1451] document chess.engine.Limit --- chess/engine.py | 10 +++++----- docs/engine.rst | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 63971fc22..179dd49a6 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -171,16 +171,16 @@ def is_managed_uci(self): class Limit: """Search termination condition.""" - def __init__(self, wtime=None, btime=None, winc=None, binc=None, movestogo=None, depth=None, nodes=None, mate=None, movetime=None): + def __init__(self, *, movetime=None, depth=None, nodes=None, mate=None, wtime=None, btime=None, winc=None, binc=None, movestogo=None): + self.movetime = movetime + self.depth = depth + self.nodes = nodes + self.mate = mate self.wtime = wtime self.btime = btime self.winc = winc self.binc = binc self.movestogo = movestogo - self.depth = depth - self.nodes = nodes - self.mate = mate - self.movetime = movetime def __repr__(self): return "{}({})".format( diff --git a/docs/engine.rst b/docs/engine.rst index fffcf1f5a..a177aac58 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -65,9 +65,42 @@ Example: Let Stockfish play against itself, 100 milliseconds per move. .. autoclass:: chess.engine.Limit :members: + .. py:attribute:: movetime + + Search exactly *movetime* milliseconds. + .. py:attribute:: depth - Maximum search depth. + Search *depth* ply only. + + .. py:attribute:: nodes + + Search only a limited number of *nodes*. + + .. py:attribute:: mate + + Search for a mate in *mate* moves. + + .. py:attribute:: wtime + + Integer of milliseconds remaining for White. + + .. py:attribute:: btime + + Integer of milliseconds remaining for Black. + + .. py:attribute:: winc + + Fisher increment for White. + + .. py:attribute:: binc + + Fisher increment for Black. + + .. py:attribute:: movestogo + + Number of moves to the next time control. If this is not set, but wtime + and btime are, then it is sudden death. .. autoclass:: chess.engine.PlayResult :members: From 293a67826cb9743613c43184a1005a916a90be4b Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 3 Jan 2019 01:11:40 +0100 Subject: [PATCH 0110/1451] add license header and todos --- chess/engine.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index 179dd49a6..e9cff53f9 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1,3 +1,27 @@ +# -*- coding: utf-8 -*- +# +# This file is part of the python-chess library. +# Copyright (C) 2012-2019 Niklas Fiekas +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# TODO: Info parsing +# TODO: XBoard support +# TODO: Check naming. Is it too UCI specific? +# TODO: Pondering +# TODO: Test coverage + import abc import asyncio import concurrent.futures From 78982900db454769446b48aed8b520b069e5be44 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 3 Jan 2019 01:48:51 +0100 Subject: [PATCH 0111/1451] fix travis.yml (EngineTestCase) --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index f33637f0e..a16002490 100644 --- a/.travis.yml +++ b/.travis.yml @@ -72,7 +72,7 @@ script: - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv ThreeCheckTestCase; fi - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv CrazyhouseTestCase; fi - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv GiveawayTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv UciOptionMapTestCase; fi + - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv EngineTestCase; fi - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv UciEngineTestCase; fi - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv CraftyTestCase; fi - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv StockfishTestCase; fi From e0bea782901b5ce3eda59cc667277ab5710a4fe5 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 3 Jan 2019 12:18:03 +0100 Subject: [PATCH 0112/1451] python 3.4 compatibility --- chess/engine.py | 152 ++++++++++++++++++++++++++++++------------------ 1 file changed, 95 insertions(+), 57 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index e9cff53f9..d4e78c5a9 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -36,9 +36,22 @@ import os try: + # Python 3.7 from asyncio import get_running_loop except ImportError: - from asyncio import _get_running_loop as get_running_loop + try: + from asyncio import _get_running_loop as get_running_loop + except: + def get_running_loop(): + # TODO: Check + return asyncio.get_event_loop() + +try: + StopAsyncIteration +except NameError: + class StopAsyncIteration(Exception): + pass + import chess @@ -444,7 +457,7 @@ def __init__(self): self.command = None self.next_command = None - self.returncode = self.loop.create_future() + self.returncode = asyncio.Future(loop=self.loop) def connection_made(self, transport): self.transport = transport @@ -497,7 +510,8 @@ def _line_received(self, line): def line_received(self, line): pass - async def communicate(self, command_factory): + @asyncio.coroutine + def communicate(self, command_factory): command = command_factory(self.loop) if self.returncode.done(): @@ -530,26 +544,30 @@ def previous_command_finished(_): elif not self.command.result.cancelled(): self.command._cancel(self) - return await command.result + return (yield from command.result) def __repr__(self): pid = self.transport.get_pid() if self.transport is not None else None return "<{} (pid={})>".format(type(self).__name__, pid) @abc.abstractmethod - async def ping(self): + @asyncio.coroutine + def ping(self): """ Ping the engine and wait for a response. Used to ensure the engine is still alive and idle. """ raise NotImplementedError - async def configure(self, options): + @abc.abstractmethod + @asyncio.coroutine + def configure(self, options): """Configure global engine options.""" raise NotImplementedError @abc.abstractmethod - async def play(self, board, limit, *, game=None, searchmoves=None, options={}): + @asyncio.coroutine + def play(self, board, limit, *, game=None, searchmoves=None, options={}): """ Play a position. @@ -568,7 +586,8 @@ async def play(self, board, limit, *, game=None, searchmoves=None, options={}): """ raise NotImplementedError - async def analyse(self, board, limit, *, multipv=None, game=None, searchmoves=None, options={}): + @asyncio.coroutine + def analyse(self, board, limit, *, multipv=None, game=None, searchmoves=None, options={}): """ Analyses a position and returns an info dictionary. @@ -588,15 +607,16 @@ async def analyse(self, board, limit, *, multipv=None, game=None, searchmoves=No analysis is complete. You can permanently apply a configuration with :func:`~chess.engine.EngineProtocol.configure()`. """ - analysis = await self.analysis(board, limit, game=game, searchmoves=searchmoves, options=options) + analysis = yield from self.analysis(board, limit, game=game, searchmoves=searchmoves, options=options) with analysis: - await analysis.wait() + yield from analysis.wait() return analysis.info if multipv is None else analysis.multipv @abc.abstractmethod - async def analysis(self, board, limit=None, *, multipv=None, game=None, searchmoves=None, options={}): + @asyncio.coroutine + def analysis(self, board, limit=None, *, multipv=None, game=None, searchmoves=None, options={}): """ Start analysing a position. @@ -622,12 +642,14 @@ async def analysis(self, board, limit=None, *, multipv=None, game=None, searchmo raise NotImplementedError @abc.abstractmethod - async def quit(self): + @asyncio.coroutine + def quit(self): """Ask the engine to shut down.""" raise NotImplementedError @classmethod - async def popen(cls, command, *, setpgrp=False, **kwargs): + @asyncio.coroutine + def popen(cls, command, *, setpgrp=False, **kwargs): if not isinstance(command, list): command = [command] @@ -642,8 +664,8 @@ async def popen(cls, command, *, setpgrp=False, **kwargs): popen_args.update(kwargs) loop = get_running_loop() - transport, protocol = await loop.subprocess_exec(cls, *command, **popen_args) - await protocol._initialize() + transport, protocol = yield from loop.subprocess_exec(cls, *command, **popen_args) + yield from protocol._initialize() return transport, protocol @@ -659,8 +681,8 @@ def __init__(self, loop): self.state = CommandState.New self.loop = loop - self.result = self.loop.create_future() - self.finished = self.loop.create_future() + self.result = asyncio.Future(loop=loop) + self.finished = asyncio.Future(loop=loop) def _engine_terminated(self, engine, code): exc = EngineTerminatedError("engine process died unexpectedly (exit code: {})".format(type(self).__name__, code)) @@ -735,7 +757,8 @@ def __init__(self): self.board = chess.Board() self.game = None - async def _initialize(self): + @asyncio.coroutine + def _initialize(self): class Command(BaseCommand): def start(self, engine): engine.send_line("uci") @@ -803,7 +826,7 @@ def _option(self, engine, arg): option = Option(name, type, without_default.parse(default), min, max, var) engine.options[option.name] = option - return await self.communicate(Command) + return (yield from self.communicate(Command)) def _isready(self): self.send_line("isready") @@ -811,7 +834,8 @@ def _isready(self): def _ucinewgame(self): self.send_line("ucinewgame") - async def ping(self): + @asyncio.coroutine + def ping(self): class Command(BaseCommand): def start(self, engine): engine._isready() @@ -822,7 +846,7 @@ def line_received(self, engine, line): else: LOGGER.warning("%s: Unexpected engine output: %s", engine, line) - return await self.communicate(Command) + return (yield from self.communicate(Command)) def _getoption(self, option, default=None): if option in self.config: @@ -857,13 +881,14 @@ def _configure(self, options): else: self._setoption(name, value) - async def configure(self, options): + @asyncio.coroutine + def configure(self, options): class Command(BaseCommand): def start(self, engine): engine._configure(options) self.set_finished() - return await self.communicate(Command) + return (yield from self.communicate(Command)) def _position(self, board): # Select UCI_Variant and UCI_Chess960. @@ -948,7 +973,8 @@ def _go(self, limit, *, searchmoves=None, ponder=False, infinite=False): self.send_line(" ".join(builder)) - async def play(self, board, limit, *, game=None, searchmoves=None, options={}): + @asyncio.coroutine + def play(self, board, limit, *, game=None, searchmoves=None, options={}): previous_config = self.config.copy() class Command(BaseCommand): @@ -1004,9 +1030,10 @@ def _bestmove(self, engine, arg): def cancel(self, engine): engine.send_line("stop") - return await self.communicate(Command) + return (yield from self.communicate(Command)) - async def analysis(self, board, limit=None, *, multipv=None, game=None, searchmoves=None, options={}): + @asyncio.coroutine + def analysis(self, board, limit=None, *, multipv=None, game=None, searchmoves=None, options={}): previous_config = self.config.copy() class Command(BaseCommand): @@ -1055,11 +1082,12 @@ def _bestmove(self, engine, arg): def cancel(self, engine): engine.send_line("stop") - return await self.communicate(Command) + return (yield from self.communicate(Command)) - async def quit(self): + @asyncio.coroutine + def quit(self): self.send_line("quit") - await self.returncode + yield from self.returncode class UciOptionMap(collections.abc.MutableMapping): @@ -1149,18 +1177,20 @@ def stop(self): self._stop() self._stop = None - async def wait(self): + @asyncio.coroutine + def wait(self): """Waits until the analysis is complete (or stopped).""" - return await self._finished.wait() + return (yield from self._finished.wait()) def __aiter__(self): return self - async def __anext__(self): + @asyncio.coroutine + def __anext__(self): if self._seen_kork: raise StopAsyncIteration - info = await self._queue.get() + info = yield from self._queue.get() if info is KORK: self._seen_kork = True raise StopAsyncIteration @@ -1174,20 +1204,22 @@ def __exit__(self, a, b, c): self.stop() -async def popen_uci(command, **kwargs): +@asyncio.coroutine +def popen_uci(command, **kwargs): """ Spawns and initializes an UCI engine. Returns a subprocess transport and engine protocol pair. """ - return await UciProtocol.popen(command, **kwargs) + return (yield from UciProtocol.popen(command, **kwargs)) -async def popen_xboard(command, **kwargs): +@asyncio.coroutine +def popen_xboard(command, **kwargs): """ Swpans and initializes an XBoard engine. Returns a subprocess transport and engine protocol pair. """ - return await XBoardProtocol.popen(command, **kwargs) + return (yield from XBoardProtocol.popen(command, **kwargs)) class SimpleEngine: @@ -1209,7 +1241,8 @@ def __init__(self, transport, protocol, *, timeout=10.0): @property def options(self): - async def _get(): + @asyncio.coroutine + def _get(): return self.protocol.options.copy() return asyncio.run_coroutine_threadsafe(_get(), self.protocol.loop).result() @@ -1240,20 +1273,22 @@ def close(self): @classmethod def popen_uci(cls, command, *, timeout=10.0, **popen_args): """Spawn and initialize an UCI engine.""" - async def background(future): - transport, protocol = await asyncio.wait_for(UciProtocol.popen(command, **popen_args), timeout) + @asyncio.coroutine + def background(future): + transport, protocol = yield from asyncio.wait_for(UciProtocol.popen(command, **popen_args), timeout) future.set_result(cls(transport, protocol, timeout=timeout)) - await protocol.returncode + yield from protocol.returncode return run_in_background(background) @classmethod def popen_xboard(cls, command, *, timeout=10.0, **popen_args): """Spawn and initialize an XBoard engine.""" - async def background(future): - transport, protocol = await asyncio.wait_for(XBoardProtocol.popen(command, **popen_args), timeout) + @asyncio.coroutine + def background(future): + transport, protocol = yield from asyncio.wait_for(XBoardProtocol.popen(command, **popen_args), timeout) future.set_result(cls(transport, protocol, timeout=timeout)) - await protocol.returncode + yield from protocol.returncode return run_in_background(background) @@ -1276,13 +1311,15 @@ def __init__(self, inner, loop): @property def info(self): - async def _get(): + @asyncio.coroutine + def _get(): return self.inner.info.copy() return asyncio.run_coroutine_threadsafe(_get(), self.loop).result() @property def multipv(self): - async def _get(): + @asyncio.coroutine + def _get(): return [info.copy() for info in self.inner.multipv] return asyncio.run_coroutine_threadsafe(_get(), self.loop).result() @@ -1308,13 +1345,14 @@ def __exit__(self, a, b, c): self.stop() -async def async_main(): - transport, engine = await popen_uci(sys.argv[1:]) +@asyncio.coroutine +def async_main(): + transport, engine = yield from popen_uci(sys.argv[1:]) print(engine.options) - await engine.ping() + yield from engine.ping() - await engine.configure({ + yield from engine.configure({ "Contempt": 40, }) @@ -1323,12 +1361,12 @@ async def async_main(): board = chess.Board() limit = Limit(depth=20) - with await engine.analysis(board) as analysis: - async for info in analysis: - print("!", info) - if "123" in info: - break - await analysis.wait() + #with yield from engine.analysis(board) as analysis: + # async for info in analysis: + # print("!", info) + # if "123" in info: + # break + yield from analysis.wait() #try: # analysis = await asyncio.wait_for(engine.analyse(board, limit), 0.1) @@ -1339,7 +1377,7 @@ async def async_main(): #move = await engine.play(board, limit) #print("PLAY", move) - await engine.quit() + yield from engine.quit() def main(): From 2f77810e62ebae22c836a01dd3f56a595b1fcde7 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 3 Jan 2019 17:28:15 +0100 Subject: [PATCH 0113/1451] let sphinx mark coroutines --- docs/conf.py | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 7741abbec..49a1598eb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,8 +1,13 @@ # -*- coding: utf-8 -*- +import asyncio import sys import os +import sphinx +import sphinx.ext.autodoc +import sphinx.domains.python + # Import the chess module. sys.path.insert(0, os.path.abspath('..')) import chess @@ -35,3 +40,46 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = "default" + +# Mark coroutine functions and methods. +def setup(app): + app.add_directive_to_domain("py", "coroutine", PyCoroutineFunction) + app.add_directive_to_domain("py", "coroutinemethod", PyCoroutineMethod) + app.add_autodocumenter(FunctionDocumenter) + app.add_autodocumenter(MethodDocumenter) + + return { + "version": "1.0", + "parallel_read_safe": True, + } + +class PyCoroutineMixin: + def handle_signature(self, sig, signode): + ret = super().handle_signature(sig, signode) + signode.insert(0, sphinx.addnodes.desc_annotation("coroutine ", "coroutine ")) + print("inserted async!") + return ret + +class PyCoroutineFunction(PyCoroutineMixin, sphinx.domains.python.PyModulelevel): + def run(self): + self.name = "py:function" + return super().run() + +class PyCoroutineMethod(PyCoroutineMixin, sphinx.domains.python.PyClassmember): + def run(self): + self.name = "py:method" + return super().run() + +class FunctionDocumenter(sphinx.ext.autodoc.FunctionDocumenter): + def import_object(self): + ret = super().import_object() + if ret and asyncio.iscoroutinefunction(self.parent.__dict__.get(self.object_name)): + self.directivetype = "coroutine" + return ret + +class MethodDocumenter(sphinx.ext.autodoc.MethodDocumenter): + def import_object(self): + ret = super().import_object() + if ret and asyncio.iscoroutinefunction(self.parent.__dict__.get(self.object_name)): + self.directivetype = "coroutinemethod" + return ret From 6f00f156a603f2bea069240a08bf4d7318d6e2f9 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 3 Jan 2019 17:36:42 +0100 Subject: [PATCH 0114/1451] add AnalysisResult.next --- chess/engine.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index d4e78c5a9..820b2547b 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1185,6 +1185,24 @@ def wait(self): def __aiter__(self): return self + @asyncio.coroutine + def next(self): + """ + Waits for the next dictionary of information from the engine and + returns it. Returns ``None`` if the analysis has been stopped and + all information has been consumed. + + It might be more convenient to use async for (requires at least + Python 3.5): + + >>> async for info in analysis: + ... print(info) + """ + try: + return (yield from self.__anext__()) + except StopAsyncIteration: + return None + @asyncio.coroutine def __anext__(self): if self._seen_kork: From 7285d1a6ba7682dccb5aee168ca30b19d7c26c3e Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 3 Jan 2019 18:56:35 +0100 Subject: [PATCH 0115/1451] tweak setup_event_loop and run_in_background docs --- chess/engine.py | 10 ++++++---- docs/engine.rst | 2 -- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 820b2547b..736907482 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -70,14 +70,13 @@ def setup_event_loop(): Creates and sets up a new asyncio event loop that is capable of spawning and watching subprocesses. - Uses polling to watch subprocesses when not running in the main thread. + On Windows: Globally sets a proactor event loop policy. - Note that this sets a global event loop policy for the entire process. + On Unix systems: Does nothing, except when not running on the main thread. + Then it installs a child watcher that uses polling. """ if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - else: - asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy()) if sys.platform == "win32" or threading.current_thread() == threading.main_thread(): loop = asyncio.new_event_loop() @@ -1347,6 +1346,9 @@ def stop(self): def wait(self): return asyncio.run_coroutine_threadsafe(self.inner.wait(), self.loop).result() + def next(self): + return asyncio.run_coroutine_threadsafe(self.inner.next(), self.loop).result() + def __iter__(self): return self diff --git a/docs/engine.rst b/docs/engine.rst index a177aac58..b08cb9134 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -355,5 +355,3 @@ Reference :members: .. autofunction:: chess.engine.setup_event_loop - -.. autofunction:: chess.engine.run_in_background From 92f3b3d86d9765cabbe1d95f3f9ff37d1b09b708 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 3 Jan 2019 19:30:12 +0100 Subject: [PATCH 0116/1451] minor engine doc improvements --- chess/engine.py | 79 ++++++++++++++++++++++++++++++++----------------- docs/engine.rst | 10 +++---- 2 files changed, 57 insertions(+), 32 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 736907482..9dd883718 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -553,7 +553,7 @@ def __repr__(self): @asyncio.coroutine def ping(self): """ - Ping the engine and wait for a response. Used to ensure the engine + Pings the engine and waits for a response. Used to ensure the engine is still alive and idle. """ raise NotImplementedError @@ -561,7 +561,7 @@ def ping(self): @abc.abstractmethod @asyncio.coroutine def configure(self, options): - """Configure global engine options.""" + """Configures global engine options.""" raise NotImplementedError @abc.abstractmethod @@ -617,7 +617,7 @@ def analyse(self, board, limit, *, multipv=None, game=None, searchmoves=None, op @asyncio.coroutine def analysis(self, board, limit=None, *, multipv=None, game=None, searchmoves=None, options={}): """ - Start analysing a position. + Starts analysing a position. :param board: The position to analyse. The entire move stack will be sent to the engine. @@ -643,7 +643,7 @@ def analysis(self, board, limit=None, *, multipv=None, game=None, searchmoves=No @abc.abstractmethod @asyncio.coroutine def quit(self): - """Ask the engine to shut down.""" + """Asks the engine to shut down.""" raise NotImplementedError @classmethod @@ -1171,7 +1171,7 @@ def info(self): return self.multipv[0] def stop(self): - """Stop the analysis as soon as possible.""" + """Stops the analysis as soon as possible.""" if self._stop and not self._finished.is_set(): self._stop() self._stop = None @@ -1191,11 +1191,8 @@ def next(self): returns it. Returns ``None`` if the analysis has been stopped and all information has been consumed. - It might be more convenient to use async for (requires at least - Python 3.5): - - >>> async for info in analysis: - ... print(info) + It might be more convenient to use ``async for info in analysis`` + (requires at least Python 3.5). """ try: return (yield from self.__anext__()) @@ -1222,21 +1219,43 @@ def __exit__(self, a, b, c): @asyncio.coroutine -def popen_uci(command, **kwargs): +def popen_uci(command, *, setpgrp=False, **popen_args): """ - Spawns and initializes an UCI engine. Returns a subprocess transport - and engine protocol pair. + Spawns and initializes an UCI engine. + + :param command: Path of the engine executable, or a list including the + path and arguments. + :param setpgrp: Open the engine process in a new process group. This will + stop signals (such as keyboard interrupts) from propagating from the + parent process. Defaults to ``False``. + :param popen_args: Additional arguments for + `popen `_. + Do not set ``stdin``, ``stdout``, ``bufsize`` or + ``universal_newlines``. + + Returns a subprocess transport and engine protocol pair. """ - return (yield from UciProtocol.popen(command, **kwargs)) + return (yield from UciProtocol.popen(command, setpgrp=setpgrp, **popen_args)) @asyncio.coroutine -def popen_xboard(command, **kwargs): +def popen_xboard(command, *, setpgrp=False, **popen_args): """ - Swpans and initializes an XBoard engine. Returns a subprocess transport - and engine protocol pair. + Spawns and initializes an XBoard engine. + + :param command: Path of the engine executable, or a list including the + path and arguments. + :param setpgrp: Open the engine process in a new process group. This will + stop signals (such as keyboard interrupts) from propagating from the + parent process. Defaults to ``False``. + :param popen_args: Additional arguments for + `popen `_. + Do not set ``stdin``, ``stdout``, ``bufsize`` or + ``universal_newlines``. + + Returns a subprocess transport and engine protocol pair. """ - return (yield from XBoardProtocol.popen(command, **kwargs)) + return (yield from XBoardProtocol.popen(command, setpgrp=setpgrp, **popen_args)) class SimpleEngine: @@ -1245,8 +1264,8 @@ class SimpleEngine: the same methods and attributes as :class:`~chess.engine.EngineProtocol`, with blocking functions instead of coroutines. - If *timeout* is specified, methods will raise :class:`asyncio.TimeoutError` - if an operation takes *timeout* seconds longer than expected. + Methods will raise :class:`asyncio.TimeoutError` if an operation takes + *timeout* seconds longer than expected (unless *timeout* is ``None``). Automatically closes the transport when used as a context manager. """ @@ -1284,26 +1303,32 @@ def quit(self): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.quit(), self.timeout), self.protocol.loop).result() def close(self): - """Close the transport.""" + """Closes the transport.""" self.transport.close() @classmethod - def popen_uci(cls, command, *, timeout=10.0, **popen_args): - """Spawn and initialize an UCI engine.""" + def popen_uci(cls, command, *, timeout=10.0, setpgrp=False, **popen_args): + """ + Spawns and initializes an UCI engine. + Returns a :class:`~chess.engine.SimpleEngine` instance. + """ @asyncio.coroutine def background(future): - transport, protocol = yield from asyncio.wait_for(UciProtocol.popen(command, **popen_args), timeout) + transport, protocol = yield from asyncio.wait_for(UciProtocol.popen(command, setpgrp=setpgrp, **popen_args), timeout) future.set_result(cls(transport, protocol, timeout=timeout)) yield from protocol.returncode return run_in_background(background) @classmethod - def popen_xboard(cls, command, *, timeout=10.0, **popen_args): - """Spawn and initialize an XBoard engine.""" + def popen_xboard(cls, command, *, timeout=10.0, setpgrp=False, **popen_args): + """ + Spawns and initializes an XBoard engine. + Returns a :class:`~chess.engine.SimpleEngine` instance. + """ @asyncio.coroutine def background(future): - transport, protocol = yield from asyncio.wait_for(XBoardProtocol.popen(command, **popen_args), timeout) + transport, protocol = yield from asyncio.wait_for(XBoardProtocol.popen(command, setpgrp=setpgrp, **popen_args), timeout) future.set_result(cls(transport, protocol, timeout=timeout)) yield from protocol.returncode diff --git a/docs/engine.rst b/docs/engine.rst index b08cb9134..6a1bc4398 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -196,10 +196,9 @@ Example: Stream information from the engine and stop on an arbitrary condition. import chess.engine async def main(): - transport, engine = chess.engine.popen_uci("stockfish") + transport, engine = await chess.engine.popen_uci("stockfish") - analysis = await engine.analysis(chess.Board()) - with analysis: + with await engine.analysis(chess.Board()) as analysis: async for info in analysis: print(info.get("score"), info.get("pv")) @@ -321,7 +320,8 @@ Logging ------- Communication is logged with debug level on a logger named ``chess.engine``. -Debug logs are useful while troubleshooting or in bug reports. +Debug logs are useful while troubleshooting. Please also provide them +when submitting bug reports. .. code:: python @@ -342,7 +342,7 @@ Reference .. py:attribute:: returncode - Exit code of the process. Future. + Future: Exit code of the process. .. autoclass:: chess.engine.UciProtocol From 73414fa60ade8fa426c4207769129e55bffb7730 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 3 Jan 2019 19:35:34 +0100 Subject: [PATCH 0117/1451] merge info --- chess/engine.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index 9dd883718..c5abee2a9 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1160,6 +1160,11 @@ def __init__(self, stop=None): self.multipv = [{}] def post(self, info): + multipv = info.get("multipv", 1) + while len(self.multipv) < multipv: + self.multipv.append({}) + self.multipv[multipv - 1].update(info) + self._queue.put_nowait(info) def set_finished(self): From 478c4ec1ef133de9285739824b0a350377f3e202 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 3 Jan 2019 19:57:20 +0100 Subject: [PATCH 0118/1451] make get_running_loop polyfill private --- chess/engine.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index c5abee2a9..26e8a66bf 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -37,31 +37,29 @@ try: # Python 3.7 - from asyncio import get_running_loop + from asyncio import get_running_loop as _get_running_loop except ImportError: try: - from asyncio import _get_running_loop as get_running_loop + from asyncio import _get_running_loop except: - def get_running_loop(): - # TODO: Check + # Python 3.4 + def _get_running_loop(): return asyncio.get_event_loop() try: StopAsyncIteration except NameError: + # Python 3.4 class StopAsyncIteration(Exception): pass - import chess LOGGER = logging.getLogger(__name__) - KORK = object() - MANAGED_UCI_OPTIONS = ["uci_chess960", "uci_variant", "uci_analysemode", "multipv", "ponder"] @@ -445,7 +443,7 @@ class EngineProtocol(asyncio.SubprocessProtocol, metaclass=abc.ABCMeta): """Protocol for communicating with a chess engine process.""" def __init__(self): - self.loop = get_running_loop() + self.loop = _get_running_loop() self.transport = None self.buffer = { @@ -662,7 +660,7 @@ def popen(cls, command, *, setpgrp=False, **kwargs): popen_args["preexec_fn"] = os.setpgrp popen_args.update(kwargs) - loop = get_running_loop() + loop = _get_running_loop() transport, protocol = yield from loop.subprocess_exec(cls, *command, **popen_args) yield from protocol._initialize() return transport, protocol From 37f9fd2ada0581992ef4835122a89aed9a275260 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 3 Jan 2019 20:32:30 +0100 Subject: [PATCH 0119/1451] parse info from engine --- chess/engine.py | 111 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 2 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 26e8a66bf..0443c564a 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -16,7 +16,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -# TODO: Info parsing # TODO: XBoard support # TODO: Check naming. Is it too UCI specific? # TODO: Pondering @@ -1067,7 +1066,115 @@ def line_received(self, engine, line): LOGGER.warning("%s: Unexpected engine output: %s", engine, line) def _info(self, engine, arg): - self.analysis.post(arg) + # Initialize parser state. + info = {} + board = None + pv = None + score_kind = None + refutation_move = None + refuted_by = [] + currline_cpunr = None + currline_moves = [] + string = [] + + # Parameters with variable length can only be handled when the + # next parameter starts or at the end of the line. + def end_of_parameter(): + if pv is not None: + info["pv"] = pv + + if refutation_move is not None: + if not "refutation" in info: + info["refutation"] = {} + info["refutation"][refutation_move] = refuted_by + + if currline_cpunr is not None: + if not "currline" in info: + info["currline"] = {} + info["currline"][currline_cpunr] = currline_moves + + # Parse all other parameters. + current_parameter = None + for token in arg.split(" "): + if current_parameter == "string": + string.append(token) + elif not token: + # Ignore extra spaces. Those can not be directly discarded, + # because they may occur in the string parameter. + pass + elif token in ["depth", "seldepth", "time", "nodes", "pv", "multipv", "score", "currmove", "currmovenumber", "hashfull", "nps", "tbhits", "cpuload", "refutation", "currline", "ebf", "string"]: + end_of_parameter() + current_parameter = token + + pv = None + score_kind = None + refutation_move = None + refuted_by = [] + currline_cpunr = None + currline_moves = [] + + if current_parameter == "pv": + pv = [] + + if current_parameter in ["refutation", "pv", "currline"]: + board = engine.board.copy(stack=False) + elif current_parameter in ["depth", "seldepth", "time", "nodes", "multipv", "currmovenumber", "hashfull", "nps", "tbhits", "cpuload"]: + try: + info[current_parameter] = int(token) + except ValueError: + LOGGER.error("exception parsing %s from info: %r", current_parameter, arg) + elif current_parameter == "pv": + try: + pv.append(board.push_uci(token)) + except ValueError: + LOGGER.exception("exception parsing pv from info: %r, position at root: %s", arg, engine.board.fen()) + elif current_parameter == "score": + try: + if token in ["cp", "mate"]: + score_kind = token + elif token == "lowerbound": + info["lowerbound"] = True + elif token == "upperbound": + info["upperbound"] = True + elif score_kind == "cp": + info["score"] = Cp(int(token)) + elif score_kind == "mate": + info["mate"] = Mate.from_moves(int(token)) + except ValueError: + LOGGER.error("exception parsing score %s from info: %r", score_kind, arg) + elif current_parameter == "currmove": + try: + info[current_parameter] = chess.Move.from_uci(token) + except ValueError: + LOGGER.error("exception parsing %s from info: %r", current_parameter, arg) + elif current_parameter == "refutation": + try: + if refutation_move is None: + refutation_move = board.push_uci(token) + else: + refuted_by.append(board.push_uci(token)) + except ValueError: + LOGGER.exception("exception parsing refutation from info: %r, position at root: %s", arg, engine.fen()) + elif current_parameter == "currline": + try: + if currline_cpunr is None: + currline_cpunr = int(token) + else: + currline_moves.append(board.push_uci(token)) + except ValueError: + LOGGER.exception("exception parsing currline from info: %r, position at root: %s", arg, engine.fen()) + elif current_parameter == "ebf": + try: + info[current_parameter] = float(token) + except ValueError: + LOGGER.error("exception parsing %s from info: %r", current_parameter, arg) + + end_of_parameter() + + if string: + info["string"] = " ".join(string) + + self.analysis.post(info) def _bestmove(self, engine, arg): for name, value in previous_config.items(): From 608bf5026c233a11d1d85becb25e9efb62b93669 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 3 Jan 2019 20:50:51 +0100 Subject: [PATCH 0120/1451] add some basic tests with stockfish --- chess/engine.py | 8 +++++++- test.py | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index 0443c564a..0a5c94a03 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -45,6 +45,12 @@ def _get_running_loop(): return asyncio.get_event_loop() +try: + # Python 3.7 + from asyncio import all_tasks as _all_tasks +except ImportError: + _all_tasks = asyncio.Task.all_tasks + try: StopAsyncIteration except NameError: @@ -142,7 +148,7 @@ def background(): finally: try: # Finish all remaining tasks. - pending = asyncio.Task.all_tasks(loop) + pending = _all_tasks(loop) loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) # Shutdown async generators. diff --git a/test.py b/test.py index ac78c05fc..e445f8835 100755 --- a/test.py +++ b/test.py @@ -3080,6 +3080,33 @@ def test_score_ordering(self): self.assertEqual(i > j, a > b) self.assertEqual(i >= j, a >= b) + @catchAndSkip(FileNotFoundError, "need stockfish") + def test_sf_forced_mates(self): + with chess.engine.SimpleEngine.popen_uci("stockfish") as engine: + epds = [ + "1k1r4/pp1b1R2/3q2pp/4p3/2B5/4Q3/PPP2B2/2K5 b - - bm Qd1+; id \"BK.01\";", + "6k1/N1p3pp/2p5/3n1P2/4K3/1P5P/P1Pr1r2/R1R5 b - - bm Rf4+; id \"Clausthal 2014\";", + ] + + board = chess.Board() + + for epd in epds: + operations = board.set_epd(epd) + result = engine.play(board, chess.engine.Limit(mate=5), game=object()) + self.assertIn(result.move, operations["bm"], operations["id"]) + + @catchAndSkip(FileNotFoundError, "need stockfish") + def test_sf_options(self): + with chess.engine.SimpleEngine.popen_uci("stockfish") as engine: + self.assertEqual(engine.options["UCI_Chess960"].name, "UCI_Chess960") + self.assertEqual(engine.options["uci_Chess960"].type, "check") + self.assertEqual(engine.options["UCI_CHESS960"].default, False) + + @catchAndSkip(FileNotFoundError, "need stockfish") + def test_sf_quit(self): + with chess.engine.SimpleEngine.popen_uci("stockfish") as engine: + engine.quit() + class SyzygyTestCase(unittest.TestCase): From 396480476d4c4bf62f88b2be58f1463d882387bb Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 3 Jan 2019 21:49:21 +0100 Subject: [PATCH 0121/1451] wip MockTransport --- chess/engine.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index 0a5c94a03..c0c4d9e16 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -23,6 +23,7 @@ import abc import asyncio +import collections import concurrent.futures import functools import logging @@ -444,6 +445,40 @@ def __lt__(self, other): return other > self +class MockTransport: + def __init__(self, protocol): + self.protocol = protocol + self.expectations = collections.deque() + self.stdin_buffer = bytearray() + + def expect(self, expectation, responses=[]): + self.expectations.append((expectation, responses)) + + def assert_done(self): + assert not self.expectations, "pending expectations: {}".format(self.expectations) + + def get_pipe_transport(self, fd): + assert fd == 0, "expected 0 for stdin, got {}".format(fd) + return self + + def write(self, data): + self.stdin_buffer.extend(data) + while b"\n" in self.stdin_buffer: + line, self.stdin_buffer = self.stdin_buffer.split(b"\n", 1) + line = line.decode("utf-8") + + assert self.expectations, "unexpected: {}".format(line) + expectation, responses = self.expectations.popleft() + assert expectation == line, "expected {}, got: {}".format(expectation, line) + self.protocol.loop.call_soon(lambda: self.protocol.pipe_data_received(1, "\n".join(responses).encode("utf-8") + b"\n")) + + def get_pid(self): + return id(self) + + def get_returncode(self): + return 0 + + class EngineProtocol(asyncio.SubprocessProtocol, metaclass=abc.ABCMeta): """Protocol for communicating with a chess engine process.""" From 44f7b63e096670ee09adf529eb5a6783669f07a4 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 3 Jan 2019 22:08:30 +0100 Subject: [PATCH 0122/1451] make SimpleEngine.close threadsafe --- chess/engine.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index c0c4d9e16..0704f39f9 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1455,7 +1455,15 @@ def quit(self): def close(self): """Closes the transport.""" - self.transport.close() + if self.protocol.loop.is_running(): + @asyncio.coroutine + def close(): + LOGGER.debug("%s: Closing transport ...", self.protocol) + self.transport.close() + yield from self.protocol.returncode + return asyncio.run_coroutine_threadsafe(close(), self.protocol.loop).result() + else: + self.transport.close() @classmethod def popen_uci(cls, command, *, timeout=10.0, setpgrp=False, **popen_args): From ea7c323b6c24057d6e694d9f446ef3c948b79793 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 3 Jan 2019 22:19:08 +0100 Subject: [PATCH 0123/1451] test uci ping --- chess/engine.py | 3 ++- test.py | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index 0704f39f9..66206e8b0 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -450,6 +450,7 @@ def __init__(self, protocol): self.protocol = protocol self.expectations = collections.deque() self.stdin_buffer = bytearray() + self.protocol.connection_made(self) def expect(self, expectation, responses=[]): self.expectations.append((expectation, responses)) @@ -476,7 +477,7 @@ def get_pid(self): return id(self) def get_returncode(self): - return 0 + return None if self.expectations else 0 class EngineProtocol(asyncio.SubprocessProtocol, metaclass=abc.ABCMeta): diff --git a/test.py b/test.py index e445f8835..dd8d1ce7c 100755 --- a/test.py +++ b/test.py @@ -17,8 +17,10 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import asyncio import collections import copy +import contextlib import logging import os import os.path @@ -3107,6 +3109,23 @@ def test_sf_quit(self): with chess.engine.SimpleEngine.popen_uci("stockfish") as engine: engine.quit() + def test_uci_ping(self): + @asyncio.coroutine + def main(): + protocol = chess.engine.UciProtocol() + mock = chess.engine.MockTransport(protocol) + + mock.expect("uci", ["uciok"]) + yield from protocol._initialize() + mock.assert_done() + + mock.expect("isready", ["readyok"]) + yield from protocol.ping() + mock.assert_done() + + with contextlib.closing(chess.engine.setup_event_loop()) as loop: + loop.run_until_complete(main()) + class SyzygyTestCase(unittest.TestCase): From 91ff5297aade3053af73a49a6fb791d39d3dc491 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 3 Jan 2019 22:33:03 +0100 Subject: [PATCH 0124/1451] add and test UciProtocol.debug --- chess/engine.py | 10 ++++++++++ test.py | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index 66206e8b0..c437e3423 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -872,6 +872,16 @@ def _isready(self): def _ucinewgame(self): self.send_line("ucinewgame") + def debug(self, on=True): + """ + Switches debug move of the engine on or off. This does not interrupt + other ongoing operations. + """ + if on: + self.send_line("debug on") + else: + self.send_line("debug off") + @asyncio.coroutine def ping(self): class Command(BaseCommand): diff --git a/test.py b/test.py index dd8d1ce7c..b858d3e6e 100755 --- a/test.py +++ b/test.py @@ -3123,6 +3123,14 @@ def main(): yield from protocol.ping() mock.assert_done() + mock.expect("debug on", []) + protocol.debug() + mock.assert_done() + + mock.expect("debug off", []) + protocol.debug(False) + mock.assert_done() + with contextlib.closing(chess.engine.setup_event_loop()) as loop: loop.run_until_complete(main()) From f5a0a31f72287ffe6b8b0975228be4a8355c16df Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 3 Jan 2019 23:28:31 +0100 Subject: [PATCH 0125/1451] fix race conditions with global event loop policy --- chess/engine.py | 44 +++++++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index c437e3423..9ed4ba7ff 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -74,16 +74,10 @@ def setup_event_loop(): Creates and sets up a new asyncio event loop that is capable of spawning and watching subprocesses. - On Windows: Globally sets a proactor event loop policy. - - On Unix systems: Does nothing, except when not running on the main thread. - Then it installs a child watcher that uses polling. + Unix: Uses slow polling when not running on the main thread. """ - if sys.platform == "win32": - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - if sys.platform == "win32" or threading.current_thread() == threading.main_thread(): - loop = asyncio.new_event_loop() + loop = asyncio.ProactorEventLoop() if sys.platform == "win32" else asyncio.SelectorEventLoop() asyncio.set_event_loop(loop) return loop @@ -104,9 +98,6 @@ def attach_loop(self, loop): self._loop = loop if loop is not None: self._poll_handle = self._loop.call_soon(self._poll) - - # Prevent a race condition in case a child terminated - # during the switch. self._do_waitpid_all() def _poll(self): @@ -114,14 +105,33 @@ def _poll(self): self._do_waitpid_all() self._poll_handle = self._loop.call_later(1.0, self._poll) - policy = asyncio.get_event_loop_policy() - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) + class StandaloneSelectorEventLoop(asyncio.SelectorEventLoop): + def __init__(self, child_watcher, selector=None): + super().__init__(selector=selector) + self.child_watcher = child_watcher + self.child_watcher.attach_loop(self) - watcher = PollingChildWatcher() - watcher.attach_loop(loop) - policy.set_child_watcher(watcher) + @asyncio.coroutine + def _make_subprocess_transport(self, protocol, args, shell, stdin, stdout, stderr, bufsize, extra=None, **kwargs): + # Implementation is exactly the same, but uses local child watcher + # instead getting the global child watcher from the event loop + # policy. + with self.child_watcher as watcher: + waiter = self.create_future() + transp = asyncio.unix_events._UnixSubprocessTransport(self, protocol, args, shell, stdin, stdout, stderr, bufsize, waiter=waiter, extra=extra, **kwargs) + watcher.add_child_handler(transp.get_pid(), self._child_watcher_callback, transp) + try: + yield from waiter + except Exception: + transp.close() + yield from transp._wait() + raise + + return transp + + loop = StandaloneSelectorEventLoop(PollingChildWatcher()) + asyncio.set_event_loop(loop) return loop From 606260470dfb81d7e84677f2020933cb9c11e2b8 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 3 Jan 2019 23:30:56 +0100 Subject: [PATCH 0126/1451] fix python 3.4 compability again --- chess/engine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index 9ed4ba7ff..37f89805c 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -117,7 +117,7 @@ def _make_subprocess_transport(self, protocol, args, shell, stdin, stdout, stder # instead getting the global child watcher from the event loop # policy. with self.child_watcher as watcher: - waiter = self.create_future() + waiter = asyncio.Future(loop=self) transp = asyncio.unix_events._UnixSubprocessTransport(self, protocol, args, shell, stdin, stdout, stderr, bufsize, waiter=waiter, extra=extra, **kwargs) watcher.add_child_handler(transp.get_pid(), self._child_watcher_callback, transp) From a4c277527c6ac2d7a1a0ea459524d812bdc0f88b Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 3 Jan 2019 23:54:12 +0100 Subject: [PATCH 0127/1451] simplify SimpleEngine.close --- chess/engine.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 37f89805c..373f28765 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1476,15 +1476,8 @@ def quit(self): def close(self): """Closes the transport.""" - if self.protocol.loop.is_running(): - @asyncio.coroutine - def close(): - LOGGER.debug("%s: Closing transport ...", self.protocol) - self.transport.close() - yield from self.protocol.returncode - return asyncio.run_coroutine_threadsafe(close(), self.protocol.loop).result() - else: - self.transport.close() + # This happens to be threadsafe. + self.transport.close() @classmethod def popen_uci(cls, command, *, timeout=10.0, setpgrp=False, **popen_args): From ebaa8b560a9a7dcbe5c2ed31d69503e8e5389b75 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 4 Jan 2019 11:30:38 +0100 Subject: [PATCH 0128/1451] factor out info parsing --- chess/engine.py | 273 ++++++++++++++++++++++++++---------------------- 1 file changed, 150 insertions(+), 123 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 373f28765..b3429ea1d 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -25,6 +25,7 @@ import asyncio import collections import concurrent.futures +import enum import functools import logging import enum @@ -52,6 +53,12 @@ def _get_running_loop(): except ImportError: _all_tasks = asyncio.Task.all_tasks +try: + # Python 3.6 + _IntFlag = enum.IntFlag +except AttributeError: + _IntFlag = enum.IntEnum + try: StopAsyncIteration except NameError: @@ -243,12 +250,22 @@ def __repr__(self): class PlayResult: """Returned by :func:`chess.engine.EngineProtocol.play()`.""" - def __init__(self, move, ponder): + def __init__(self, move, ponder, info=None): self.move = move self.ponder = ponder + self.info = info or {} def __repr__(self): - return "<{} at {} (move={}, ponder={})>".format(type(self).__name__, hex(id(self)), self.move, self.ponder) + return "<{} at {} (move={}, ponder={}, info={})>".format(type(self).__name__, hex(id(self)), self.move, self.ponder, self.info) + + +class Info(_IntFlag): + NONE = 0 + SCORE = 1 + PV = 2 + REFUTATION = 4 + CURRLINE = 8 + ALL = 15 class Score(abc.ABC): @@ -615,7 +632,7 @@ def configure(self, options): @abc.abstractmethod @asyncio.coroutine - def play(self, board, limit, *, game=None, searchmoves=None, options={}): + def play(self, board, limit, *, game=None, info=Info.SCORE, searchmoves=None, options={}): """ Play a position. @@ -635,7 +652,7 @@ def play(self, board, limit, *, game=None, searchmoves=None, options={}): raise NotImplementedError @asyncio.coroutine - def analyse(self, board, limit, *, multipv=None, game=None, searchmoves=None, options={}): + def analyse(self, board, limit, *, multipv=None, game=None, info=Info.ALL, searchmoves=None, options={}): """ Analyses a position and returns an info dictionary. @@ -655,7 +672,7 @@ def analyse(self, board, limit, *, multipv=None, game=None, searchmoves=None, op analysis is complete. You can permanently apply a configuration with :func:`~chess.engine.EngineProtocol.configure()`. """ - analysis = yield from self.analysis(board, limit, game=game, searchmoves=searchmoves, options=options) + analysis = yield from self.analysis(board, limit, game=game, info=info, searchmoves=searchmoves, options=options) with analysis: yield from analysis.wait() @@ -664,7 +681,7 @@ def analyse(self, board, limit, *, multipv=None, game=None, searchmoves=None, op @abc.abstractmethod @asyncio.coroutine - def analysis(self, board, limit=None, *, multipv=None, game=None, searchmoves=None, options={}): + def analysis(self, board, limit=None, *, multipv=None, game=None, info=Info.ALL, searchmoves=None, options={}): """ Starts analysing a position. @@ -1032,7 +1049,7 @@ def _go(self, limit, *, searchmoves=None, ponder=False, infinite=False): self.send_line(" ".join(builder)) @asyncio.coroutine - def play(self, board, limit, *, game=None, searchmoves=None, options={}): + def play(self, board, limit, *, game=None, info=Info.SCORE, searchmoves=None, options={}): previous_config = self.config.copy() class Command(BaseCommand): @@ -1091,7 +1108,7 @@ def cancel(self, engine): return (yield from self.communicate(Command)) @asyncio.coroutine - def analysis(self, board, limit=None, *, multipv=None, game=None, searchmoves=None, options={}): + def analysis(self, board, limit=None, *, multipv=None, game=None, info=Info.ALL, searchmoves=None, options={}): previous_config = self.config.copy() class Command(BaseCommand): @@ -1128,115 +1145,7 @@ def line_received(self, engine, line): LOGGER.warning("%s: Unexpected engine output: %s", engine, line) def _info(self, engine, arg): - # Initialize parser state. - info = {} - board = None - pv = None - score_kind = None - refutation_move = None - refuted_by = [] - currline_cpunr = None - currline_moves = [] - string = [] - - # Parameters with variable length can only be handled when the - # next parameter starts or at the end of the line. - def end_of_parameter(): - if pv is not None: - info["pv"] = pv - - if refutation_move is not None: - if not "refutation" in info: - info["refutation"] = {} - info["refutation"][refutation_move] = refuted_by - - if currline_cpunr is not None: - if not "currline" in info: - info["currline"] = {} - info["currline"][currline_cpunr] = currline_moves - - # Parse all other parameters. - current_parameter = None - for token in arg.split(" "): - if current_parameter == "string": - string.append(token) - elif not token: - # Ignore extra spaces. Those can not be directly discarded, - # because they may occur in the string parameter. - pass - elif token in ["depth", "seldepth", "time", "nodes", "pv", "multipv", "score", "currmove", "currmovenumber", "hashfull", "nps", "tbhits", "cpuload", "refutation", "currline", "ebf", "string"]: - end_of_parameter() - current_parameter = token - - pv = None - score_kind = None - refutation_move = None - refuted_by = [] - currline_cpunr = None - currline_moves = [] - - if current_parameter == "pv": - pv = [] - - if current_parameter in ["refutation", "pv", "currline"]: - board = engine.board.copy(stack=False) - elif current_parameter in ["depth", "seldepth", "time", "nodes", "multipv", "currmovenumber", "hashfull", "nps", "tbhits", "cpuload"]: - try: - info[current_parameter] = int(token) - except ValueError: - LOGGER.error("exception parsing %s from info: %r", current_parameter, arg) - elif current_parameter == "pv": - try: - pv.append(board.push_uci(token)) - except ValueError: - LOGGER.exception("exception parsing pv from info: %r, position at root: %s", arg, engine.board.fen()) - elif current_parameter == "score": - try: - if token in ["cp", "mate"]: - score_kind = token - elif token == "lowerbound": - info["lowerbound"] = True - elif token == "upperbound": - info["upperbound"] = True - elif score_kind == "cp": - info["score"] = Cp(int(token)) - elif score_kind == "mate": - info["mate"] = Mate.from_moves(int(token)) - except ValueError: - LOGGER.error("exception parsing score %s from info: %r", score_kind, arg) - elif current_parameter == "currmove": - try: - info[current_parameter] = chess.Move.from_uci(token) - except ValueError: - LOGGER.error("exception parsing %s from info: %r", current_parameter, arg) - elif current_parameter == "refutation": - try: - if refutation_move is None: - refutation_move = board.push_uci(token) - else: - refuted_by.append(board.push_uci(token)) - except ValueError: - LOGGER.exception("exception parsing refutation from info: %r, position at root: %s", arg, engine.fen()) - elif current_parameter == "currline": - try: - if currline_cpunr is None: - currline_cpunr = int(token) - else: - currline_moves.append(board.push_uci(token)) - except ValueError: - LOGGER.exception("exception parsing currline from info: %r, position at root: %s", arg, engine.fen()) - elif current_parameter == "ebf": - try: - info[current_parameter] = float(token) - except ValueError: - LOGGER.error("exception parsing %s from info: %r", current_parameter, arg) - - end_of_parameter() - - if string: - info["string"] = " ".join(string) - - self.analysis.post(info) + self.analysis.post(_parse_uci_info(arg, engine.board, info)) def _bestmove(self, engine, arg): for name, value in previous_config.items(): @@ -1256,6 +1165,124 @@ def quit(self): yield from self.returncode +def _parse_uci_info(arg, root_board, selector=Info.ALL): + info = {} + if not selector: + return info + + # Initialize parser state. + board = None + pv = None + score_kind = None + refutation_move = None + refuted_by = [] + currline_cpunr = None + currline_moves = [] + string = [] + + # Parameters with variable length can only be handled when the + # next parameter starts or at the end of the line. + def end_of_parameter(): + if pv is not None: + info["pv"] = pv + + if refutation_move is not None: + if not "refutation" in info: + info["refutation"] = {} + info["refutation"][refutation_move] = refuted_by + + if currline_cpunr is not None: + if not "currline" in info: + info["currline"] = {} + info["currline"][currline_cpunr] = currline_moves + + # Parse all other parameters. + current_parameter = None + for token in arg.split(" "): + if current_parameter == "string": + string.append(token) + elif not token: + # Ignore extra spaces. Those can not be directly discarded, + # because they may occur in the string parameter. + pass + elif token in ["depth", "seldepth", "time", "nodes", "pv", "multipv", "score", "currmove", "currmovenumber", "hashfull", "nps", "tbhits", "cpuload", "refutation", "currline", "ebf", "string"]: + end_of_parameter() + current_parameter = token + + board = None + pv = None + score_kind = None + refutation_move = None + refuted_by = [] + currline_cpunr = None + currline_moves = [] + + if current_parameter == "pv" and selector & Info.PV: + pv = [] + board = root_board.copy(stack=False) + elif current_parameter == "refutation" and selector & Info.REFUTATION: + board = root_board.copy(stack=False) + elif current_parameter == "currline" and selector & Info.CURRLINE: + board = root_board.copy(stack=False) + elif current_parameter in ["depth", "seldepth", "time", "nodes", "multipv", "currmovenumber", "hashfull", "nps", "tbhits", "cpuload"]: + try: + info[current_parameter] = int(token) + except ValueError: + LOGGER.error("exception parsing %s from info: %r", current_parameter, arg) + elif current_parameter == "pv" and pv is not None: + try: + pv.append(board.push_uci(token)) + except ValueError: + LOGGER.exception("exception parsing pv from info: %r, position at root: %s", arg, root_board.fen()) + elif current_parameter == "score" and selector & Info.SCORE: + try: + if token in ["cp", "mate"]: + score_kind = token + elif token == "lowerbound": + info["lowerbound"] = True + elif token == "upperbound": + info["upperbound"] = True + elif score_kind == "cp": + info["score"] = Cp(int(token)) + elif score_kind == "mate": + info["mate"] = Mate.from_moves(int(token)) + except ValueError: + LOGGER.error("exception parsing score %s from info: %r", score_kind, arg) + elif current_parameter == "currmove": + try: + info[current_parameter] = chess.Move.from_uci(token) + except ValueError: + LOGGER.error("exception parsing %s from info: %r", current_parameter, arg) + elif current_parameter == "refutation" and board is not None: + try: + if refutation_move is None: + refutation_move = board.push_uci(token) + else: + refuted_by.append(board.push_uci(token)) + except ValueError: + LOGGER.exception("exception parsing refutation from info: %r, position at root: %s", arg, root_board.fen()) + elif current_parameter == "currline" and board is not None: + try: + if currline_cpunr is None: + currline_cpunr = int(token) + else: + currline_moves.append(board.push_uci(token)) + except ValueError: + LOGGER.exception("exception parsing currline from info: %r, position at root: %s", arg, root_board.fen()) + elif current_parameter == "ebf": + try: + info[current_parameter] = float(token) + except ValueError: + LOGGER.error("exception parsing %s from info: %r", current_parameter, arg) + + end_of_parameter() + + if string: + info["string"] = " ".join(string) + + return info + + class UciOptionMap(collections.abc.MutableMapping): """Dictionary with case-insensitive keys.""" @@ -1460,15 +1487,15 @@ def configure(self, options): def ping(self): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.ping(), self.timeout), self.protocol.loop).result() - def play(self, board, limit, *, game=None, searchmoves=None, options={}): - return asyncio.run_coroutine_threadsafe(self.protocol.play(board, limit, game=game, searchmoves=searchmoves, options=options), self.protocol.loop).result() + def play(self, board, limit, *, game=None, info=Info.SCORE, searchmoves=None, options={}): + return asyncio.run_coroutine_threadsafe(self.protocol.play(board, limit, game=game, info=info, searchmoves=searchmoves, options=options), self.protocol.loop).result() - def analyse(self, board, limit, *, multipv=None, game=None, searchmoves=None, options={}): - return asyncio.run_coroutine_threadsafe(self.protocol.analyse(board, limit, multipv=multipv, game=game, searchmoves=searchmoves, options=options), self.protocol.loop).result() + def analyse(self, board, limit, *, multipv=None, game=None, info=Info.ALL, searchmoves=None, options={}): + return asyncio.run_coroutine_threadsafe(self.protocol.analyse(board, limit, multipv=multipv, game=game, info=info, searchmoves=searchmoves, options=options), self.protocol.loop).result() - def analysis(self, board, limit=None, *, multipv=None, game=None, searchmoves=None, options={}): + def analysis(self, board, limit=None, *, multipv=None, game=None, info=Info.ALL, searchmoves=None, options={}): return SimpleAnalysisResult( - asyncio.run_coroutine_threadsafe(self.protocol.analysis(board, limit, multipv=multipv, game=game, searchmoves=searchmoves, options=options), self.protocol.loop).result(), + asyncio.run_coroutine_threadsafe(self.protocol.analysis(board, limit, multipv=multipv, game=game, info=info, searchmoves=searchmoves, options=options), self.protocol.loop).result(), self.protocol.loop) def quit(self): From 7873580e81a62b1b39369d385c8b84e41f069118 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 4 Jan 2019 11:36:14 +0100 Subject: [PATCH 0129/1451] include selected info in PlayResult --- chess/engine.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index b3429ea1d..4be5e2a43 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -260,6 +260,7 @@ def __repr__(self): class Info(_IntFlag): + """Select information sent by the chess engine.""" NONE = 0 SCORE = 1 PV = 2 @@ -1054,6 +1055,8 @@ def play(self, board, limit, *, game=None, info=Info.SCORE, searchmoves=None, op class Command(BaseCommand): def start(self, engine): + self.info = {} + if "UCI_AnalyseMode" in engine.options: engine._setoption("UCI_AnalyseMode", False) @@ -1067,11 +1070,16 @@ def start(self, engine): engine._go(limit, searchmoves=searchmoves) def line_received(self, engine, line): - if line.startswith("bestmove "): + if line.startswith("info "): + self._info(engine, line.split(" ", 1)[1]) + elif line.startswith("bestmove "): self._bestmove(engine, line.split(" ", 1)[1]) - elif not line.startswith("info "): + else: LOGGER.warning("%s: Unexpected engine output: %s", engine, line) + def _info(self, engine, arg): + self.info.update(_parse_uci_info(arg, engine.board, info)) + def _bestmove(self, engine, arg): try: if not self.result.cancelled(): @@ -1095,7 +1103,7 @@ def _bestmove(self, engine, arg): finally: board.pop() - self.result.set_result(PlayResult(bestmove, ponder)) + self.result.set_result(PlayResult(bestmove, ponder, self.info)) finally: for name, value in previous_config.items(): engine._setoption(name, value) From 91a8c532c761f07a14f9e7ce1713b4f472cbd4db Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 4 Jan 2019 11:51:33 +0100 Subject: [PATCH 0130/1451] add SimpleEngine.__repr__ --- chess/engine.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index 4be5e2a43..4f210f617 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1548,6 +1548,9 @@ def __enter__(self): def __exit__(self, a, b, c): self.close() + def __repr__(self): + return "<{} (pid={}>".format(type(self).__name__, self.transport.get_pid()) + class SimpleAnalysisResult: """ From 69d00e42ecc4a70e7a94855df01847a7a08e7792 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 4 Jan 2019 12:18:18 +0100 Subject: [PATCH 0131/1451] implement basic pondering --- chess/engine.py | 42 ++++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 4f210f617..48620fa4d 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -16,9 +16,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -# TODO: XBoard support -# TODO: Check naming. Is it too UCI specific? -# TODO: Pondering +# TODO: XBoard support. Check naming: Is it too UCI specific? # TODO: Test coverage import abc @@ -633,7 +631,7 @@ def configure(self, options): @abc.abstractmethod @asyncio.coroutine - def play(self, board, limit, *, game=None, info=Info.SCORE, searchmoves=None, options={}): + def play(self, board, limit, *, game=None, info=Info.SCORE, ponder=False, searchmoves=None, options={}): """ Play a position. @@ -644,6 +642,8 @@ def play(self, board, limit, *, game=None, info=Info.SCORE, searchmoves=None, op :param game: Optional. An arbitrary object that identifies the game. Will automatically clear hashtables if the object is not equal to the previous game. + :param ponder: Whether the engine should keep analysing in the + background even after the result has been returned. :param searchmoves: Optional. Consider only root moves from this list. :param options: Optional. A dictionary of engine options for the analysis. The previous configuration will be restored after the @@ -1050,15 +1050,18 @@ def _go(self, limit, *, searchmoves=None, ponder=False, infinite=False): self.send_line(" ".join(builder)) @asyncio.coroutine - def play(self, board, limit, *, game=None, info=Info.SCORE, searchmoves=None, options={}): + def play(self, board, limit, *, game=None, info=Info.SCORE, ponder=False, searchmoves=None, options={}): previous_config = self.config.copy() class Command(BaseCommand): def start(self, engine): self.info = {} + self.pondering = False if "UCI_AnalyseMode" in engine.options: engine._setoption("UCI_AnalyseMode", False) + if "Ponder" in engine.options: + engine._setoption("Ponder", ponder) engine._configure(options) @@ -1078,11 +1081,14 @@ def line_received(self, engine, line): LOGGER.warning("%s: Unexpected engine output: %s", engine, line) def _info(self, engine, arg): - self.info.update(_parse_uci_info(arg, engine.board, info)) + if not self.pondering: + self.info.update(_parse_uci_info(arg, engine.board, info)) def _bestmove(self, engine, arg): try: - if not self.result.cancelled(): + if self.pondering: + self.pondering = False + elif not self.result.cancelled(): tokens = arg.split(None, 2) bestmove = None @@ -1093,22 +1099,26 @@ def _bestmove(self, engine, arg): self.result.set_exception(EngineError(err)) return - ponder = None + pondermove = None if bestmove is not None and len(tokens) >= 3 and tokens[1] == "ponder" and tokens[2] != "(none)": - board.push(bestmove) + engine.board.push(bestmove) try: - ponder = board.parse_uci(tokens[2]) + pondermove = engine.board.push_uci(tokens[2]) except ValueError as err: LOGGER.exception("engine sent invalid ponder move") - finally: - board.pop() - self.result.set_result(PlayResult(bestmove, ponder, self.info)) + self.result.set_result(PlayResult(bestmove, pondermove, self.info)) + + if ponder and pondermove: + self.pondering = True + engine._position(engine.board) + engine._go(limit, ponder=True) finally: - for name, value in previous_config.items(): - engine._setoption(name, value) + if not self.pondering: + for name, value in previous_config.items(): + engine._setoption(name, value) - self.set_finished() + self.set_finished() def cancel(self, engine): engine.send_line("stop") From d64be545ad6c1c6c45279924be56605c15c6f209 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 4 Jan 2019 18:12:39 +0100 Subject: [PATCH 0132/1451] clean up --- chess/engine.py | 59 +++---------------------------------------------- 1 file changed, 3 insertions(+), 56 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 48620fa4d..5a067d8cf 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1559,7 +1559,9 @@ def __exit__(self, a, b, c): self.close() def __repr__(self): - return "<{} (pid={}>".format(type(self).__name__, self.transport.get_pid()) + # This happens to be threadsafe. + pid = self.transport.get_pid() + return "<{} (pid={}>".format(type(self).__name__, pid) class SimpleAnalysisResult: @@ -1609,58 +1611,3 @@ def __enter__(self): def __exit__(self, a, b, c): self.stop() - - -@asyncio.coroutine -def async_main(): - transport, engine = yield from popen_uci(sys.argv[1:]) - print(engine.options) - - yield from engine.ping() - - yield from engine.configure({ - "Contempt": 40, - }) - - import chess.variant - - board = chess.Board() - limit = Limit(depth=20) - - #with yield from engine.analysis(board) as analysis: - # async for info in analysis: - # print("!", info) - # if "123" in info: - # break - yield from analysis.wait() - - #try: - # analysis = await asyncio.wait_for(engine.analyse(board, limit), 0.1) - # print("ANALYSIS", analysis) - #except asyncio.TimeoutError: - # print("TIMEOUT ERROR") - - #move = await engine.play(board, limit) - #print("PLAY", move) - - yield from engine.quit() - - -def main(): - with SimpleEngine.popen_uci(sys.argv[1:], setpgrp=True) as engine: - print(engine.protocol.options) - - board = chess.Board() - with engine.analysis(board, Limit(movetime=3000)) as analysis: - for info in analysis: - print("!!!", analysis.multipv) - if "123" in info: - break - - engine.quit() - - -if __name__ == "__main__": - logging.basicConfig(level=logging.DEBUG) - main() - #asyncio.run(async_main()) From a1291f4427a0ace9b38f5ff13e0b091374102373 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 4 Jan 2019 18:23:12 +0100 Subject: [PATCH 0133/1451] document info --- chess/engine.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index 5a067d8cf..ed0b4ec79 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -642,6 +642,11 @@ def play(self, board, limit, *, game=None, info=Info.SCORE, ponder=False, search :param game: Optional. An arbitrary object that identifies the game. Will automatically clear hashtables if the object is not equal to the previous game. + :param info: Selects which additional information to retrieve from the + engine. ``Info.NONE``, ``Info.SCORE``, ``Info.PV``, + ``Info.REFUTATION``, ``Info.CURRLINE``, ``Info.ALL`` or any + bitwise combination. Some overhead is associated with parsing + these. :param ponder: Whether the engine should keep analysing in the background even after the result has been returned. :param searchmoves: Optional. Consider only root moves from this list. @@ -667,6 +672,11 @@ def analyse(self, board, limit, *, multipv=None, game=None, info=Info.ALL, searc :param game: Optional. An arbitrary object that identifies the game. Will automatically clear hashtables if the object is not equal to the previous game. + :param info: Selects which information to retrieve from the + engine. ``Info.NONE``, ``Info.SCORE``, ``Info.PV``, + ``Info.REFUTATION``, ``Info.CURRLINE``, ``Info.ALL`` or any + bitwise combination. Some overhead is associated with parsing + these. :param searchmoves: Optional. Limit analysis to a list of root moves. :param options: Optional. A dictionary of engine options for the analysis. The previous configuration will be restored after the @@ -695,6 +705,11 @@ def analysis(self, board, limit=None, *, multipv=None, game=None, info=Info.ALL, :param game: Optional. An arbitrary object that identifies the game. Will automatically clear hashtables if the object is not equal to the previous game. + :param info: Selects which information to retrieve from the + engine. ``Info.NONE``, ``Info.SCORE``, ``Info.PV``, + ``Info.REFUTATION``, ``Info.CURRLINE``, ``Info.ALL`` or any + bitwise combination. Some overhead is associated with parsing + these. :param searchmoves: Optional. Limit analysis to a list of root moves. :param options: Optional. A dictionary of engine options for the analysis. The previous configuration will be restored after the From ed4abe38e350e6a9b4a59ab7c1220db8aee50765 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 4 Jan 2019 18:27:30 +0100 Subject: [PATCH 0134/1451] seperate test_uci_debug --- test.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test.py b/test.py index b858d3e6e..2a6f9f41f 100755 --- a/test.py +++ b/test.py @@ -3123,6 +3123,15 @@ def main(): yield from protocol.ping() mock.assert_done() + with contextlib.closing(chess.engine.setup_event_loop()) as loop: + loop.run_until_complete(main()) + + def test_uci_debug(self): + @asyncio.coroutine + def main(): + protocol = chess.engine.UciProtocol() + mock = chess.engine.MockTransport(protocol) + mock.expect("debug on", []) protocol.debug() mock.assert_done() From 8727be4ffc784559131b5014f4298e741cb5a1bf Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 4 Jan 2019 18:30:33 +0100 Subject: [PATCH 0135/1451] more quickly terminate short-lived SimpleEngine --- chess/engine.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index ed0b4ec79..0377d182d 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -90,6 +90,7 @@ class PollingChildWatcher(asyncio.SafeChildWatcher): def __init__(self): super().__init__() self._poll_handle = None + self._poll_delay = 0.001 def attach_loop(self, loop): assert loop is None or isinstance(loop, asyncio.AbstractEventLoop) @@ -108,7 +109,8 @@ def attach_loop(self, loop): def _poll(self): if self._loop: self._do_waitpid_all() - self._poll_handle = self._loop.call_later(1.0, self._poll) + self._poll_delay = min(self._poll_delay * 2, 1.0) + self._poll_handle = self._loop.call_later(self._poll_delay, self._poll) class StandaloneSelectorEventLoop(asyncio.SelectorEventLoop): def __init__(self, child_watcher, selector=None): From 5025095f1f5a875a384cd70a4b9f95041bbf8e13 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 4 Jan 2019 18:55:15 +0100 Subject: [PATCH 0136/1451] add test_uci_go --- test.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test.py b/test.py index 2a6f9f41f..85d65cf9f 100755 --- a/test.py +++ b/test.py @@ -3143,6 +3143,34 @@ def main(): with contextlib.closing(chess.engine.setup_event_loop()) as loop: loop.run_until_complete(main()) + def test_uci_go(self): + @asyncio.coroutine + def main(): + protocol = chess.engine.UciProtocol() + mock = chess.engine.MockTransport(protocol) + + mock.expect("position startpos") + mock.expect("go movetime 123 searchmoves e2e4 d2d4", ["info string searching ...", "bestmove d2d4 ponder d7d5"]) + mock.expect("position startpos moves d2d4 d7d5") + mock.expect("go ponder movetime 123") + board = chess.Board() + result = yield from protocol.play(board, chess.engine.Limit(movetime=123), searchmoves=[board.parse_san("e4"), board.parse_san("d4")], ponder=True) + self.assertEqual(result.move, chess.Move.from_uci("d2d4")) + self.assertEqual(result.ponder, chess.Move.from_uci("d7d5")) + self.assertEqual(result.info["string"], "searching ...") + mock.assert_done() + + mock.expect("stop", ["bestmove c2c4"]) + mock.expect("position startpos") + mock.expect("go wtime 1 btime 2 winc 3 binc 4 movestogo 5 depth 6 nodes 7 mate 8 movetime 9", ["bestmove d2d4"]) + result = yield from protocol.play(board, chess.engine.Limit(wtime=1, btime=2, winc=3, binc=4, movestogo=5, depth=6, nodes=7, mate=8, movetime=9)) + self.assertEqual(result.move, chess.Move.from_uci("d2d4")) + self.assertEqual(result.ponder, None) + mock.assert_done() + + with contextlib.closing(chess.engine.setup_event_loop()) as loop: + loop.run_until_complete(main()) + class SyzygyTestCase(unittest.TestCase): From 61fa6535d2cee545f335cdcc1aac28bbe4d3f32f Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 4 Jan 2019 19:19:47 +0100 Subject: [PATCH 0137/1451] test and fix uci info parsing --- chess/engine.py | 2 +- test.py | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index 0377d182d..4a325f181 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1280,7 +1280,7 @@ def end_of_parameter(): elif score_kind == "cp": info["score"] = Cp(int(token)) elif score_kind == "mate": - info["mate"] = Mate.from_moves(int(token)) + info["score"] = Mate.from_moves(int(token)) except ValueError: LOGGER.error("exception parsing score %s from info: %r", score_kind, arg) elif current_parameter == "currmove": diff --git a/test.py b/test.py index 85d65cf9f..f237c0924 100755 --- a/test.py +++ b/test.py @@ -3149,6 +3149,7 @@ def main(): protocol = chess.engine.UciProtocol() mock = chess.engine.MockTransport(protocol) + # Pondering. mock.expect("position startpos") mock.expect("go movetime 123 searchmoves e2e4 d2d4", ["info string searching ...", "bestmove d2d4 ponder d7d5"]) mock.expect("position startpos moves d2d4 d7d5") @@ -3161,6 +3162,8 @@ def main(): mock.assert_done() mock.expect("stop", ["bestmove c2c4"]) + + # Limits. mock.expect("position startpos") mock.expect("go wtime 1 btime 2 winc 3 binc 4 movestogo 5 depth 6 nodes 7 mate 8 movetime 9", ["bestmove d2d4"]) result = yield from protocol.play(board, chess.engine.Limit(wtime=1, btime=2, winc=3, binc=4, movestogo=5, depth=6, nodes=7, mate=8, movetime=9)) @@ -3171,6 +3174,44 @@ def main(): with contextlib.closing(chess.engine.setup_event_loop()) as loop: loop.run_until_complete(main()) + def test_uci_info(self): + ALL = chess.engine.Info.ALL + + # Info: refutation. + board = chess.Board("8/8/6k1/8/8/8/1K6/3B4 w - - 0 1") + info = chess.engine._parse_uci_info("refutation d1h5 g6h5", board, ALL) + self.assertEqual(info["refutation"][chess.Move.from_uci("d1h5")], [chess.Move.from_uci("g6h5")]) + + info = chess.engine._parse_uci_info("refutation d1h5", board, ALL) + self.assertEqual(info["refutation"][chess.Move.from_uci("d1h5")], []) + + # Info: string. + info = chess.engine._parse_uci_info("string goes to end no matter score cp 4 what", None, ALL) + self.assertEqual(info["string"], "goes to end no matter score cp 4 what") + + # Info: currline. + info = chess.engine._parse_uci_info("currline 0 e2e4 e7e5", chess.Board(), ALL) + self.assertEqual(info["currline"][0], [chess.Move.from_uci("e2e4"), chess.Move.from_uci("e7e5")]) + + # Info: ebf. + info = chess.engine._parse_uci_info("ebf 0.42", None, ALL) + self.assertEqual(info["ebf"], 0.42) + + # Info: depth, seldepth, score mate. + info = chess.engine._parse_uci_info("depth 7 seldepth 8 score mate 3", None, ALL) + self.assertEqual(info["depth"], 7) + self.assertEqual(info["seldepth"], 8) + self.assertEqual(info["score"], chess.engine.Mate.plus(3)) + + # Info: tbhits, cpuload, hashfull, time, nodes, nps. + info = chess.engine._parse_uci_info("tbhits 123 cpuload 456 hashfull 789 time 987 nodes 654 nps 321", None, ALL) + self.assertEqual(info["tbhits"], 123) + self.assertEqual(info["cpuload"], 456) + self.assertEqual(info["hashfull"], 789) + self.assertEqual(info["time"], 987) + self.assertEqual(info["nodes"], 654) + self.assertEqual(info["nps"], 321) + class SyzygyTestCase(unittest.TestCase): From 5201976d2443d999f6de587aa0a646c6643f31f5 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 4 Jan 2019 19:31:47 +0100 Subject: [PATCH 0138/1451] factor out SimpleEngine.popen --- chess/engine.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 4a325f181..81c37a259 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1542,32 +1542,30 @@ def close(self): self.transport.close() @classmethod - def popen_uci(cls, command, *, timeout=10.0, setpgrp=False, **popen_args): - """ - Spawns and initializes an UCI engine. - Returns a :class:`~chess.engine.SimpleEngine` instance. - """ + def popen(cls, Protocol, command, *, timeout=10.0, setpgrp=False, **popen_args): @asyncio.coroutine def background(future): - transport, protocol = yield from asyncio.wait_for(UciProtocol.popen(command, setpgrp=setpgrp, **popen_args), timeout) + transport, protocol = yield from asyncio.wait_for(Protocol.popen(command, setpgrp=setpgrp, **popen_args), timeout) future.set_result(cls(transport, protocol, timeout=timeout)) yield from protocol.returncode return run_in_background(background) + @classmethod + def popen_uci(cls, command, *, timeout=10.0, setpgrp=False, **popen_args): + """ + Spawns and initializes an UCI engine. + Returns a :class:`~chess.engine.SimpleEngine` instance. + """ + return cls.popen(UciProtocol, command, timeout=timeout, setpgrp=setpgrp, **popen_args) + @classmethod def popen_xboard(cls, command, *, timeout=10.0, setpgrp=False, **popen_args): """ Spawns and initializes an XBoard engine. Returns a :class:`~chess.engine.SimpleEngine` instance. """ - @asyncio.coroutine - def background(future): - transport, protocol = yield from asyncio.wait_for(XBoardProtocol.popen(command, setpgrp=setpgrp, **popen_args), timeout) - future.set_result(cls(transport, protocol, timeout=timeout)) - yield from protocol.returncode - - return run_in_background(background) + return cls.popen(XBoardProtocol, command, timeout=timeout, setpgrp=setpgrp, **popen_args) def __enter__(self): return self From 342a280a4031c6ca4539f9494e5722592e2f2ea0 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 4 Jan 2019 21:05:29 +0100 Subject: [PATCH 0139/1451] engine api documentation tweaks --- chess/engine.py | 56 +++++++++++++++++++++++++++++-------------------- docs/conf.py | 1 - docs/engine.rst | 12 +++++++++-- test.py | 16 +++++++------- 4 files changed, 51 insertions(+), 34 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 81c37a259..0abba80aa 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -79,7 +79,9 @@ def setup_event_loop(): Creates and sets up a new asyncio event loop that is capable of spawning and watching subprocesses. - Unix: Uses slow polling when not running on the main thread. + Unix: Uses relatively slow polling to watch child processes, when not + running on the main thread. This only affects detection of process + termination, not communication. """ if sys.platform == "win32" or threading.current_thread() == threading.main_thread(): loop = asyncio.ProactorEventLoop() if sys.platform == "win32" else asyncio.SelectorEventLoop() @@ -269,6 +271,14 @@ class Info(_IntFlag): ALL = 15 +INFO_NONE = 0 +INFO_SCORE = 1 +INFO_PV = 2 +INFO_REFUTATION = 4 +INFO_CURRLINE = 8 +INFO_ALL = INFO_SCORE | INFO_PV | INFO_REFUTATION | INFO_CURRLINE + + class Score(abc.ABC): """ Evaluation of a position. @@ -633,7 +643,7 @@ def configure(self, options): @abc.abstractmethod @asyncio.coroutine - def play(self, board, limit, *, game=None, info=Info.SCORE, ponder=False, searchmoves=None, options={}): + def play(self, board, limit, *, game=None, info=INFO_SCORE, ponder=False, searchmoves=None, options={}): """ Play a position. @@ -645,10 +655,10 @@ def play(self, board, limit, *, game=None, info=Info.SCORE, ponder=False, search Will automatically clear hashtables if the object is not equal to the previous game. :param info: Selects which additional information to retrieve from the - engine. ``Info.NONE``, ``Info.SCORE``, ``Info.PV``, - ``Info.REFUTATION``, ``Info.CURRLINE``, ``Info.ALL`` or any + engine. ``INFO_NONE``, ``INFO_SCORE``, ``INFO_PV``, + ``INFO_REFUTATION``, ``INFO_CURRLINE``, ``INFO_ALL`` or any bitwise combination. Some overhead is associated with parsing - these. + extra information. :param ponder: Whether the engine should keep analysing in the background even after the result has been returned. :param searchmoves: Optional. Consider only root moves from this list. @@ -660,7 +670,7 @@ def play(self, board, limit, *, game=None, info=Info.SCORE, ponder=False, search raise NotImplementedError @asyncio.coroutine - def analyse(self, board, limit, *, multipv=None, game=None, info=Info.ALL, searchmoves=None, options={}): + def analyse(self, board, limit, *, multipv=None, game=None, info=INFO_ALL, searchmoves=None, options={}): """ Analyses a position and returns an info dictionary. @@ -675,10 +685,10 @@ def analyse(self, board, limit, *, multipv=None, game=None, info=Info.ALL, searc Will automatically clear hashtables if the object is not equal to the previous game. :param info: Selects which information to retrieve from the - engine. ``Info.NONE``, ``Info.SCORE``, ``Info.PV``, - ``Info.REFUTATION``, ``Info.CURRLINE``, ``Info.ALL`` or any + engine. ``INFO_NONE``, ``INFO_SCORE``, ``INFO_PV``, + ``INFO_REFUTATION``, ``INFO_CURRLINE``, ``INFO_ALL`` or any bitwise combination. Some overhead is associated with parsing - these. + extra information. :param searchmoves: Optional. Limit analysis to a list of root moves. :param options: Optional. A dictionary of engine options for the analysis. The previous configuration will be restored after the @@ -694,7 +704,7 @@ def analyse(self, board, limit, *, multipv=None, game=None, info=Info.ALL, searc @abc.abstractmethod @asyncio.coroutine - def analysis(self, board, limit=None, *, multipv=None, game=None, info=Info.ALL, searchmoves=None, options={}): + def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, searchmoves=None, options={}): """ Starts analysing a position. @@ -708,10 +718,10 @@ def analysis(self, board, limit=None, *, multipv=None, game=None, info=Info.ALL, Will automatically clear hashtables if the object is not equal to the previous game. :param info: Selects which information to retrieve from the - engine. ``Info.NONE``, ``Info.SCORE``, ``Info.PV``, - ``Info.REFUTATION``, ``Info.CURRLINE``, ``Info.ALL`` or any + engine. ``INFO_NONE``, ``INFO_SCORE``, ``INFO_PV``, + ``INFO_REFUTATION``, ``INFO_CURRLINE``, ``INFO_ALL`` or any bitwise combination. Some overhead is associated with parsing - these. + extra information. :param searchmoves: Optional. Limit analysis to a list of root moves. :param options: Optional. A dictionary of engine options for the analysis. The previous configuration will be restored after the @@ -1067,7 +1077,7 @@ def _go(self, limit, *, searchmoves=None, ponder=False, infinite=False): self.send_line(" ".join(builder)) @asyncio.coroutine - def play(self, board, limit, *, game=None, info=Info.SCORE, ponder=False, searchmoves=None, options={}): + def play(self, board, limit, *, game=None, info=INFO_SCORE, ponder=False, searchmoves=None, options={}): previous_config = self.config.copy() class Command(BaseCommand): @@ -1143,7 +1153,7 @@ def cancel(self, engine): return (yield from self.communicate(Command)) @asyncio.coroutine - def analysis(self, board, limit=None, *, multipv=None, game=None, info=Info.ALL, searchmoves=None, options={}): + def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, searchmoves=None, options={}): previous_config = self.config.copy() class Command(BaseCommand): @@ -1200,7 +1210,7 @@ def quit(self): yield from self.returncode -def _parse_uci_info(arg, root_board, selector=Info.ALL): +def _parse_uci_info(arg, root_board, selector=INFO_ALL): info = {} if not selector: return info @@ -1252,12 +1262,12 @@ def end_of_parameter(): currline_cpunr = None currline_moves = [] - if current_parameter == "pv" and selector & Info.PV: + if current_parameter == "pv" and selector & INFO_PV: pv = [] board = root_board.copy(stack=False) - elif current_parameter == "refutation" and selector & Info.REFUTATION: + elif current_parameter == "refutation" and selector & INFO_REFUTATION: board = root_board.copy(stack=False) - elif current_parameter == "currline" and selector & Info.CURRLINE: + elif current_parameter == "currline" and selector & INFO_CURRLINE: board = root_board.copy(stack=False) elif current_parameter in ["depth", "seldepth", "time", "nodes", "multipv", "currmovenumber", "hashfull", "nps", "tbhits", "cpuload"]: try: @@ -1269,7 +1279,7 @@ def end_of_parameter(): pv.append(board.push_uci(token)) except ValueError: LOGGER.exception("exception parsing pv from info: %r, position at root: %s", arg, root_board.fen()) - elif current_parameter == "score" and selector & Info.SCORE: + elif current_parameter == "score" and selector & INFO_SCORE: try: if token in ["cp", "mate"]: score_kind = token @@ -1522,13 +1532,13 @@ def configure(self, options): def ping(self): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.ping(), self.timeout), self.protocol.loop).result() - def play(self, board, limit, *, game=None, info=Info.SCORE, searchmoves=None, options={}): + def play(self, board, limit, *, game=None, info=INFO_SCORE, searchmoves=None, options={}): return asyncio.run_coroutine_threadsafe(self.protocol.play(board, limit, game=game, info=info, searchmoves=searchmoves, options=options), self.protocol.loop).result() - def analyse(self, board, limit, *, multipv=None, game=None, info=Info.ALL, searchmoves=None, options={}): + def analyse(self, board, limit, *, multipv=None, game=None, info=INFO_ALL, searchmoves=None, options={}): return asyncio.run_coroutine_threadsafe(self.protocol.analyse(board, limit, multipv=multipv, game=game, info=info, searchmoves=searchmoves, options=options), self.protocol.loop).result() - def analysis(self, board, limit=None, *, multipv=None, game=None, info=Info.ALL, searchmoves=None, options={}): + def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, searchmoves=None, options={}): return SimpleAnalysisResult( asyncio.run_coroutine_threadsafe(self.protocol.analysis(board, limit, multipv=multipv, game=game, info=info, searchmoves=searchmoves, options=options), self.protocol.loop).result(), self.protocol.loop) diff --git a/docs/conf.py b/docs/conf.py index 49a1598eb..4caa664ba 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -57,7 +57,6 @@ class PyCoroutineMixin: def handle_signature(self, sig, signode): ret = super().handle_signature(sig, signode) signode.insert(0, sphinx.addnodes.desc_annotation("coroutine ", "coroutine ")) - print("inserted async!") return ret class PyCoroutineFunction(PyCoroutineMixin, sphinx.domains.python.PyModulelevel): diff --git a/docs/engine.rst b/docs/engine.rst index 6a1bc4398..c6ff5e790 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -2,8 +2,8 @@ Engine communication [experimental] =================================== UCI and XBoard are protocols for communicating with chess engines. This module -implements a thin abstraction for playing moves and analysing positions -with both kinds of engines. +implements an abstraction for playing moves and analysing positions with +both kinds of engines. :warning: This is an experimental module that may change in semver incompatible ways. Please weigh in on the design if the provided APIs do not cover @@ -113,6 +113,14 @@ Example: Let Stockfish play against itself, 100 milliseconds per move. The response that the engine expects after *move*, or ``None``. + .. py:attribute:: info + + A dictionary of extra information sent by the engine. Known keys are + ``score``, ``depth``, ``seldepth``, ``time``, ``nodes``, ``pv``, + ``multipv``, ``currmove``, ``currmovenumber``, ``hashfull``, ``nps``, + ``tbhits``, ``cpuload``, ``refutation``, ``currline``, ``ebf`` and + ``string``. + Analysing and evaluating a position ----------------------------------- diff --git a/test.py b/test.py index f237c0924..650fb58e2 100755 --- a/test.py +++ b/test.py @@ -3175,36 +3175,36 @@ def main(): loop.run_until_complete(main()) def test_uci_info(self): - ALL = chess.engine.Info.ALL + from chess.engine import INFO_ALL # Info: refutation. board = chess.Board("8/8/6k1/8/8/8/1K6/3B4 w - - 0 1") - info = chess.engine._parse_uci_info("refutation d1h5 g6h5", board, ALL) + info = chess.engine._parse_uci_info("refutation d1h5 g6h5", board, INFO_ALL) self.assertEqual(info["refutation"][chess.Move.from_uci("d1h5")], [chess.Move.from_uci("g6h5")]) - info = chess.engine._parse_uci_info("refutation d1h5", board, ALL) + info = chess.engine._parse_uci_info("refutation d1h5", board, INFO_ALL) self.assertEqual(info["refutation"][chess.Move.from_uci("d1h5")], []) # Info: string. - info = chess.engine._parse_uci_info("string goes to end no matter score cp 4 what", None, ALL) + info = chess.engine._parse_uci_info("string goes to end no matter score cp 4 what", None, INFO_ALL) self.assertEqual(info["string"], "goes to end no matter score cp 4 what") # Info: currline. - info = chess.engine._parse_uci_info("currline 0 e2e4 e7e5", chess.Board(), ALL) + info = chess.engine._parse_uci_info("currline 0 e2e4 e7e5", chess.Board(), INFO_ALL) self.assertEqual(info["currline"][0], [chess.Move.from_uci("e2e4"), chess.Move.from_uci("e7e5")]) # Info: ebf. - info = chess.engine._parse_uci_info("ebf 0.42", None, ALL) + info = chess.engine._parse_uci_info("ebf 0.42", None, INFO_ALL) self.assertEqual(info["ebf"], 0.42) # Info: depth, seldepth, score mate. - info = chess.engine._parse_uci_info("depth 7 seldepth 8 score mate 3", None, ALL) + info = chess.engine._parse_uci_info("depth 7 seldepth 8 score mate 3", None, INFO_ALL) self.assertEqual(info["depth"], 7) self.assertEqual(info["seldepth"], 8) self.assertEqual(info["score"], chess.engine.Mate.plus(3)) # Info: tbhits, cpuload, hashfull, time, nodes, nps. - info = chess.engine._parse_uci_info("tbhits 123 cpuload 456 hashfull 789 time 987 nodes 654 nps 321", None, ALL) + info = chess.engine._parse_uci_info("tbhits 123 cpuload 456 hashfull 789 time 987 nodes 654 nps 321", None, INFO_ALL) self.assertEqual(info["tbhits"], 123) self.assertEqual(info["cpuload"], 456) self.assertEqual(info["hashfull"], 789) From 5a5fda9b26db51165e5f9ad6e77f5652cb81e9cf Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 4 Jan 2019 22:57:46 +0100 Subject: [PATCH 0140/1451] Bump copyright year --- chess/__init__.py | 2 +- chess/_engine.py | 2 +- chess/gaviota.py | 2 +- chess/pgn.py | 2 +- chess/polyglot.py | 2 +- chess/svg.py | 2 +- chess/syzygy.py | 2 +- chess/uci.py | 2 +- chess/variant.py | 2 +- chess/xboard.py | 4 ++-- docs/conf.py | 2 +- setup.py | 2 +- test.py | 2 +- 13 files changed, 14 insertions(+), 14 deletions(-) diff --git a/chess/__init__.py b/chess/__init__.py index fcf0f87f6..48b565d2b 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of the python-chess library. -# Copyright (C) 2012-2018 Niklas Fiekas +# Copyright (C) 2012-2019 Niklas Fiekas # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/chess/_engine.py b/chess/_engine.py index 12ad4a93c..3ebce28f5 100644 --- a/chess/_engine.py +++ b/chess/_engine.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of the python-chess library. -# Copyright (C) 2012-2018 Niklas Fiekas +# Copyright (C) 2012-2019 Niklas Fiekas # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/chess/gaviota.py b/chess/gaviota.py index 8f6895805..20119412d 100644 --- a/chess/gaviota.py +++ b/chess/gaviota.py @@ -2,7 +2,7 @@ # # This file is part of the python-chess library. # Copyright (C) 2015 Jean-Noël Avila -# Copyright (C) 2015-2018 Niklas Fiekas +# Copyright (C) 2015-2019 Niklas Fiekas # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/chess/pgn.py b/chess/pgn.py index ca48d5edf..0de2a0cb3 100644 --- a/chess/pgn.py +++ b/chess/pgn.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of the python-chess library. -# Copyright (C) 2012-2018 Niklas Fiekas +# Copyright (C) 2012-2019 Niklas Fiekas # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/chess/polyglot.py b/chess/polyglot.py index 69d3dec74..baeeda70a 100644 --- a/chess/polyglot.py +++ b/chess/polyglot.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of the python-chess library. -# Copyright (C) 2012-2018 Niklas Fiekas +# Copyright (C) 2012-2019 Niklas Fiekas # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/chess/svg.py b/chess/svg.py index a91cfccd6..06245f353 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of the python-chess library. -# Copyright (C) 2016-2018 Niklas Fiekas +# Copyright (C) 2016-2019 Niklas Fiekas # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/chess/syzygy.py b/chess/syzygy.py index ca7102e37..391c961b0 100644 --- a/chess/syzygy.py +++ b/chess/syzygy.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of the python-chess library. -# Copyright (C) 2012-2018 Niklas Fiekas +# Copyright (C) 2012-2019 Niklas Fiekas # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/chess/uci.py b/chess/uci.py index 1f1bb145a..f617801e1 100644 --- a/chess/uci.py +++ b/chess/uci.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of the python-chess library. -# Copyright (C) 2012-2018 Niklas Fiekas +# Copyright (C) 2012-2019 Niklas Fiekas # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/chess/variant.py b/chess/variant.py index e6f448ce6..79a732711 100644 --- a/chess/variant.py +++ b/chess/variant.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of the python-chess library. -# Copyright (C) 2016-2018 Niklas Fiekas +# Copyright (C) 2016-2019 Niklas Fiekas # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/chess/xboard.py b/chess/xboard.py index 10ec54187..7e7b909c2 100644 --- a/chess/xboard.py +++ b/chess/xboard.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- # # This file is part of the python-chess library. -# Copyright (C) 2017-2018 Manik Charan -# Copyright (C) 2017-2018 Niklas Fiekas +# Copyright (C) 2017-2019 Manik Charan +# Copyright (C) 2017-2019 Niklas Fiekas # Copyright (C) 2017 Cash Costello # # This program is free software: you can redistribute it and/or modify diff --git a/docs/conf.py b/docs/conf.py index 7741abbec..fcdb274f9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -19,7 +19,7 @@ # General information about the project. project = "python-chess" -copyright = "2014–2018, Niklas Fiekas" +copyright = "2014–2019, Niklas Fiekas" # The version. version = chess.__version__ diff --git a/setup.py b/setup.py index 36ca1e7a2..860122194 100755 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # # This file is part of the python-chess library. -# Copyright (C) 2012-2018 Niklas Fiekas +# Copyright (C) 2012-2019 Niklas Fiekas # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by diff --git a/test.py b/test.py index b8bb34af8..0f30b754e 100755 --- a/test.py +++ b/test.py @@ -2,7 +2,7 @@ # -*- coding: utf-8 -*- # # This file is part of the python-chess library. -# Copyright (C) 2012-2018 Niklas Fiekas +# Copyright (C) 2012-2019 Niklas Fiekas # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by From a596e8567da0fdd8766244399edd99b939a6876a Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 4 Jan 2019 23:04:31 +0100 Subject: [PATCH 0141/1451] GameModelCreator -> GameCreator --- chess/pgn.py | 10 +++++++--- docs/pgn.rst | 2 +- test.py | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/chess/pgn.py b/chess/pgn.py index 0de2a0cb3..a1f6d5f18 100644 --- a/chess/pgn.py +++ b/chess/pgn.py @@ -746,7 +746,7 @@ def handle_error(self, error): raise error -class GameModelCreator(BaseVisitor): +class GameCreator(BaseVisitor): """ Creates a game model. Default visitor for :func:`~chess.pgn.read_game()`. """ @@ -1019,7 +1019,7 @@ def __str__(self): return self.__repr__() -def read_game(handle, *, Visitor=GameModelCreator): +def read_game(handle, *, Visitor=GameCreator): """ Reads a game from a file opened in text mode. @@ -1065,7 +1065,7 @@ def read_game(handle, *, Visitor=GameModelCreator): The parser is relatively forgiving when it comes to errors. It skips over tokens it can not parse. Any exceptions are logged and collected in :data:`Game.errors `. This behavior can be - :func:`overriden `. + :func:`overriden `. Returns the parsed game or ``None`` if the end of file is reached. """ @@ -1311,3 +1311,7 @@ def skip_game(handle): Skip a game. Returns ``True`` if a game was found and skipped. """ return read_game(handle, Visitor=SkipVisitor) + + +# TODO: Deprecated +GameModelCreator = GameCreator diff --git a/docs/pgn.rst b/docs/pgn.rst index 2b6b3261f..b1d9a43ca 100644 --- a/docs/pgn.rst +++ b/docs/pgn.rst @@ -111,7 +111,7 @@ Visitors are an advanced concept for game tree traversal. The following visitors are readily available. -.. autoclass:: chess.pgn.GameModelCreator +.. autoclass:: chess.pgn.GameCreator :members: handle_error, result .. autoclass:: chess.pgn.HeaderCreator diff --git a/test.py b/test.py index 0f30b754e..b1231ec17 100755 --- a/test.py +++ b/test.py @@ -2373,7 +2373,7 @@ def test_subgame(self): pgn = io.StringIO("1. d4 d5 (1... Nf6 2. c4 (2. Nf3 g6 3. g3))") game = chess.pgn.read_game(pgn) node = game.variations[0].variations[1] - subgame = node.accept_subgame(chess.pgn.GameModelCreator()) + subgame = node.accept_subgame(chess.pgn.GameCreator()) self.assertEqual(subgame.headers["FEN"], "rnbqkb1r/pppppppp/5n2/8/3P4/8/PPP1PPPP/RNBQKBNR w KQkq - 1 2") self.assertEqual(subgame.variations[0].move, chess.Move.from_uci("c2c4")) self.assertEqual(subgame.variations[1].move, chess.Move.from_uci("g1f3")) From ce04ac1ea4d462185223a88a5f00b94ad476fd7d Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 5 Jan 2019 16:31:28 +0100 Subject: [PATCH 0142/1451] Test and fix {ThreeCheck,Crazyhouse}Board.root() (fixes #345) --- chess/variant.py | 22 ++++++++++++++++++++++ test.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/chess/variant.py b/chess/variant.py index 79a732711..40eae4fb0 100644 --- a/chess/variant.py +++ b/chess/variant.py @@ -482,6 +482,7 @@ class ThreeCheckBoard(chess.Board): def __init__(self, fen=starting_fen, chess960=False): self.remaining_checks = [3, 3] + self._root_remaining_checks = None super().__init__(fen, chess960=chess960) def reset_board(self): @@ -495,6 +496,9 @@ def clear_board(self): self.remaining_checks[chess.BLACK] = 3 def push(self, move): + if not self._stack: + self._root_remaining_checks = self.remaining_checks.copy() + super().push(move) if self.is_check(): self.remaining_checks[not self.turn] -= 1 @@ -599,6 +603,13 @@ def mirror(self): board.remaining_checks[chess.BLACK] = self.remaining_checks[chess.WHITE] return board + def root(self): + board = super().root() + if self._stack: + board.remaining_checks[chess.WHITE] = self._root_remaining_checks[chess.WHITE] + board.remaining_checks[chess.BLACK] = self._root_remaining_checks[chess.BLACK] + return board + class CrazyhousePocket: @@ -644,6 +655,7 @@ class CrazyhouseBoard(chess.Board): def __init__(self, fen=starting_fen, chess960=False): self.pockets = [CrazyhousePocket(), CrazyhousePocket()] + self._root_pockets = None super().__init__(fen, chess960=chess960) def reset_board(self): @@ -657,6 +669,9 @@ def clear_board(self): self.pockets[chess.BLACK].reset() def push(self, move): + if not self._stack: + self._root_pockets = [pocket.copy() for pocket in self.pockets] + if move.drop: self.pockets[self.turn].remove(move.drop) @@ -817,6 +832,13 @@ def mirror(self): board.pockets[chess.BLACK] = self.pockets[chess.WHITE].copy() return board + def root(self): + board = super().root() + if self._stack: + board.pockets[chess.WHITE] = self._root_pockets[chess.WHITE].copy() + board.pockets[chess.BLACK] = self._root_pockets[chess.BLACK].copy() + return board + def status(self): status = super().status() diff --git a/test.py b/test.py index b1231ec17..1af3be402 100755 --- a/test.py +++ b/test.py @@ -3754,6 +3754,19 @@ def test_three_check_eq(self): self.assertNotEqual(a, c) self.assertNotEqual(b, c) + def test_three_check_root(self): + board = chess.variant.ThreeCheckBoard("r1bq1bnr/pppp1kpp/2n5/4p3/4P3/8/PPPP1PPP/RNBQK1NR w KQ - 2+3 0 4") + root = board.root() + self.assertEqual(root.remaining_checks[chess.WHITE], 2) + self.assertEqual(root.remaining_checks[chess.BLACK], 3) + + board.push_san("Qf3+") + board.push_san("Ke6") + board.push_san("Qb3+") + root = board.root() + self.assertEqual(root.remaining_checks[chess.WHITE], 2) + self.assertEqual(root.remaining_checks[chess.BLACK], 3) + class CrazyhouseTestCase(unittest.TestCase): @@ -3852,6 +3865,21 @@ def test_mirror_pockets(self): board = chess.variant.CrazyhouseBoard(fen) self.assertEqual(board, board.mirror().mirror()) + def test_root_pockets(self): + board = chess.variant.CrazyhouseBoard("r2B1rk1/ppp2ppp/3p4/4p3/2B5/2NP1R1P/PPPn2K1/8/QPBQPRNNbp w - - 40 21") + white_pocket = "qqrbnnpp" + black_pocket = "bp" + self.assertEqual(str(board.root().pockets[chess.WHITE]), white_pocket) + self.assertEqual(str(board.root().pockets[chess.BLACK]), black_pocket) + + board.push_san("N@h6+") + board.push_san("Kh8") + board.push_san("R@g8+") + board.push_san("Rxg8") + board.push_san("Nxf7#") + self.assertEqual(str(board.root().pockets[chess.WHITE]), white_pocket) + self.assertEqual(str(board.root().pockets[chess.BLACK]), black_pocket) + class GiveawayTestCase(unittest.TestCase): From 9e4143fa7f08eaaedf784db07f7b299724559199 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 5 Jan 2019 17:05:53 +0100 Subject: [PATCH 0143/1451] add reminder to check options implementation --- chess/engine.py | 1 + 1 file changed, 1 insertion(+) diff --git a/chess/engine.py b/chess/engine.py index 0abba80aa..f70506933 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -18,6 +18,7 @@ # TODO: XBoard support. Check naming: Is it too UCI specific? # TODO: Test coverage +# TODO: Options restore import abc import asyncio From f0af6833e48830c292aa96d6e4b196ddca353c9e Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 5 Jan 2019 17:05:30 +0100 Subject: [PATCH 0144/1451] Prepare 0.24.2 --- CHANGELOG.rst | 26 ++++++++++++++++++++++++++ chess/__init__.py | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d1a8daae6..0eae2e32b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,32 @@ Changelog for python-chess ========================== +New in v0.24.2 +-------------- + +Bugfixes: + +* `CrazyhouseBoard.root()` and `ThreeCheckBoard.root()` were not returning the + correct pockets and number of remaining checks, respectively. Thanks @gbtami. +* `chess.pgn.skip_game()` now correctly skips PGN comments that contain + line-breaks and PGN header tag notation. + +Changes: + +* Renamed `chess.pgn.GameModelCreator` to `GameCreator`. Alias kept in place + and will be removed in a future release. +* Renamed `chess.engine` to `chess._engine`. Use re-exports from `chess.uci` + or `chess.xboard`. +* Renamed `Board.stack` to `Board._stack`. Do not use this directly. +* Improved memory usage: `Board.legal_moves` and `Board.pseudo_legal_moves` + no longer create reference cycles. PGN visitors can manage headers + themselves. +* Removed previously deprecated items. + +Features: + +* Added `chess.pgn.BaseVisitor.visit_board()` and `chess.pgn.BoardCreator`. + New in v0.24.1, v0.23.11 ------------------------ diff --git a/chess/__init__.py b/chess/__init__.py index 48b565d2b..20c21a365 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -26,7 +26,7 @@ __email__ = "niklas.fiekas@backscattering.de" -__version__ = "0.24.1" +__version__ = "0.24.2" import collections import collections.abc From b4fcbd8e17f4429d9fb52e01c93b9e870be40eb2 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 5 Jan 2019 23:52:44 +0100 Subject: [PATCH 0145/1451] trigger doc rebuild --- docs/engine.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/engine.rst b/docs/engine.rst index c6ff5e790..1308fa6ba 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -9,7 +9,7 @@ both kinds of engines. ways. Please weigh in on the design if the provided APIs do not cover your use case. - The module will eventually replace ``chess.uci`` and ``chess.xboard``, + The intention is to eventually replace ``chess.uci`` and ``chess.xboard``, but not before things have settled down and there has been a transition period. From 5dd264ed3d78c44ab794a71c8c3b5efbbcfdfced Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sun, 6 Jan 2019 11:59:42 +0100 Subject: [PATCH 0146/1451] create a chess.engine.EventLoopPolicy --- chess/engine.py | 111 ++++++++++++++++++++++++++---------------------- test.py | 12 +++--- 2 files changed, 66 insertions(+), 57 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index f70506933..19f32195e 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -75,7 +75,7 @@ class StopAsyncIteration(Exception): MANAGED_UCI_OPTIONS = ["uci_chess960", "uci_variant", "uci_analysemode", "multipv", "ponder"] -def setup_event_loop(): +class EventLoopPolicy(asyncio.DefaultEventLoopPolicy): """ Creates and sets up a new asyncio event loop that is capable of spawning and watching subprocesses. @@ -84,65 +84,71 @@ def setup_event_loop(): running on the main thread. This only affects detection of process termination, not communication. """ - if sys.platform == "win32" or threading.current_thread() == threading.main_thread(): - loop = asyncio.ProactorEventLoop() if sys.platform == "win32" else asyncio.SelectorEventLoop() - asyncio.set_event_loop(loop) - return loop + class _ThreadLocal(threading.local): + _watcher = None + + def __init__(self): + super().__init__() + self._thread_local = self._ThreadLocal() - class PollingChildWatcher(asyncio.SafeChildWatcher): - def __init__(self): - super().__init__() - self._poll_handle = None - self._poll_delay = 0.001 + def get_child_watcher(self): + if sys.platform == "win32" or threading.current_thread() == threading.main_thread(): + return super().get_child_watcher() - def attach_loop(self, loop): - assert loop is None or isinstance(loop, asyncio.AbstractEventLoop) + class PollingChildWatcher(asyncio.SafeChildWatcher): + def __init__(self): + super().__init__() + self._poll_handle = None + self._poll_delay = 0.001 - if self._loop is not None and loop is None and self._callbacks: - warnings.warn("A loop is being detached from a child watcher with pending handlers", RuntimeWarning) + def attach_loop(self, loop): + assert loop is None or isinstance(loop, asyncio.AbstractEventLoop) - if self._poll_handle is not None: - self._poll_handle.cancel() + if self._loop is not None and loop is None and self._callbacks: + warnings.warn("A loop is being detached from a child watcher with pending handlers", RuntimeWarning) - self._loop = loop - if loop is not None: - self._poll_handle = self._loop.call_soon(self._poll) - self._do_waitpid_all() + if self._poll_handle is not None: + self._poll_handle.cancel() - def _poll(self): - if self._loop: - self._do_waitpid_all() - self._poll_delay = min(self._poll_delay * 2, 1.0) - self._poll_handle = self._loop.call_later(self._poll_delay, self._poll) + self._loop = loop + if loop is not None: + self._poll_handle = self._loop.call_soon(self._poll) + self._do_waitpid_all() - class StandaloneSelectorEventLoop(asyncio.SelectorEventLoop): - def __init__(self, child_watcher, selector=None): - super().__init__(selector=selector) - self.child_watcher = child_watcher - self.child_watcher.attach_loop(self) + def _poll(self): + if self._loop: + self._do_waitpid_all() + self._poll_delay = min(self._poll_delay * 2, 1.0) + self._poll_handle = self._loop.call_later(self._poll_delay, self._poll) - @asyncio.coroutine - def _make_subprocess_transport(self, protocol, args, shell, stdin, stdout, stderr, bufsize, extra=None, **kwargs): - # Implementation is exactly the same, but uses local child watcher - # instead getting the global child watcher from the event loop - # policy. - with self.child_watcher as watcher: - waiter = asyncio.Future(loop=self) - transp = asyncio.unix_events._UnixSubprocessTransport(self, protocol, args, shell, stdin, stdout, stderr, bufsize, waiter=waiter, extra=extra, **kwargs) - watcher.add_child_handler(transp.get_pid(), self._child_watcher_callback, transp) + if self._thread_local._watcher is None: + self._thread_local._watcher = PollingChildWatcher() + return self._thread_local._watcher - try: - yield from waiter - except Exception: - transp.close() - yield from transp._wait() - raise + def set_child_watcher(self, watcher): + if sys.platform == "win32" or threading.current_thread() == threading.main_thread(): + return super().set_child_watcher(watcher) + else: + assert watcher is None or isinstance(watcher, asyncio.AbstractChildWatcher) + + if self._thread_local._watcher: + self._thread_local._watcher.close() + self._thread_local._watcher = watcher + + def new_event_loop(self): + return asyncio.ProactorEventLoop() if sys.platform == "win32" else asyncio.SelectorEventLoop() - return transp + def get_event_loop(self): + print("GET EVENT LOOP") + return super().get_event_loop() - loop = StandaloneSelectorEventLoop(PollingChildWatcher()) - asyncio.set_event_loop(loop) - return loop + def set_event_loop(self, loop): + print("SET EVENT LOOP", threading.current_thread()) + super().set_event_loop(loop) + + if sys.platform != "win32" and threading.current_thread() != threading.main_thread(): + print("ATTACHING EVENT LOOP") + self.get_child_watcher().attach_loop(loop) def run_in_background(coroutine): @@ -157,8 +163,11 @@ def run_in_background(coroutine): future = concurrent.futures.Future() + asyncio.set_event_loop_policy(EventLoopPolicy()) + def background(): - loop = setup_event_loop() + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) try: loop.run_until_complete(coroutine(future)) @@ -170,7 +179,7 @@ def background(): try: # Finish all remaining tasks. pending = _all_tasks(loop) - loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + loop.run_until_complete(asyncio.gather(*pending, loop=loop, return_exceptions=True)) # Shutdown async generators. try: diff --git a/test.py b/test.py index 650fb58e2..2d251d174 100755 --- a/test.py +++ b/test.py @@ -3123,8 +3123,8 @@ def main(): yield from protocol.ping() mock.assert_done() - with contextlib.closing(chess.engine.setup_event_loop()) as loop: - loop.run_until_complete(main()) + asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy()) + asyncio.run(main()) def test_uci_debug(self): @asyncio.coroutine @@ -3140,8 +3140,8 @@ def main(): protocol.debug(False) mock.assert_done() - with contextlib.closing(chess.engine.setup_event_loop()) as loop: - loop.run_until_complete(main()) + asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy()) + asyncio.run(main()) def test_uci_go(self): @asyncio.coroutine @@ -3171,8 +3171,8 @@ def main(): self.assertEqual(result.ponder, None) mock.assert_done() - with contextlib.closing(chess.engine.setup_event_loop()) as loop: - loop.run_until_complete(main()) + asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy()) + asyncio.run(main()) def test_uci_info(self): from chess.engine import INFO_ALL From 3a07f26ada4fb868cf49ecff5170c19beecd44d9 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sun, 6 Jan 2019 12:08:32 +0100 Subject: [PATCH 0147/1451] clean up after introducing EventLoopPolicy --- chess/engine.py | 34 ++++++++++++++++------------------ docs/engine.rst | 10 +++++----- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 19f32195e..6f2d3e9de 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -77,12 +77,12 @@ class StopAsyncIteration(Exception): class EventLoopPolicy(asyncio.DefaultEventLoopPolicy): """ - Creates and sets up a new asyncio event loop that is capable of spawning - and watching subprocesses. + An event loop policy that ensures the event loop is capable of spawning + and watching subprocesses, even when not running in the main thread. - Unix: Uses relatively slow polling to watch child processes, when not - running on the main thread. This only affects detection of process - termination, not communication. + Unix: Child watchers are thread local. Uses relatively slow polling to + watch child processes, when not running on the main thread. This only + affects detection of process termination, not communication. """ class _ThreadLocal(threading.local): _watcher = None @@ -128,42 +128,40 @@ def _poll(self): def set_child_watcher(self, watcher): if sys.platform == "win32" or threading.current_thread() == threading.main_thread(): return super().set_child_watcher(watcher) - else: - assert watcher is None or isinstance(watcher, asyncio.AbstractChildWatcher) - if self._thread_local._watcher: - self._thread_local._watcher.close() - self._thread_local._watcher = watcher + assert watcher is None or isinstance(watcher, asyncio.AbstractChildWatcher) + + if self._thread_local._watcher: + self._thread_local._watcher.close() + self._thread_local._watcher = watcher def new_event_loop(self): return asyncio.ProactorEventLoop() if sys.platform == "win32" else asyncio.SelectorEventLoop() - def get_event_loop(self): - print("GET EVENT LOOP") - return super().get_event_loop() - def set_event_loop(self, loop): - print("SET EVENT LOOP", threading.current_thread()) super().set_event_loop(loop) if sys.platform != "win32" and threading.current_thread() != threading.main_thread(): - print("ATTACHING EVENT LOOP") self.get_child_watcher().attach_loop(loop) -def run_in_background(coroutine): +def run_in_background(coroutine, _policy_lock=threading.Lock()): """ Runs ``coroutine(future)`` in a new event loop on a background thread. Blocks and returns the *future* result as soon as it is resolved. The coroutine and all remaining tasks continue running in the background until it is complete. + + Note: This installs an event loop policy for the entire process. """ assert asyncio.iscoroutinefunction(coroutine) future = concurrent.futures.Future() - asyncio.set_event_loop_policy(EventLoopPolicy()) + with _policy_lock: + if not isinstance(asyncio.get_event_loop_policy(), EventLoopPolicy): + asyncio.set_event_loop_policy(EventLoopPolicy()) def background(): loop = asyncio.new_event_loop() diff --git a/docs/engine.rst b/docs/engine.rst index 1308fa6ba..673680c34 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -56,7 +56,7 @@ Example: Let Stockfish play against itself, 100 milliseconds per move. await engine.quit() - chess.engine.setup_event_loop() + asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy()) asyncio.run(main()) .. autoclass:: chess.engine.EngineProtocol @@ -166,7 +166,7 @@ Example: await engine.quit() - chess.engine.setup_event_loop() + asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy()) asyncio.run(main()) .. autoclass:: chess.engine.EngineProtocol @@ -216,7 +216,7 @@ Example: Stream information from the engine and stop on an arbitrary condition. await engine.quit() - chess.engine.setup_event_loop() + asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy()) asyncio.run(main()) .. autoclass:: chess.engine.EngineProtocol @@ -269,7 +269,7 @@ Option(name='Hash', type='spin', default=16, min=1, max=131072, var=[]) # Set an option. await engine.configure({"Hash": 32}) - chess.engine.setup_event_loop() + asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy()) asyncio.run(main()) .. autoclass:: chess.engine.EngineProtocol @@ -362,4 +362,4 @@ Reference .. autoclass:: chess.engine.SimpleAnalysisResult :members: -.. autofunction:: chess.engine.setup_event_loop +.. autofunction:: chess.engine.EventLoopPolicy From 58fa3e65d06a372ee475cd234404205cbf985bca Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sun, 6 Jan 2019 12:18:42 +0100 Subject: [PATCH 0148/1451] EventLoopPolicy documentation tweaks --- chess/engine.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 6f2d3e9de..c03a50efd 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -80,9 +80,12 @@ class EventLoopPolicy(asyncio.DefaultEventLoopPolicy): An event loop policy that ensures the event loop is capable of spawning and watching subprocesses, even when not running in the main thread. - Unix: Child watchers are thread local. Uses relatively slow polling to - watch child processes, when not running on the main thread. This only - affects detection of process termination, not communication. + Windows: Creates a :class:`~asyncio.ProactorEventLoop`. + + Unix: Creates a :class:`~asyncio.SelectorEventLoop`. Child watchers are + thread local. When not running on the main thread, the default child + watchers use relatively slow polling to detect process termination. + This does not affect communication. """ class _ThreadLocal(threading.local): _watcher = None @@ -153,16 +156,17 @@ def run_in_background(coroutine, _policy_lock=threading.Lock()): The coroutine and all remaining tasks continue running in the background until it is complete. - Note: This installs an event loop policy for the entire process. + Note: This installs a :class:`chess.engine.EventLoopPolicy` for the entire + process. """ assert asyncio.iscoroutinefunction(coroutine) - future = concurrent.futures.Future() - with _policy_lock: if not isinstance(asyncio.get_event_loop_policy(), EventLoopPolicy): asyncio.set_event_loop_policy(EventLoopPolicy()) + future = concurrent.futures.Future() + def background(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) From a1d3e4b7ad47bdbfa1c588306064f6c85fd20116 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sun, 6 Jan 2019 13:23:58 +0100 Subject: [PATCH 0149/1451] replace asyncio.run with requires Python 3.7 --- test.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test.py b/test.py index 2d251d174..3c8fff6b8 100755 --- a/test.py +++ b/test.py @@ -3124,7 +3124,8 @@ def main(): mock.assert_done() asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy()) - asyncio.run(main()) + with contextlib.closing(asyncio.new_event_loop()) as loop: + loop.run_until_complete(main()) def test_uci_debug(self): @asyncio.coroutine @@ -3141,7 +3142,8 @@ def main(): mock.assert_done() asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy()) - asyncio.run(main()) + with contextlib.closing(asyncio.new_event_loop()) as loop: + loop.run_until_complete(main()) def test_uci_go(self): @asyncio.coroutine @@ -3172,7 +3174,8 @@ def main(): mock.assert_done() asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy()) - asyncio.run(main()) + with contextlib.closing(asyncio.new_event_loop()) as loop: + loop.run_until_complete(main()) def test_uci_info(self): from chess.engine import INFO_ALL From 41ed83003a6975b10e81e6d01a6b71e228ff7368 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sun, 6 Jan 2019 13:35:44 +0100 Subject: [PATCH 0150/1451] use get_event_loop in tests --- test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test.py b/test.py index 3c8fff6b8..b9a68122a 100755 --- a/test.py +++ b/test.py @@ -3124,7 +3124,7 @@ def main(): mock.assert_done() asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy()) - with contextlib.closing(asyncio.new_event_loop()) as loop: + with contextlib.closing(asyncio.get_event_loop()) as loop: loop.run_until_complete(main()) def test_uci_debug(self): @@ -3142,7 +3142,7 @@ def main(): mock.assert_done() asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy()) - with contextlib.closing(asyncio.new_event_loop()) as loop: + with contextlib.closing(asyncio.get_event_loop()) as loop: loop.run_until_complete(main()) def test_uci_go(self): @@ -3174,7 +3174,7 @@ def main(): mock.assert_done() asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy()) - with contextlib.closing(asyncio.new_event_loop()) as loop: + with contextlib.closing(asyncio.get_event_loop()) as loop: loop.run_until_complete(main()) def test_uci_info(self): From 9b670e07514cbf9d697a0860bbcc096afa825b7c Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sun, 6 Jan 2019 16:51:34 +0100 Subject: [PATCH 0151/1451] Use twine to upload releases (closes #347) --- release.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/release.py b/release.py index 769719dcb..61e76e694 100755 --- a/release.py +++ b/release.py @@ -100,7 +100,9 @@ def update_rtd(): def pypi(): print("--- PYPI ---------------------------------------------------------") - system("python3 setup.py sdist upload") + system("python3 setup.py sdist bdist_wheel") + system("twine check dist/*") + system("twine upload --skip-existing --sign dist/*") def github_release(tagname): From 03ae736446e67035cdebd0d25e0e39f08811023d Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sun, 6 Jan 2019 16:53:15 +0100 Subject: [PATCH 0152/1451] release.py: update_rtd now handled by webhook --- release.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/release.py b/release.py index 61e76e694..8ce114931 100755 --- a/release.py +++ b/release.py @@ -93,11 +93,6 @@ def tag_and_push(): return tagname -def update_rtd(): - print("--- UPDATE RTD ---------------------------------------------------") - system("curl -X POST http://readthedocs.org/build/python-chess") - - def pypi(): print("--- PYPI ---------------------------------------------------------") system("python3 setup.py sdist bdist_wheel") @@ -116,6 +111,5 @@ def github_release(tagname): check_git() check_changelog() tagname = tag_and_push() - update_rtd() pypi() github_release(tagname) From a3a2217043ad73105a07542b5b651f361a2bfa4f Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sun, 6 Jan 2019 17:23:24 +0100 Subject: [PATCH 0153/1451] update engine example in readme --- README.rst | 35 ++++++++--------------------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/README.rst b/README.rst index 879d898c4..7a3ee6190 100644 --- a/README.rst +++ b/README.rst @@ -60,7 +60,7 @@ python-chess: * `Polyglot opening book reading `_ * `Gaviota endgame tablebase probing `_ * `Syzygy endgame tablebase probing `_ -* `UCI engine communication `_ +* `UCI/XBoard engine communication `_ * `Variants `_ * `Changelog `_ @@ -266,40 +266,21 @@ Features >>> tablebase.close() -* Communicate with an UCI engine. - `Docs `__. +* Communicate with UCI/XBoard engines. Based on ``asyncio``. + `Docs `__. .. code:: python - >>> import chess.uci + >>> import chess.engine - >>> engine = chess.uci.popen_engine("stockfish") - >>> engine.uci() - >>> engine.author # doctest: +SKIP - 'Tord Romstad, Marco Costalba and Joona Kiiski' + >>> engine = chess.engine.SimpleEngine.popen_uci("stockfish") - >>> # Synchronous mode. >>> board = chess.Board("1k1r4/pp1b1R2/3q2pp/4p3/2B5/4Q3/PPP2B2/2K5 b - - 0 1") - >>> engine.position(board) - >>> engine.go(movetime=2000) # Gets a tuple of bestmove and ponder move - BestMove(bestmove=Move.from_uci('d6d1'), ponder=Move.from_uci('c1d1')) - - >>> # Asynchronous mode. - >>> def callback(command): - ... bestmove, ponder = command.result() - ... assert bestmove == chess.Move.from_uci('d6d1') - ... - >>> command = engine.go(movetime=2000, async_callback=callback) - >>> command.done() - False - >>> command.result() - BestMove(bestmove=Move.from_uci('d6d1'), ponder=Move.from_uci('c1d1')) - >>> command.done() - True + >>> limit = chess.engine.Limit(movetime=2000) + >>> engine.play(board, limit) # doctest: +ELLIPSIS + - >>> # Quit. >>> engine.quit() - 0 Installing ---------- From 2d14895d701ca1d561cbbd9b570e001c717ec0f1 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 7 Jan 2019 15:38:18 +0100 Subject: [PATCH 0154/1451] Some flake8 --- chess/__init__.py | 9 ++++----- chess/svg.py | 1 - chess/syzygy.py | 1 - chess/xboard.py | 4 ++-- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/chess/__init__.py b/chess/__init__.py index 20c21a365..57abfcc1b 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -34,7 +34,6 @@ import enum import re import itertools -import struct COLORS = [WHITE, BLACK] = [True, False] @@ -2412,7 +2411,7 @@ def _parse_epd_ops(self, operation_part, make_board): operations[opcode] = float(operand) try: operations[opcode] = int(operand) - except: + except ValueError: pass opcode = "" operand = "" @@ -3629,10 +3628,10 @@ def mirror(self): def tolist(self): """Convert the set to a list of 64 bools.""" - l = [False] * 64 + result = [False] * 64 for square in self: - l[square] = True - return l + result[square] = True + return result def __bool__(self): return bool(self.mask) diff --git a/chess/svg.py b/chess/svg.py index 06245f353..78f51855b 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -21,7 +21,6 @@ # GNU General Public License. import chess -import collections import math import xml.etree.ElementTree as ET diff --git a/chess/syzygy.py b/chess/syzygy.py index 391c961b0..0cfc8e3df 100644 --- a/chess/syzygy.py +++ b/chess/syzygy.py @@ -17,7 +17,6 @@ # along with this program. If not, see . import collections -import itertools import mmap import os import re diff --git a/chess/xboard.py b/chess/xboard.py index 7e7b909c2..132b5bb7f 100644 --- a/chess/xboard.py +++ b/chess/xboard.py @@ -519,8 +519,8 @@ def handle_integer_token(token, fn): # Assumption: The hint ponder overrides the pv ponder. # They should be the same in a normal scenario. - making_pv_ponder = False # For the '()' variation - hint_ponder_played = False # For the 'Hint: ' variation + making_pv_ponder = False # For the '()' variation + hint_ponder_played = False # For the 'Hint: ' variation if self.ponder_move: try_move(board, self.ponder_move) hint_ponder_played = True From 584efb605ec76e849820efd3e72dc81bf2f52730 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 7 Jan 2019 15:42:25 +0100 Subject: [PATCH 0155/1451] more flake8 --- chess/engine.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index c03a50efd..81a56b12b 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -27,8 +27,6 @@ import enum import functools import logging -import enum -import collections import warnings import subprocess import sys @@ -41,7 +39,7 @@ except ImportError: try: from asyncio import _get_running_loop - except: + except ImportError: # Python 3.4 def _get_running_loop(): return asyncio.get_event_loop() @@ -805,7 +803,7 @@ def _handle_exception(self, exc): # Prevent warning when the exception is not retrieved. try: self.finished.result() - except: + except Exception: pass def set_finished(self): @@ -1143,7 +1141,7 @@ def _bestmove(self, engine, arg): engine.board.push(bestmove) try: pondermove = engine.board.push_uci(tokens[2]) - except ValueError as err: + except ValueError: LOGGER.exception("engine sent invalid ponder move") self.result.set_result(PlayResult(bestmove, pondermove, self.info)) @@ -1244,12 +1242,12 @@ def end_of_parameter(): info["pv"] = pv if refutation_move is not None: - if not "refutation" in info: + if "refutation" not in info: info["refutation"] = {} info["refutation"][refutation_move] = refuted_by if currline_cpunr is not None: - if not "currline" in info: + if "currline" not in info: info["currline"] = {} info["currline"][currline_cpunr] = currline_moves From 933a7e52b7551a63da7d06c506804cc53e10bdf3 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 7 Jan 2019 19:42:10 +0100 Subject: [PATCH 0156/1451] initialization of xboard protocol --- chess/engine.py | 57 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 81a56b12b..3bafb9ad9 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -333,7 +333,6 @@ def score(self, mate_score=None): >>> mate.score(100000) 99995 """ - raise NotImplementedError @abc.abstractmethod def mate(self): @@ -343,7 +342,6 @@ def mate(self): :warning: This conflates ``Mate.minus(0)`` (we are mated) and ``Mate.plus(0)`` (we have given mate) to ``0``. """ - raise NotImplementedError def is_mate(self): """Tests if this is a mate score.""" @@ -351,7 +349,7 @@ def is_mate(self): @abc.abstractmethod def __neg__(self): - raise NotImplementedError + pass @functools.total_ordering @@ -636,6 +634,11 @@ def __repr__(self): pid = self.transport.get_pid() if self.transport is not None else None return "<{} (pid={})>".format(type(self).__name__, pid) + @abc.abstractmethod + @asyncio.coroutine + def _initialize(self): + pass + @abc.abstractmethod @asyncio.coroutine def ping(self): @@ -643,13 +646,11 @@ def ping(self): Pings the engine and waits for a response. Used to ensure the engine is still alive and idle. """ - raise NotImplementedError @abc.abstractmethod @asyncio.coroutine def configure(self, options): """Configures global engine options.""" - raise NotImplementedError @abc.abstractmethod @asyncio.coroutine @@ -1388,7 +1389,51 @@ class XBoardProtocol(EngineProtocol): An implementation of the `XBoard protocol `_ (CECP). """ - pass + + @asyncio.coroutine + def _initialize(self): + class Command(BaseCommand): + def start(self, engine): + engine.send_line("xboard") + engine.send_line("protover 2") + self.set_finished() + + yield from self.communicate(Command) + + def _ping(self, n): + self.send_line("ping {}".format(n)) + + @asyncio.coroutine + def ping(self): + class Command(BaseCommand): + def start(self, engine): + self.n = id(self) & 0xffff + engine._ping(self.n) + + def line_received(self, engine, line): + if line == "pong {}".format(self.n): + self.set_finished() + else: + LOGGER.warning("%s: Unexpected engine output: %s", engine, line) + + return (yield from self.communicate(Command)) + + @asyncio.coroutine + def play(self): + raise NotImplementedError # TODO + + @asyncio.coroutine + def analysis(self): + raise NotImplementedError # TODO + + @asyncio.coroutine + def configure(self): + raise NotImplementedError # TODO + + @asyncio.coroutine + def quit(self): + self.send_line("quit") + yield from self.returncode class AnalysisResult: From 2b15f25b74ebf12aef37abe1d571da310273b394 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 7 Jan 2019 21:59:34 +0100 Subject: [PATCH 0157/1451] basic infinite xboard analysis --- chess/engine.py | 91 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 83 insertions(+), 8 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 3bafb9ad9..d9fbc4524 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -18,7 +18,6 @@ # TODO: XBoard support. Check naming: Is it too UCI specific? # TODO: Test coverage -# TODO: Options restore import abc import asyncio @@ -1390,16 +1389,33 @@ class XBoardProtocol(EngineProtocol): `XBoard protocol `_ (CECP). """ + def __init__(self): + super().__init__() + self.options = { + "random": False, + "nps": None, + } + self.config = {} + self.board = chess.Board() + self.game = None + @asyncio.coroutine def _initialize(self): class Command(BaseCommand): def start(self, engine): engine.send_line("xboard") engine.send_line("protover 2") - self.set_finished() + engine.send_line("ping 1") + + def line_received(self, engine, line): + if line == "pong 1": + self.set_finished() yield from self.communicate(Command) + def _new(self): + self.send_line("new") + def _ping(self, n): self.send_line("ping {}".format(n)) @@ -1419,16 +1435,75 @@ def line_received(self, engine, line): return (yield from self.communicate(Command)) @asyncio.coroutine - def play(self): - raise NotImplementedError # TODO + def play(self, board, limit, *, game=None, info=INFO_SCORE, ponder=False, searchmoves=None, options={}): + previous_config = self.config.copy() + + if searchmoves is not None: + raise NotImplementedError("xboard searchmoves not implemented yet") + + raise NotImplementedError("xboard play not supported yet") # TODO @asyncio.coroutine - def analysis(self): - raise NotImplementedError # TODO + def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, searchmoves=None, options={}): + previous_config = self.config.copy() + + if searchmoves is not None: + raise NotImplementedError("xboard searchmoves not implemented yet") + + class Command(BaseCommand): + def start(self, engine): + self.analysis = AnalysisResult(stop=lambda: self.cancel(engine)) + + if engine.game != game: + engine._new() + engine.game = game + + engine.send_line("post" if info else "nopost") + engine.send_line("analyze") + + self.result.set_result(self.analysis) + + return (yield from self.communicate(Command)) + + def _configure(self, options): + for name, value in options.items(): + if value is not None and self.config.get(name) == value: + continue + + if name == "nps": + self.send_line("nps {}".format(value)) + self.config[name] = value + elif name == "memory": + # TODO: Requires feature memory=1 + self.send_line("memory {}".format(value)) + self.config[name] = memory + elif name == "cores": + # TODO: Requires feature smp=1 + self.send_line("cores {}".format(value)) + self.config[name] = value + elif name.startswith("egtpath "): + # TODO: Requires feature egt + self.send_line("{} {}".format(name, value)) + self.config[name] = value + elif name == "random": + if value is None or value != self.config.get("random", False): + self.config["random"] = not self.config.get("random", False) + self.send_line("random") + else: + # TODO: Validate option + if value is None: + self.send_line("option {}".format(name)) + else: + self.send_line("option {}={}".format(name, value)) @asyncio.coroutine - def configure(self): - raise NotImplementedError # TODO + def configure(self, options): + class Command(BaseCommand): + def start(self, engine): + engine._configure(options) + self.set_finished() + + return (yield from self.communicate(Command)) @asyncio.coroutine def quit(self): From ca58c87b9f23faf3f1ef4d08fa15ec6c88d29660 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 8 Jan 2019 16:34:26 +0100 Subject: [PATCH 0158/1451] add managed xboard options --- chess/engine.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index d9fbc4524..bc95a430e 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -71,6 +71,8 @@ class StopAsyncIteration(Exception): MANAGED_UCI_OPTIONS = ["uci_chess960", "uci_variant", "uci_analysemode", "multipv", "ponder"] +MANAGED_XBOARD_OPTIONS = ["MultiPV"] # Case sensitive + class EventLoopPolicy(asyncio.DefaultEventLoopPolicy): """ @@ -235,6 +237,9 @@ def parse(self, value): def is_managed_uci(self): return self.name.lower() in MANAGED_UCI_OPTIONS + def is_managed_xboard(self): + return self.name in MANAGED_XBOARD_OPTIONS + class Limit: """Search termination condition.""" From ca055e2ca6e8335572f68a6abbbd66e0cfc226b0 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 8 Jan 2019 16:34:58 +0100 Subject: [PATCH 0159/1451] add repr for BaseCommand --- chess/engine.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index bc95a430e..7e7f62c16 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -747,13 +747,11 @@ def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, asynchronously iterating over the information sent by the engine and stopping the the analysis at any time. """ - raise NotImplementedError @abc.abstractmethod @asyncio.coroutine def quit(self): """Asks the engine to shut down.""" - raise NotImplementedError @classmethod @asyncio.coroutine @@ -850,6 +848,9 @@ def line_received(self, engine, line): def engine_terminated(self, engine, exc): pass + def __repr__(self): + return "<{} at {} (state={}, result={}, finished={}>".format(type(self).__name__, hex(id(self)), self.state, self.result, self.finished) + class UciProtocol(EngineProtocol): """ From 1dcf21e67876b664cccf2e139b708ba3bab4a763 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 8 Jan 2019 21:08:15 +0100 Subject: [PATCH 0160/1451] skeleton of xboard play --- chess/engine.py | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index 7e7f62c16..76bf19482 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1447,7 +1447,36 @@ def play(self, board, limit, *, game=None, info=INFO_SCORE, ponder=False, search if searchmoves is not None: raise NotImplementedError("xboard searchmoves not implemented yet") - raise NotImplementedError("xboard play not supported yet") # TODO + class Command(BaseCommand): + def start(self, engine): + engine.send_line("new") + + # TODO: Variant + variant = type(board).uci_variant + if variant != "chess": + raise NotImplementedError("xboard variants not yet supported") + + if board.chess960: + raise NotImplementedError("xboard: chess960 not yet supported") + + engine.send_line("force") + + if limit.depth is not None: + engine.send_line("sd {}".format(limit.depth)) + if limit.movetime is not None: + engine.send_line("st {}".format(limit.movetime)) # TODO: Check unit + + root = board.root() + fen = root.fen() + if variant != "chess" or fen != chess.STARTING_FEN: + raise NotImplementedError("xboard: non-standard starting position not yet supported") + + for move in board.move_stack: + engine.send_line(move.uci()) + + engine.send_line("go") + + return (yield from self.communicate(Command)) @asyncio.coroutine def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, searchmoves=None, options={}): From d2aedf24028e49d900ff5261a7d06f3c7e1d9066 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 8 Jan 2019 21:19:46 +0100 Subject: [PATCH 0161/1451] finish basic xboard play --- chess/engine.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index 76bf19482..4ca0a6446 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1476,6 +1476,21 @@ def start(self, engine): engine.send_line("go") + def line_received(self, engine, line): + if line.startswith("move "): + self._move(engine, line.split(" ", 1)[1]) + else: + LOGGER.warning("%s: Unexpected engine output: %s", engine, line) + + def _move(self, engine, arg): + try: + move = engine.board.push_uci(arg) + except ValueError: + move = engine.board.push_san(arg) + + self.result.set_result(PlayResult(move, None, {})) + self.set_finished() + return (yield from self.communicate(Command)) @asyncio.coroutine From 595b34f319d65f399d70dbde2ed45173f84eb551 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 8 Jan 2019 22:00:46 +0100 Subject: [PATCH 0162/1451] properly set up position in xboard play --- chess/engine.py | 52 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 4ca0a6446..bb3b9c7bb 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1449,31 +1449,51 @@ def play(self, board, limit, *, game=None, info=INFO_SCORE, ponder=False, search class Command(BaseCommand): def start(self, engine): - engine.send_line("new") - - # TODO: Variant - variant = type(board).uci_variant - if variant != "chess": - raise NotImplementedError("xboard variants not yet supported") + # Setup start position. + root = board.root() + new_game = engine.game != game or root != engine.board.root() + if new_game: + engine.board = root + engine.send_line("new") - if board.chess960: - raise NotImplementedError("xboard: chess960 not yet supported") + variant = type(board).uci_variant + if variant != "chess" or root.fen() != chess.STARTING_FEN: + raise NotImplementedError("xboard: non-standard starting position not yet supported") + if board.chess960: + raise NotImplementedError("xboard: chess960 not yet supported") engine.send_line("force") + # Undo moves until common position. + common_stack_len = 0 + if not new_game: + for left, right in zip(engine.board.move_stack, board.move_stack): + if left == right: + common_stack_len += 1 + else: + break + + while len(engine.board.move_stack) > common_stack_len + 1: + self.send_line("remove") + engine.board.pop() + engine.board.pop() + + while len(engine.board.move_stack) > common_stack_len: + self.send_line("undo") + engine.board.pop() + + # Play moves from board stack. + for move in board.move_stack[common_stack_len:]: + engine.send_line(move.uci()) + engine.board.push(move) + + # Limit or time control. if limit.depth is not None: engine.send_line("sd {}".format(limit.depth)) if limit.movetime is not None: engine.send_line("st {}".format(limit.movetime)) # TODO: Check unit - root = board.root() - fen = root.fen() - if variant != "chess" or fen != chess.STARTING_FEN: - raise NotImplementedError("xboard: non-standard starting position not yet supported") - - for move in board.move_stack: - engine.send_line(move.uci()) - + # Start thinking. engine.send_line("go") def line_received(self, engine, line): From a919b57eb19dcd075b798748f986774ca0268193 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 8 Jan 2019 23:09:09 +0100 Subject: [PATCH 0163/1451] generalize chess.engine.Limit --- chess/engine.py | 44 ++++++++++++++++++++++---------------------- docs/engine.rst | 26 +++++++++++++------------- test.py | 4 ++-- 3 files changed, 37 insertions(+), 37 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index bb3b9c7bb..e019cb1d1 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -244,22 +244,22 @@ def is_managed_xboard(self): class Limit: """Search termination condition.""" - def __init__(self, *, movetime=None, depth=None, nodes=None, mate=None, wtime=None, btime=None, winc=None, binc=None, movestogo=None): - self.movetime = movetime + def __init__(self, *, time=None, depth=None, nodes=None, mate=None, white_clock=None, black_clock=None, white_inc=None, black_inc=None, remaining_moves=None): + self.time = time self.depth = depth self.nodes = nodes self.mate = mate - self.wtime = wtime - self.btime = btime - self.winc = winc - self.binc = binc - self.movestogo = movestogo + self.white_clock = white_clock + self.black_clock = black_clock + self.white_inc = white_inc + self.black_inc = black_inc + self.remaining_moves = remaining_moves def __repr__(self): return "{}({})".format( type(self).__name__, ", ".join("{}={}".format(attr, repr(getattr(self, attr))) - for attr in ["wtime", "btime", "winc", "binc", "movestogo", "depth", "nodes", "mate", "movetime"] + for attr in ["time", "depth", "nodes", "mate", "white_clock", "black_clock", "white_inc", "black_inc", "remaining_moves"] if getattr(self, attr) is not None)) @@ -1046,25 +1046,25 @@ def _go(self, limit, *, searchmoves=None, ponder=False, infinite=False): if ponder: builder.append("ponder") - if limit.wtime is not None: + if limit.white_clock is not None: builder.append("wtime") - builder.append(str(int(limit.wtime))) + builder.append(str(int(limit.white_clock * 1000))) - if limit.btime is not None: + if limit.black_clock is not None: builder.append("btime") - builder.append(str(int(limit.btime))) + builder.append(str(int(limit.black_clock * 1000))) - if limit.winc is not None: + if limit.white_inc is not None: builder.append("winc") - builder.append(str(int(limit.winc))) + builder.append(str(int(limit.white_inc * 1000))) - if limit.binc is not None: + if limit.black_inc is not None: builder.append("binc") - builder.append(str(int(limit.binc))) + builder.append(str(int(limit.black_inc * 1000))) - if limit.movestogo is not None and int(limit.movestogo) > 0: + if limit.remaining_moves is not None and int(limit.remaining_moves) > 0: builder.append("movestogo") - builder.append(str(int(limit.movestogo))) + builder.append(str(int(limit.remaining_moves))) if limit.depth is not None: builder.append("depth") @@ -1078,9 +1078,9 @@ def _go(self, limit, *, searchmoves=None, ponder=False, infinite=False): builder.append("mate") builder.append(str(int(limit.mate))) - if limit.movetime is not None: + if limit.time is not None: builder.append("movetime") - builder.append(str(int(limit.movetime))) + builder.append(str(int(limit.time * 1000))) if infinite: builder.append("infinite") @@ -1490,8 +1490,8 @@ def start(self, engine): # Limit or time control. if limit.depth is not None: engine.send_line("sd {}".format(limit.depth)) - if limit.movetime is not None: - engine.send_line("st {}".format(limit.movetime)) # TODO: Check unit + if limit.time is not None: + engine.send_line("st {}".format(int(limit.movetime * 100))) # Start thinking. engine.send_line("go") diff --git a/docs/engine.rst b/docs/engine.rst index 673680c34..7a1d5cbf6 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -65,9 +65,9 @@ Example: Let Stockfish play against itself, 100 milliseconds per move. .. autoclass:: chess.engine.Limit :members: - .. py:attribute:: movetime + .. py:attribute:: time - Search exactly *movetime* milliseconds. + Search exactly *time* seconds. .. py:attribute:: depth @@ -81,26 +81,26 @@ Example: Let Stockfish play against itself, 100 milliseconds per move. Search for a mate in *mate* moves. - .. py:attribute:: wtime + .. py:attribute:: white_clock - Integer of milliseconds remaining for White. + Time in seconds remaining for White. - .. py:attribute:: btime + .. py:attribute:: black_time - Integer of milliseconds remaining for Black. + Time in seconds remaining for Black. - .. py:attribute:: winc + .. py:attribute:: white_inc - Fisher increment for White. + Fisher increment for White, in seconds. - .. py:attribute:: binc + .. py:attribute:: black_inc - Fisher increment for Black. + Fisher increment for Black, in seconds. - .. py:attribute:: movestogo + .. py:attribute:: remaining_moves - Number of moves to the next time control. If this is not set, but wtime - and btime are, then it is sudden death. + Number of moves to the next time control. If this is not set, but + *white_clock* and *black_clock* are, then it is sudden death. .. autoclass:: chess.engine.PlayResult :members: diff --git a/test.py b/test.py index b9a68122a..4bc2dfd89 100755 --- a/test.py +++ b/test.py @@ -3157,7 +3157,7 @@ def main(): mock.expect("position startpos moves d2d4 d7d5") mock.expect("go ponder movetime 123") board = chess.Board() - result = yield from protocol.play(board, chess.engine.Limit(movetime=123), searchmoves=[board.parse_san("e4"), board.parse_san("d4")], ponder=True) + result = yield from protocol.play(board, chess.engine.Limit(time=0.123), searchmoves=[board.parse_san("e4"), board.parse_san("d4")], ponder=True) self.assertEqual(result.move, chess.Move.from_uci("d2d4")) self.assertEqual(result.ponder, chess.Move.from_uci("d7d5")) self.assertEqual(result.info["string"], "searching ...") @@ -3168,7 +3168,7 @@ def main(): # Limits. mock.expect("position startpos") mock.expect("go wtime 1 btime 2 winc 3 binc 4 movestogo 5 depth 6 nodes 7 mate 8 movetime 9", ["bestmove d2d4"]) - result = yield from protocol.play(board, chess.engine.Limit(wtime=1, btime=2, winc=3, binc=4, movestogo=5, depth=6, nodes=7, mate=8, movetime=9)) + result = yield from protocol.play(board, chess.engine.Limit(white_clock=0.001, black_clock=0.002, white_inc=0.003, black_inc=0.004, remaining_moves=5, depth=6, nodes=7, mate=8, time=0.009)) self.assertEqual(result.move, chess.Move.from_uci("d2d4")) self.assertEqual(result.ponder, None) mock.assert_done() From 8d744b65440d3b77b40a9f266c929520d641b659 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 8 Jan 2019 23:18:18 +0100 Subject: [PATCH 0164/1451] rename searchmoves to root_moves --- chess/engine.py | 56 ++++++++++++++++++++++++------------------------- test.py | 2 +- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index e019cb1d1..797e10f46 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -16,7 +16,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -# TODO: XBoard support. Check naming: Is it too UCI specific? # TODO: Test coverage import abc @@ -658,7 +657,7 @@ def configure(self, options): @abc.abstractmethod @asyncio.coroutine - def play(self, board, limit, *, game=None, info=INFO_SCORE, ponder=False, searchmoves=None, options={}): + def play(self, board, limit, *, game=None, info=INFO_SCORE, ponder=False, root_moves=None, options={}): """ Play a position. @@ -676,7 +675,7 @@ def play(self, board, limit, *, game=None, info=INFO_SCORE, ponder=False, search extra information. :param ponder: Whether the engine should keep analysing in the background even after the result has been returned. - :param searchmoves: Optional. Consider only root moves from this list. + :param root_moves: Optional. Consider only root moves from this list. :param options: Optional. A dictionary of engine options for the analysis. The previous configuration will be restored after the analysis is complete. You can permanently apply a configuration @@ -685,7 +684,7 @@ def play(self, board, limit, *, game=None, info=INFO_SCORE, ponder=False, search raise NotImplementedError @asyncio.coroutine - def analyse(self, board, limit, *, multipv=None, game=None, info=INFO_ALL, searchmoves=None, options={}): + def analyse(self, board, limit, *, multipv=None, game=None, info=INFO_ALL, root_moves=None, options={}): """ Analyses a position and returns an info dictionary. @@ -704,13 +703,13 @@ def analyse(self, board, limit, *, multipv=None, game=None, info=INFO_ALL, searc ``INFO_REFUTATION``, ``INFO_CURRLINE``, ``INFO_ALL`` or any bitwise combination. Some overhead is associated with parsing extra information. - :param searchmoves: Optional. Limit analysis to a list of root moves. + :param root_moves: Optional. Limit analysis to a list of root moves. :param options: Optional. A dictionary of engine options for the analysis. The previous configuration will be restored after the analysis is complete. You can permanently apply a configuration with :func:`~chess.engine.EngineProtocol.configure()`. """ - analysis = yield from self.analysis(board, limit, game=game, info=info, searchmoves=searchmoves, options=options) + analysis = yield from self.analysis(board, limit, game=game, info=info, root_moves=root_moves, options=options) with analysis: yield from analysis.wait() @@ -719,7 +718,7 @@ def analyse(self, board, limit, *, multipv=None, game=None, info=INFO_ALL, searc @abc.abstractmethod @asyncio.coroutine - def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, searchmoves=None, options={}): + def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, root_moves=None, options={}): """ Starts analysing a position. @@ -737,7 +736,7 @@ def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, ``INFO_REFUTATION``, ``INFO_CURRLINE``, ``INFO_ALL`` or any bitwise combination. Some overhead is associated with parsing extra information. - :param searchmoves: Optional. Limit analysis to a list of root moves. + :param root_moves: Optional. Limit analysis to a list of root moves. :param options: Optional. A dictionary of engine options for the analysis. The previous configuration will be restored after the analysis is complete. You can permanently apply a configuration @@ -1040,7 +1039,7 @@ def _position(self, board): self.send_line(" ".join(builder)) self.board = board.copy(stack=False) - def _go(self, limit, *, searchmoves=None, ponder=False, infinite=False): + def _go(self, limit, *, root_moves=None, ponder=False, infinite=False): builder = ["go"] if ponder: @@ -1085,15 +1084,14 @@ def _go(self, limit, *, searchmoves=None, ponder=False, infinite=False): if infinite: builder.append("infinite") - if searchmoves: + if root_moves: builder.append("searchmoves") - for move in searchmoves: - builder.append(self.board.uci(move)) + builder.extend(move.uci() for move in root_moves) self.send_line(" ".join(builder)) @asyncio.coroutine - def play(self, board, limit, *, game=None, info=INFO_SCORE, ponder=False, searchmoves=None, options={}): + def play(self, board, limit, *, game=None, info=INFO_SCORE, ponder=False, root_moves=None, options={}): previous_config = self.config.copy() class Command(BaseCommand): @@ -1113,7 +1111,7 @@ def start(self, engine): engine.game = game engine._position(board) - engine._go(limit, searchmoves=searchmoves) + engine._go(limit, root_moves=root_moves) def line_received(self, engine, line): if line.startswith("info "): @@ -1169,7 +1167,7 @@ def cancel(self, engine): return (yield from self.communicate(Command)) @asyncio.coroutine - def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, searchmoves=None, options={}): + def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, root_moves=None, options={}): previous_config = self.config.copy() class Command(BaseCommand): @@ -1191,9 +1189,9 @@ def start(self, engine): engine._position(board) if limit: - engine._go(limit, searchmoves=searchmoves) + engine._go(limit, root_moves=root_moves) else: - engine._go(Limit(), searchmoves=searchmoves, infinite=True) + engine._go(Limit(), root_moves=root_moves, infinite=True) self.result.set_result(self.analysis) @@ -1441,11 +1439,11 @@ def line_received(self, engine, line): return (yield from self.communicate(Command)) @asyncio.coroutine - def play(self, board, limit, *, game=None, info=INFO_SCORE, ponder=False, searchmoves=None, options={}): + def play(self, board, limit, *, game=None, info=INFO_SCORE, ponder=False, root_moves=None, options={}): previous_config = self.config.copy() - if searchmoves is not None: - raise NotImplementedError("xboard searchmoves not implemented yet") + if root_moves is not None: + raise NotImplementedError("xboard include not implemented yet") class Command(BaseCommand): def start(self, engine): @@ -1514,11 +1512,11 @@ def _move(self, engine, arg): return (yield from self.communicate(Command)) @asyncio.coroutine - def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, searchmoves=None, options={}): + def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, root_moves=None, options={}): previous_config = self.config.copy() - if searchmoves is not None: - raise NotImplementedError("xboard searchmoves not implemented yet") + if root_moves is not None: + raise NotImplementedError("xboard root_moves not implemented yet") class Command(BaseCommand): def start(self, engine): @@ -1732,15 +1730,15 @@ def configure(self, options): def ping(self): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.ping(), self.timeout), self.protocol.loop).result() - def play(self, board, limit, *, game=None, info=INFO_SCORE, searchmoves=None, options={}): - return asyncio.run_coroutine_threadsafe(self.protocol.play(board, limit, game=game, info=info, searchmoves=searchmoves, options=options), self.protocol.loop).result() + def play(self, board, limit, *, game=None, info=INFO_SCORE, root_moves=None, options={}): + return asyncio.run_coroutine_threadsafe(self.protocol.play(board, limit, game=game, info=info, root_moves=root_moves, options=options), self.protocol.loop).result() - def analyse(self, board, limit, *, multipv=None, game=None, info=INFO_ALL, searchmoves=None, options={}): - return asyncio.run_coroutine_threadsafe(self.protocol.analyse(board, limit, multipv=multipv, game=game, info=info, searchmoves=searchmoves, options=options), self.protocol.loop).result() + def analyse(self, board, limit, *, multipv=None, game=None, info=INFO_ALL, root_moves=None, options={}): + return asyncio.run_coroutine_threadsafe(self.protocol.analyse(board, limit, multipv=multipv, game=game, info=info, root_moves=root_moves, options=options), self.protocol.loop).result() - def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, searchmoves=None, options={}): + def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, root_moves=None, options={}): return SimpleAnalysisResult( - asyncio.run_coroutine_threadsafe(self.protocol.analysis(board, limit, multipv=multipv, game=game, info=info, searchmoves=searchmoves, options=options), self.protocol.loop).result(), + asyncio.run_coroutine_threadsafe(self.protocol.analysis(board, limit, multipv=multipv, game=game, info=info, root_moves=root_moves, options=options), self.protocol.loop).result(), self.protocol.loop) def quit(self): diff --git a/test.py b/test.py index 4bc2dfd89..775b67e8d 100755 --- a/test.py +++ b/test.py @@ -3157,7 +3157,7 @@ def main(): mock.expect("position startpos moves d2d4 d7d5") mock.expect("go ponder movetime 123") board = chess.Board() - result = yield from protocol.play(board, chess.engine.Limit(time=0.123), searchmoves=[board.parse_san("e4"), board.parse_san("d4")], ponder=True) + result = yield from protocol.play(board, chess.engine.Limit(time=0.123), root_moves=[board.parse_san("e4"), board.parse_san("d4")], ponder=True) self.assertEqual(result.move, chess.Move.from_uci("d2d4")) self.assertEqual(result.ponder, chess.Move.from_uci("d7d5")) self.assertEqual(result.info["string"], "searching ...") From abc465732361682e90435a0d7c3f5ef19abb2009 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 8 Jan 2019 23:42:10 +0100 Subject: [PATCH 0165/1451] full limit support for xboard play --- chess/engine.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 797e10f46..c74733079 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -657,7 +657,7 @@ def configure(self, options): @abc.abstractmethod @asyncio.coroutine - def play(self, board, limit, *, game=None, info=INFO_SCORE, ponder=False, root_moves=None, options={}): + def play(self, board, limit, *, game=None, info=INFO_NONE, ponder=False, root_moves=None, options={}): """ Play a position. @@ -1091,7 +1091,7 @@ def _go(self, limit, *, root_moves=None, ponder=False, infinite=False): self.send_line(" ".join(builder)) @asyncio.coroutine - def play(self, board, limit, *, game=None, info=INFO_SCORE, ponder=False, root_moves=None, options={}): + def play(self, board, limit, *, game=None, info=INFO_NONE, ponder=False, root_moves=None, options={}): previous_config = self.config.copy() class Command(BaseCommand): @@ -1439,7 +1439,7 @@ def line_received(self, engine, line): return (yield from self.communicate(Command)) @asyncio.coroutine - def play(self, board, limit, *, game=None, info=INFO_SCORE, ponder=False, root_moves=None, options={}): + def play(self, board, limit, *, game=None, info=INFO_NONE, ponder=False, root_moves=None, options={}): previous_config = self.config.copy() if root_moves is not None: @@ -1486,12 +1486,22 @@ def start(self, engine): engine.board.push(move) # Limit or time control. + increment = limit.white_inc if board.turn else limit.black_inc + if limit.remaining_moves or increment: + base_mins, base_secs = divmod(int(limit.white_clock if board.turn else limit.black_clock), 60) + engine.send_line("level {} {}:{02d} {}".format(limit.remaining_moves or 0, base_mins, base_secs, increment)) + if limit.depth is not None: engine.send_line("sd {}".format(limit.depth)) if limit.time is not None: engine.send_line("st {}".format(int(limit.movetime * 100))) + if limit.white_clock is not None: + engine.send_line("{} {}".format("time" if board.turn else "otim", int(limit.white_clock * 100))) + if limit.black_clock is not None: + engine.send_line("{} {}".format("otim" if board.turn else "time", int(limit.black_clock * 100))) # Start thinking. + engine.send_line("post" if info else "nopost") engine.send_line("go") def line_received(self, engine, line): @@ -1730,7 +1740,7 @@ def configure(self, options): def ping(self): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.ping(), self.timeout), self.protocol.loop).result() - def play(self, board, limit, *, game=None, info=INFO_SCORE, root_moves=None, options={}): + def play(self, board, limit, *, game=None, info=INFO_NONE, root_moves=None, options={}): return asyncio.run_coroutine_threadsafe(self.protocol.play(board, limit, game=game, info=info, root_moves=root_moves, options=options), self.protocol.loop).result() def analyse(self, board, limit, *, multipv=None, game=None, info=INFO_ALL, root_moves=None, options={}): From a2008fd9f26b1028285efeaae85869b1c6583a63 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 00:06:13 +0100 Subject: [PATCH 0166/1451] variant support for xboard --- chess/engine.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index c74733079..d0ab6a3de 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1423,6 +1423,10 @@ def _new(self): def _ping(self, n): self.send_line("ping {}".format(n)) + def _variant(self, variant): + # TODO: Validate + self.send_line("variant {}", variant) + @asyncio.coroutine def ping(self): class Command(BaseCommand): @@ -1443,7 +1447,7 @@ def play(self, board, limit, *, game=None, info=INFO_NONE, ponder=False, root_mo previous_config = self.config.copy() if root_moves is not None: - raise NotImplementedError("xboard include not implemented yet") + raise EngineError("play with root_move, but xboard supports include only in analysis mode") class Command(BaseCommand): def start(self, engine): @@ -1455,14 +1459,17 @@ def start(self, engine): engine.send_line("new") variant = type(board).uci_variant - if variant != "chess" or root.fen() != chess.STARTING_FEN: - raise NotImplementedError("xboard: non-standard starting position not yet supported") - if board.chess960: - raise NotImplementedError("xboard: chess960 not yet supported") + if variant == "chess" and board.chess960: + engine._variant("fischerandom") + elif variant != "chess": + engine._variant(variant) - engine.send_line("force") + fen = root.fen() + if variant != "chess" or fen != chess.STARTING_FEN or board.chess960: + engine.end_line("setboard {}".format(root.shredder_fen() if board.chess960 else fen)) # Undo moves until common position. + engine.send_line("force") common_stack_len = 0 if not new_game: for left, right in zip(engine.board.move_stack, board.move_stack): From 819b931272b5d907fb44867a56887cc71feed9eb Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 00:15:24 +0100 Subject: [PATCH 0167/1451] fix restore of temporary uci options --- chess/engine.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index d0ab6a3de..a722dee88 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1103,6 +1103,8 @@ def start(self, engine): engine._setoption("UCI_AnalyseMode", False) if "Ponder" in engine.options: engine._setoption("Ponder", ponder) + if "MultiPV" in engine.options: + engine._setoption("MultiPV", engine.options["MultiPV"].default) engine._configure(options) @@ -1158,6 +1160,9 @@ def _bestmove(self, engine, arg): if not self.pondering: for name, value in previous_config.items(): engine._setoption(name, value) + for name, option in engine.options.items(): + if name not in ["UCI_AnalyseMode", "Ponder"] and name not in previous_config: + engine._setoption(name, option.default) self.set_finished() @@ -1177,7 +1182,7 @@ def start(self, engine): if "UCI_AnalyseMode" in engine.options: engine._setoption("UCI_AnalyseMode", True) - if multipv and multipv > 1: + if "MultiPV" in engine.options or (multipv and multipv > 1): engine._setoption("MultiPV", multipv) engine._configure(options) @@ -1209,6 +1214,9 @@ def _info(self, engine, arg): def _bestmove(self, engine, arg): for name, value in previous_config.items(): engine._setoption(name, value) + for name, option in engine.options.items(): + if name not in ["UCI_AnalyseMode", "Ponder", "MultiPV"] and name not in previous_config: + engine._setoption(name, option.default) self.analysis.set_finished() self.set_finished() From 1fe3b69103901b81aa9e46004f023970002c3885 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 00:18:00 +0100 Subject: [PATCH 0168/1451] add Info.BASIC --- chess/engine.py | 28 +++++++++++++++------------- test.py | 2 +- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index a722dee88..ec0c999db 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -277,19 +277,21 @@ def __repr__(self): class Info(_IntFlag): """Select information sent by the chess engine.""" NONE = 0 - SCORE = 1 - PV = 2 - REFUTATION = 4 - CURRLINE = 8 - ALL = 15 - - -INFO_NONE = 0 -INFO_SCORE = 1 -INFO_PV = 2 -INFO_REFUTATION = 4 -INFO_CURRLINE = 8 -INFO_ALL = INFO_SCORE | INFO_PV | INFO_REFUTATION | INFO_CURRLINE + BASIC = 1 + SCORE = 2 + PV = 4 + REFUTATION = 8 + CURRLINE = 16 + ALL = BASIC | SCORE | PV | REFUTATION | CURRLINE + + +INFO_NONE = Info.NONE +INFO_BASIC = Info.BASIC +INFO_SCORE = Info.SCORE +INFO_PV = Info.PV +INFO_REFUTATION = Info.REFUTATION +INFO_CURRLINE = Info.CURRLINE +INFO_ALL = Info.ALL class Score(abc.ABC): diff --git a/test.py b/test.py index 775b67e8d..38f330430 100755 --- a/test.py +++ b/test.py @@ -3157,7 +3157,7 @@ def main(): mock.expect("position startpos moves d2d4 d7d5") mock.expect("go ponder movetime 123") board = chess.Board() - result = yield from protocol.play(board, chess.engine.Limit(time=0.123), root_moves=[board.parse_san("e4"), board.parse_san("d4")], ponder=True) + result = yield from protocol.play(board, chess.engine.Limit(time=0.123), root_moves=[board.parse_san("e4"), board.parse_san("d4")], ponder=True, info=chess.engine.INFO_ALL) self.assertEqual(result.move, chess.Move.from_uci("d2d4")) self.assertEqual(result.ponder, chess.Move.from_uci("d7d5")) self.assertEqual(result.info["string"], "searching ...") From 83c663f245e9687910f61521f695ff32316d1f44 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 00:33:45 +0100 Subject: [PATCH 0169/1451] xboard new resets random --- chess/engine.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index ec0c999db..f1961e68a 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1427,9 +1427,6 @@ def line_received(self, engine, line): yield from self.communicate(Command) - def _new(self): - self.send_line("new") - def _ping(self, n): self.send_line("ping {}".format(n)) @@ -1437,6 +1434,12 @@ def _variant(self, variant): # TODO: Validate self.send_line("variant {}", variant) + def _new(self): + self.send_line("new") + + if "random" in self.config: + del self.config["random"] + @asyncio.coroutine def ping(self): class Command(BaseCommand): @@ -1466,7 +1469,7 @@ def start(self, engine): new_game = engine.game != game or root != engine.board.root() if new_game: engine.board = root - engine.send_line("new") + engine._new() variant = type(board).uci_variant if variant == "chess" and board.chess960: From 51aeffce4a299ae38892bcdbab49c28504a5b656 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 01:13:29 +0100 Subject: [PATCH 0170/1451] pong after xboard play --- chess/engine.py | 42 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index f1961e68a..5bff62e42 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1460,10 +1460,12 @@ def play(self, board, limit, *, game=None, info=INFO_NONE, ponder=False, root_mo previous_config = self.config.copy() if root_moves is not None: - raise EngineError("play with root_move, but xboard supports include only in analysis mode") + raise EngineError("play with root_moves, but xboard supports include only in analysis mode") class Command(BaseCommand): def start(self, engine): + self.stopped = False + # Setup start position. root = board.root() new_game = engine.game != game or root != engine.board.root() @@ -1522,22 +1524,50 @@ def start(self, engine): # Start thinking. engine.send_line("post" if info else "nopost") + engine.send_line("hard" if ponder else "easy") engine.send_line("go") def line_received(self, engine, line): if line.startswith("move "): self._move(engine, line.split(" ", 1)[1]) + elif line.startswith("pong "): + self._pong(engine, line.split(" ", 1)[1]) else: LOGGER.warning("%s: Unexpected engine output: %s", engine, line) - def _move(self, engine, arg): + def _pong(self, engine, n): try: - move = engine.board.push_uci(arg) + n = int(n) except ValueError: - move = engine.board.push_san(arg) + LOGGER.error("%s: Invalid pong: %s", self, n) - self.result.set_result(PlayResult(move, None, {})) - self.set_finished() + if n == id(self) & 0xffff: + self.set_finished() + + def _move(self, engine, arg): + if self.result.cancelled(): + return + else: + try: + move = engine.board.push_uci(arg) + except ValueError: + move = engine.board.push_san(arg) + + self.result.set_result(PlayResult(move, None, {})) + engine._ping(id(self) & 0xffff) + + def cancel(self, engine): + if self.stopped: + return + self.stopped = True + + if ponder: + engine.send_line("easy") + + if self.result.cancelled(): + engine._ping(id(self) & 0xffff) + else: + engine.send_line("?") return (yield from self.communicate(Command)) From f1c64db8e3636d2bc7769f20e3547da89261ffa1 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 01:38:38 +0100 Subject: [PATCH 0171/1451] mention INFO_BASE --- chess/engine.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 5bff62e42..101ddbcdc 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -671,7 +671,7 @@ def play(self, board, limit, *, game=None, info=INFO_NONE, ponder=False, root_mo Will automatically clear hashtables if the object is not equal to the previous game. :param info: Selects which additional information to retrieve from the - engine. ``INFO_NONE``, ``INFO_SCORE``, ``INFO_PV``, + engine. ``INFO_NONE``, ``INFO_BASE``, ``INFO_SCORE``, ``INFO_PV``, ``INFO_REFUTATION``, ``INFO_CURRLINE``, ``INFO_ALL`` or any bitwise combination. Some overhead is associated with parsing extra information. @@ -683,7 +683,6 @@ def play(self, board, limit, *, game=None, info=INFO_NONE, ponder=False, root_mo analysis is complete. You can permanently apply a configuration with :func:`~chess.engine.EngineProtocol.configure()`. """ - raise NotImplementedError @asyncio.coroutine def analyse(self, board, limit, *, multipv=None, game=None, info=INFO_ALL, root_moves=None, options={}): @@ -701,7 +700,7 @@ def analyse(self, board, limit, *, multipv=None, game=None, info=INFO_ALL, root_ Will automatically clear hashtables if the object is not equal to the previous game. :param info: Selects which information to retrieve from the - engine. ``INFO_NONE``, ``INFO_SCORE``, ``INFO_PV``, + engine. ``INFO_NONE``, ``INFO_BASE``, ``INFO_SCORE``, ``INFO_PV``, ``INFO_REFUTATION``, ``INFO_CURRLINE``, ``INFO_ALL`` or any bitwise combination. Some overhead is associated with parsing extra information. @@ -734,7 +733,7 @@ def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, Will automatically clear hashtables if the object is not equal to the previous game. :param info: Selects which information to retrieve from the - engine. ``INFO_NONE``, ``INFO_SCORE``, ``INFO_PV``, + engine. ``INFO_NONE``, ``INFO_BASE``, ``INFO_SCORE``, ``INFO_PV``, ``INFO_REFUTATION``, ``INFO_CURRLINE``, ``INFO_ALL`` or any bitwise combination. Some overhead is associated with parsing extra information. From 98e671192a854ec2adf1776549c0e2919670c75b Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 13:19:34 +0100 Subject: [PATCH 0172/1451] rewrite xboard initialization without ping --- chess/engine.py | 57 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 101ddbcdc..18f7bd769 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -26,6 +26,7 @@ import functools import logging import warnings +import shlex import subprocess import sys import threading @@ -1404,6 +1405,8 @@ class XBoardProtocol(EngineProtocol): def __init__(self): super().__init__() + self.features = {} + self.id = {} self.options = { "random": False, "nps": None, @@ -1418,11 +1421,59 @@ class Command(BaseCommand): def start(self, engine): engine.send_line("xboard") engine.send_line("protover 2") - engine.send_line("ping 1") + self.timeout_handle = engine.loop.call_later(2.0, lambda: self.timeout(engine)) + + def timeout(self, engine): + LOGGER.error("%s: Timeout during initialization", engine) + self.end(engine) def line_received(self, engine, line): - if line == "pong 1": - self.set_finished() + if line.startswith("#"): + pass + elif line.startswith("feature "): + self._feature(engine, line.split(" ", 1)[1]) + + def _feature(self, engine, arg): + for feature in shlex.split(arg): + key, value = feature.split("=", 1) + if key == "option": + pass + else: + try: + engine.features[key] = int(value) + except ValueError: + engine.features[key] = value + + if "done" in engine.features: + self.timeout_handle.cancel() + if engine.features.get("done"): + self.end(engine) + + def end(self, engine): + if not engine.features.get("ping", 0): + self.result.set_exception(EngineError("xboard engine did not declare required feature: ping")) + if not engine.features.get("setboard", 0): + self.result.set_exception(EngineError("xboard engine did not declare required feature: setboard")) + + if not engine.features.get("reuse", 1): + LOGGER.warning("%s: Rejecting feature reuse=0", engine) + engine.send_line("reject reuse") + if not engine.features.get("sigterm", 1): + LOGGER.warning("%s: Rejecting feature sigterm=0", engine) + engine.send_line("reject sigterm") + if engine.features.get("usermove", 0): + LOGGER.warning("%s: Rejecting feature usermove=1", engine) + engine.send_line("reject usermove") + if engine.features.get("san", 0): + LOGGER.warning("%s: Rejecting feature san=1", engine) + engine.send_line("reject san") + + try: + engine.id["name"] = engine.features["myname"] + except KeyError: + pass + + self.set_finished() yield from self.communicate(Command) From fa6a05c5f732f9038e21640bf31c71f0ac70820e Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 13:25:22 +0100 Subject: [PATCH 0173/1451] expose engine id --- chess/engine.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 18f7bd769..a542f900c 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -864,6 +864,7 @@ def __init__(self): super().__init__() self.options = UciOptionMap() self.config = UciOptionMap() + self.id = {} self.board = chess.Board() self.game = None @@ -878,6 +879,8 @@ def line_received(self, engine, line): self.set_finished() elif line.startswith("option "): self._option(engine, line.split(" ", 1)[1]) + elif line.startswith("id "): + self._id(engine, line.split(" ", 1)[1]) def _option(self, engine, arg): current_parameter = None @@ -936,6 +939,10 @@ def _option(self, engine, arg): option = Option(name, type, without_default.parse(default), min, max, var) engine.options[option.name] = option + def _id(self, engine, arg): + key, value = arg.split(" ", 1) + engine.id[key] = value + return (yield from self.communicate(Command)) def _isready(self): @@ -1444,6 +1451,8 @@ def _feature(self, engine, arg): except ValueError: engine.features[key] = value + if "myname" in engine.features: + engine.id["name"] = engine.features["myname"] if "done" in engine.features: self.timeout_handle.cancel() if engine.features.get("done"): @@ -1468,11 +1477,6 @@ def end(self, engine): LOGGER.warning("%s: Rejecting feature san=1", engine) engine.send_line("reject san") - try: - engine.id["name"] = engine.features["myname"] - except KeyError: - pass - self.set_finished() yield from self.communicate(Command) @@ -1834,6 +1838,13 @@ def _get(): return self.protocol.options.copy() return asyncio.run_coroutine_threadsafe(_get(), self.protocol.loop).result() + @property + def id(self): + @asyncio.coroutine + def _get(): + return self.protocol.id.copy() + return asyncio.run_coroutine_threadsafe(_get(), self.protocol.loop).result() + def configure(self, options): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.configure(options), self.timeout), self.protocol.loop).result() From 13fc53056c442da70e3cfae4662a1f0c1a2ce12f Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 13:28:58 +0100 Subject: [PATCH 0174/1451] add validation for _variant --- chess/engine.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index a542f900c..efa54fdc7 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1485,7 +1485,10 @@ def _ping(self, n): self.send_line("ping {}".format(n)) def _variant(self, variant): - # TODO: Validate + supported_variants = self.features.get("variant", "").split(",") + if variant not in supported_variants: + raise EngineError("unsupported xboard variant: {} (available: {})".format(variant, ", ".join(supported_variants)) + self.send_line("variant {}", variant) def _new(self): From 467c0b01184ef8014b6fa76237c2bf8b31e86d3f Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 13:30:38 +0100 Subject: [PATCH 0175/1451] tweak xboard ping --- chess/engine.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index efa54fdc7..a9935905d 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1487,7 +1487,7 @@ def _ping(self, n): def _variant(self, variant): supported_variants = self.features.get("variant", "").split(",") if variant not in supported_variants: - raise EngineError("unsupported xboard variant: {} (available: {})".format(variant, ", ".join(supported_variants)) + raise EngineError("unsupported xboard variant: {} (available: {})".format(variant, ", ".join(supported_variants))) self.send_line("variant {}", variant) @@ -1501,11 +1501,12 @@ def _new(self): def ping(self): class Command(BaseCommand): def start(self, engine): - self.n = id(self) & 0xffff - engine._ping(self.n) + n = id(self) & 0xffff + self.pong = "pong {}".format(n) + engine._ping(n) def line_received(self, engine, line): - if line == "pong {}".format(self.n): + if line == self.pong: self.set_finished() else: LOGGER.warning("%s: Unexpected engine output: %s", engine, line) From b43e5d121a52424abc4b25ebe7708d809c52ef26 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 13:47:09 +0100 Subject: [PATCH 0176/1451] convert some xboard features to options --- chess/engine.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index a9935905d..f968c8944 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1415,8 +1415,8 @@ def __init__(self): self.features = {} self.id = {} self.options = { - "random": False, - "nps": None, + "random": Option("random", "check", False, None, None, None), + "computer": Option("computer", "check", False, None, None, None), } self.config = {} self.board = chess.Board() @@ -1451,8 +1451,6 @@ def _feature(self, engine, arg): except ValueError: engine.features[key] = value - if "myname" in engine.features: - engine.id["name"] = engine.features["myname"] if "done" in engine.features: self.timeout_handle.cancel() if engine.features.get("done"): @@ -1477,6 +1475,18 @@ def end(self, engine): LOGGER.warning("%s: Rejecting feature san=1", engine) engine.send_line("reject san") + if "myname" in engine.features: + engine.id["name"] = engine.features["myname"] + + if engine.features.get("memory", 0): + engine.options["memory"] = Option("memory", "spin", 16, 1, None, None) + if engine.features.get("smp", 0): + engine.options["cores"] = Option("cores", "spin", 1, 1, None, None) + for egt in engine.features.get("egt", "").split(","): + if egt: + name = "egtpath {}".format(egt) + engine.options[name] = Option(name, "path", None, None, None, None) + self.set_finished() yield from self.communicate(Command) From c963ed6863be419438c58213c562b59d1e944fdf Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 13:52:07 +0100 Subject: [PATCH 0177/1451] add Board.xboard_variant --- chess/__init__.py | 1 + chess/variant.py | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/chess/__init__.py b/chess/__init__.py index c18421edb..0e995521c 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -1391,6 +1391,7 @@ class Board(BaseBoard): aliases = ["Standard", "Chess", "Classical", "Normal"] uci_variant = "chess" + xboard_variant = "normal" starting_fen = STARTING_FEN tbw_suffix = ".rtbw" diff --git a/chess/variant.py b/chess/variant.py index e6f448ce6..7faf853a8 100644 --- a/chess/variant.py +++ b/chess/variant.py @@ -25,6 +25,7 @@ class SuicideBoard(chess.Board): aliases = ["Suicide", "Suicide chess"] uci_variant = "suicide" + xboard_variant = "suicide" tbw_suffix = ".stbw" tbz_suffix = ".stbz" @@ -176,6 +177,7 @@ class GiveawayBoard(SuicideBoard): aliases = ["Giveaway", "Giveaway chess", "Anti", "Antichess", "Anti chess"] uci_variant = "giveaway" + xboard_variant = "giveaway" starting_fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1" tbw_suffix = ".gtbw" @@ -208,6 +210,7 @@ class AtomicBoard(chess.Board): aliases = ["Atomic", "Atom", "Atomic chess"] uci_variant = "atomic" + xboard_variant = "atomic" tbw_suffix = ".atbw" tbz_suffix = ".atbz" @@ -326,6 +329,7 @@ class KingOfTheHillBoard(chess.Board): aliases = ["King of the Hill", "KOTH"] uci_variant = "kingofthehill" + xboard_variant = "kingofthehill" # Unofficial tbw_suffix = tbz_suffix = None tbw_magic = tbz_magic = None @@ -347,6 +351,7 @@ class RacingKingsBoard(chess.Board): aliases = ["Racing Kings", "Racing", "Race", "racingkings"] uci_variant = "racingkings" + xboard_variant "racingkings" # Unofficial starting_fen = "8/8/8/8/8/8/krbnNBRK/qrbnNBRQ w - - 0 1" tbw_suffix = tbz_suffix = None @@ -428,6 +433,7 @@ class HordeBoard(chess.Board): aliases = ["Horde", "Horde chess"] uci_variant = "horde" + xboard_variant = "horde" # Unofficial starting_fen = "rnbqkbnr/pppppppp/8/1PP2PP1/PPPPPPPP/PPPPPPPP/PPPPPPPP/PPPPPPPP w kq - 0 1" tbw_suffix = tbz_suffix = None @@ -475,6 +481,7 @@ class ThreeCheckBoard(chess.Board): aliases = ["Three-check", "Three check", "Threecheck", "Three check chess"] uci_variant = "3check" + xboard_variant = "3check" starting_fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 3+3 0 1" tbw_suffix = tbz_suffix = None @@ -637,6 +644,7 @@ class CrazyhouseBoard(chess.Board): aliases = ["Crazyhouse", "Crazy House", "House", "ZH"] uci_variant = "crazyhouse" + xboard_variant = "crazyhouse" starting_fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR[] w KQkq - 0 1" tbw_suffix = tbz_suffix = None From 39b0a52fea05e1ce0beb185152371d9ebd4ca944 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 14:16:57 +0100 Subject: [PATCH 0178/1451] add support for xboard move parsing --- chess/__init__.py | 56 +++++++++++++++++++++++++++++++++++++++++++++++ chess/variant.py | 2 +- test.py | 12 ++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/chess/__init__.py b/chess/__init__.py index 0e995521c..b43083f75 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -477,6 +477,15 @@ def uci(self): else: return "0000" + def xboard(self): + """ + Gets an XBoard string for the move. + + Same as :func:`~chess.Move.uci()`, except that the notation for null + moves is ``@@@@``. + """ + return self.uci() if self else "@@@@" + def __bool__(self): return bool(self.from_square or self.to_square or self.promotion or self.drop) @@ -2765,6 +2774,53 @@ def push_uci(self, uci): self.push(move) return move + def xboard(self, move, chess960=None): + if chess960 is None: + chess960 = self.chess960 + + if not chess960 or not self.is_castling(move): + return move.xboard() + elif self.is_kingside_castling(move): + return "O-O" + else: + return "O-O-O" + + def parse_xboard(self, xboard): + # Special notation. + if xboard == "@@@@": + return Move.null() + elif "," in xboard: + raise ValueError("unsupported multi-leg xboard move: {}".format(repr(xboard))) + + # Castling. + try: + if xboard == "O-O": + return next(move for move in self.generate_castling_moves() if self.is_kingside_castling(move)) + elif xboard == "O-O-O": + return next(move for move in self.generate_castling_moves() if self.is_queenside_castling(move)) + except StopIteration: + raise ValueError("illegal xboard move: {} in {}".format(repr(xboard), self.fen())) + + # Normal moves. + try: + move = Move.from_uci(xboard) + except ValueError: + raise ValueError("invalid xboard move: {}".format(repr(xboard))) + + # Normalize. + move = self._to_chess960(move) + move = self._from_chess960(self.chess960, move.from_square, move.to_square, move.promotion, move.drop) + + if not self.is_legal(move): + raise ValueError("illegal xboard move: {} in {}".format(repr(xboard), self.fen())) + + return move + + def push_xboard(self, xboard): + move = self.parse_xboard(xboard) + self.push(move) + return move + def is_en_passant(self, move): """Checks if the given pseudo-legal move is an en passant capture.""" return (self.ep_square == move.to_square and diff --git a/chess/variant.py b/chess/variant.py index 7faf853a8..2e9cf5cff 100644 --- a/chess/variant.py +++ b/chess/variant.py @@ -351,7 +351,7 @@ class RacingKingsBoard(chess.Board): aliases = ["Racing Kings", "Racing", "Race", "racingkings"] uci_variant = "racingkings" - xboard_variant "racingkings" # Unofficial + xboard_variant = "racingkings" # Unofficial starting_fen = "8/8/8/8/8/8/krbnNBRK/qrbnNBRQ w - - 0 1" tbw_suffix = tbz_suffix = None diff --git a/test.py b/test.py index 38f330430..2788a646f 100755 --- a/test.py +++ b/test.py @@ -115,6 +115,7 @@ def test_uci_parsing(self): self.assertEqual(chess.Move.from_uci("e7e8q").uci(), "e7e8q") self.assertEqual(chess.Move.from_uci("P@e4").uci(), "P@e4") self.assertEqual(chess.Move.from_uci("B@f4").uci(), "B@f4") + self.assertEqual(chess.Move.from_uci("0000").uci(), "0000") def test_invalid_uci(self): with self.assertRaises(ValueError): @@ -129,6 +130,13 @@ def test_invalid_uci(self): with self.assertRaises(ValueError): chess.Move.from_uci("Q@g9") + def test_xboard_move(self): + self.assertEqual(chess.Move.from_uci("b5c7").xboard(), "b5c7") + self.assertEqual(chess.Move.from_uci("e7e8q").xboard(), "e7e8q") + self.assertEqual(chess.Move.from_uci("P@e4").xboard(), "P@e4") + self.assertEqual(chess.Move.from_uci("B@f4").xboard(), "B@f4") + self.assertEqual(chess.Move.from_uci("0000").xboard(), "@@@@") + def test_copy(self): a = chess.Move.from_uci("N@f3") b = chess.Move.from_uci("a1h8") @@ -303,12 +311,14 @@ def test_castling(self): move = board.parse_san("O-O") self.assertEqual(move, chess.Move.from_uci("e1g1")) self.assertEqual(board.san(move), "O-O") + self.assertEqual(board.xboard(move), "e1g1") self.assertIn(move, board.legal_moves) board.push(move) # Let black castle long. move = board.parse_san("O-O-O") self.assertEqual(board.san(move), "O-O-O") + self.assertEqual(board.xboard(move), "e8c8") self.assertIn(move, board.legal_moves) board.push(move) self.assertEqual(board.fen(), "2kr3r/8/8/8/8/8/8/R4RK1 w - - 3 2") @@ -343,6 +353,7 @@ def test_ninesixty_castling(self): # Let white do the king side swap. move = board.parse_san("O-O") self.assertEqual(board.san(move), "O-O") + self.assertEqual(board.xboard(move), "O-O") self.assertEqual(move.from_square, chess.F1) self.assertEqual(move.to_square, chess.G1) self.assertIn(move, board.legal_moves) @@ -355,6 +366,7 @@ def test_ninesixty_castling(self): # Let black castle queenside. move = board.parse_san("O-O-O") self.assertEqual(board.san(move), "O-O-O") + self.assertEqual(board.xboard(move), "O-O-O") self.assertEqual(move.from_square, chess.F8) self.assertEqual(move.to_square, chess.D8) self.assertIn(move, board.legal_moves) From 3e14c55adb27c08a392793fbe15f1f006b5aceed Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 14:19:33 +0100 Subject: [PATCH 0179/1451] fall back to san when parsing xboard moves --- chess/__init__.py | 30 ++++++++++-------------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/chess/__init__.py b/chess/__init__.py index b43083f75..6289af454 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -2786,35 +2786,25 @@ def xboard(self, move, chess960=None): return "O-O-O" def parse_xboard(self, xboard): - # Special notation. if xboard == "@@@@": return Move.null() elif "," in xboard: raise ValueError("unsupported multi-leg xboard move: {}".format(repr(xboard))) - # Castling. - try: - if xboard == "O-O": - return next(move for move in self.generate_castling_moves() if self.is_kingside_castling(move)) - elif xboard == "O-O-O": - return next(move for move in self.generate_castling_moves() if self.is_queenside_castling(move)) - except StopIteration: - raise ValueError("illegal xboard move: {} in {}".format(repr(xboard), self.fen())) - - # Normal moves. try: move = Move.from_uci(xboard) + move = self._to_chess960(move) + move = self._from_chess960(self.chess960, move.from_square, move.to_square, move.promotion, move.drop) + if not self.is_legal(move): + raise ValueError("illegal xboard move: {} in {}".format(repr(xboard), self.fen())) + return move except ValueError: - raise ValueError("invalid xboard move: {}".format(repr(xboard))) + pass - # Normalize. - move = self._to_chess960(move) - move = self._from_chess960(self.chess960, move.from_square, move.to_square, move.promotion, move.drop) - - if not self.is_legal(move): - raise ValueError("illegal xboard move: {} in {}".format(repr(xboard), self.fen())) - - return move + try: + return board.parse_san(xboard) + except ValueError: + raise ValueError("invalid or illegal xboard move: {} in {}".format(repr(xboard), self.fen())) def push_xboard(self, xboard): move = self.parse_xboard(xboard) From 422d78f3b904adbfd8258b7c0e0c1e4de3e1df99 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 14:22:03 +0100 Subject: [PATCH 0180/1451] add some implicit tests for parse_xboard --- chess/__init__.py | 2 +- test.py | 34 +++++++++++++++++----------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/chess/__init__.py b/chess/__init__.py index 6289af454..896873549 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -2802,7 +2802,7 @@ def parse_xboard(self, xboard): pass try: - return board.parse_san(xboard) + return self.parse_san(xboard) except ValueError: raise ValueError("invalid or illegal xboard move: {} in {}".format(repr(xboard), self.fen())) diff --git a/test.py b/test.py index 2788a646f..d0b17307f 100755 --- a/test.py +++ b/test.py @@ -308,7 +308,7 @@ def test_castling(self): board = chess.Board("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 1 1") # Let white castle short. - move = board.parse_san("O-O") + move = board.parse_xboard("O-O") self.assertEqual(move, chess.Move.from_uci("e1g1")) self.assertEqual(board.san(move), "O-O") self.assertEqual(board.xboard(move), "e1g1") @@ -316,7 +316,7 @@ def test_castling(self): board.push(move) # Let black castle long. - move = board.parse_san("O-O-O") + move = board.parse_xboard("O-O-O") self.assertEqual(board.san(move), "O-O-O") self.assertEqual(board.xboard(move), "e8c8") self.assertIn(move, board.legal_moves) @@ -1267,25 +1267,25 @@ def test_string_conversion(self): def test_move_info(self): board = chess.Board("r1bqkb1r/p3np2/2n1p2p/1p4pP/2pP4/4PQ1N/1P2BPP1/RNB1K2R w KQkq g6 0 11") - self.assertTrue(board.is_capture(board.parse_san("Qxf7+"))) - self.assertFalse(board.is_en_passant(board.parse_san("Qxf7+"))) - self.assertFalse(board.is_castling(board.parse_san("Qxf7+"))) + self.assertTrue(board.is_capture(board.parse_xboard("Qxf7+"))) + self.assertFalse(board.is_en_passant(board.parse_xboard("Qxf7+"))) + self.assertFalse(board.is_castling(board.parse_xboard("Qxf7+"))) - self.assertTrue(board.is_capture(board.parse_san("hxg6"))) - self.assertTrue(board.is_en_passant(board.parse_san("hxg6"))) - self.assertFalse(board.is_castling(board.parse_san("hxg6"))) + self.assertTrue(board.is_capture(board.parse_xboard("hxg6"))) + self.assertTrue(board.is_en_passant(board.parse_xboard("hxg6"))) + self.assertFalse(board.is_castling(board.parse_xboard("hxg6"))) - self.assertFalse(board.is_capture(board.parse_san("b3"))) - self.assertFalse(board.is_en_passant(board.parse_san("b3"))) - self.assertFalse(board.is_castling(board.parse_san("b3"))) + self.assertFalse(board.is_capture(board.parse_xboard("b3"))) + self.assertFalse(board.is_en_passant(board.parse_xboard("b3"))) + self.assertFalse(board.is_castling(board.parse_xboard("b3"))) - self.assertFalse(board.is_capture(board.parse_san("Ra6"))) - self.assertFalse(board.is_en_passant(board.parse_san("Ra6"))) - self.assertFalse(board.is_castling(board.parse_san("Ra6"))) + self.assertFalse(board.is_capture(board.parse_xboard("Ra6"))) + self.assertFalse(board.is_en_passant(board.parse_xboard("Ra6"))) + self.assertFalse(board.is_castling(board.parse_xboard("Ra6"))) - self.assertFalse(board.is_capture(board.parse_san("O-O"))) - self.assertFalse(board.is_en_passant(board.parse_san("O-O"))) - self.assertTrue(board.is_castling(board.parse_san("O-O"))) + self.assertFalse(board.is_capture(board.parse_xboard("O-O"))) + self.assertFalse(board.is_en_passant(board.parse_xboard("O-O"))) + self.assertTrue(board.is_castling(board.parse_xboard("O-O"))) def test_pin(self): board = chess.Board("rnb1k1nr/2pppppp/3P4/8/1b5q/8/PPPNPBPP/RNBQKB1R w KQkq - 0 1") From 06a9f2786dc5611210ec56144752a7661598cd60 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 14:25:48 +0100 Subject: [PATCH 0181/1451] xboard is undocumented for now --- chess/__init__.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/chess/__init__.py b/chess/__init__.py index 896873549..7cd85d1c3 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -478,12 +478,6 @@ def uci(self): return "0000" def xboard(self): - """ - Gets an XBoard string for the move. - - Same as :func:`~chess.Move.uci()`, except that the notation for null - moves is ``@@@@``. - """ return self.uci() if self else "@@@@" def __bool__(self): From 41f7628def124b3f4640663b3cdfb38f1ed2d87a Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 14:43:22 +0100 Subject: [PATCH 0182/1451] fix xboard configuration --- chess/engine.py | 45 ++++++++++++++++++++------------------------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index f968c8944..f62b696b6 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1480,12 +1480,15 @@ def end(self, engine): if engine.features.get("memory", 0): engine.options["memory"] = Option("memory", "spin", 16, 1, None, None) + engine.send_line("accept memory") if engine.features.get("smp", 0): engine.options["cores"] = Option("cores", "spin", 1, 1, None, None) - for egt in engine.features.get("egt", "").split(","): - if egt: + engine.send_line("accept smp") + if engine.features.get("egt"): + for egt in engine.features["egt"].split(","): name = "egtpath {}".format(egt) engine.options[name] = Option(name, "path", None, None, None, None) + engine.send_line("accept egt") self.set_finished() @@ -1666,31 +1669,23 @@ def _configure(self, options): if value is not None and self.config.get(name) == value: continue - if name == "nps": - self.send_line("nps {}".format(value)) - self.config[name] = value - elif name == "memory": - # TODO: Requires feature memory=1 - self.send_line("memory {}".format(value)) - self.config[name] = memory - elif name == "cores": - # TODO: Requires feature smp=1 - self.send_line("cores {}".format(value)) - self.config[name] = value - elif name.startswith("egtpath "): - # TODO: Requires feature egt + try: + option = self.options[name] + except KeyError: + raise EngineError("unsupported xboard option: {}".format(name)) + + self.config[name] = value = option.parse(value) + + if name in ["memory", "cores"] or name.startswith("egtpath "): self.send_line("{} {}".format(name, value)) - self.config[name] = value - elif name == "random": - if value is None or value != self.config.get("random", False): - self.config["random"] = not self.config.get("random", False) - self.send_line("random") + elif value is None: + self.send_line("option {}".format(name)) + elif value is True: + self.send_line("option {}=1".format(name)) + elif value is False: + self.send_line("option {}=0".format(name)) else: - # TODO: Validate option - if value is None: - self.send_line("option {}".format(name)) - else: - self.send_line("option {}={}".format(name, value)) + self.send_line("option {}={}".format(name, value)) @asyncio.coroutine def configure(self, options): From 287cbe02bfdaa6c837f210725bb0ae43029f27cf Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 15:02:43 +0100 Subject: [PATCH 0183/1451] factor out xboard new and setboard --- chess/engine.py | 104 +++++++++++++++++++++++++----------------------- 1 file changed, 55 insertions(+), 49 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index f62b696b6..00eee2f6d 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -16,8 +16,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -# TODO: Test coverage - import abc import asyncio import collections @@ -1444,6 +1442,7 @@ def _feature(self, engine, arg): for feature in shlex.split(arg): key, value = feature.split("=", 1) if key == "option": + # TODO: Implement xboard option parsing pass else: try: @@ -1499,16 +1498,60 @@ def _ping(self, n): def _variant(self, variant): supported_variants = self.features.get("variant", "").split(",") - if variant not in supported_variants: + if not variant or variant not in supported_variants: raise EngineError("unsupported xboard variant: {} (available: {})".format(variant, ", ".join(supported_variants))) self.send_line("variant {}", variant) - def _new(self): - self.send_line("new") + def _new(self, board, game, options): + self._configure(options) + + # Setup start position. + root = board.root() + new_options = "random" in options or "computer" in options + new_game = self.game != game or new_options or root != self.board.root() + if new_game: + self.board = root + self.send_line("new") + + variant = type(board).xboard_variant + if variant == "normal" and board.chess960: + self._variant("fischerandom") + elif variant != "normal": + self._variant(variant) + + if self.config.get("random"): + self.send_line("random") + if self.config.get("computer"): + self.send_line("computer") + + fen = root.fen() + if variant != "normal" or fen != chess.STARTING_FEN or board.chess960: + self.send_line("setboard {}".format(root.shredder_fen() if board.chess960 else fen)) + + # Undo moves until common position. + self.send_line("force") + common_stack_len = 0 + if not new_game: + for left, right in zip(self.board.move_stack, board.move_stack): + if left == right: + common_stack_len += 1 + else: + break + + while len(self.board.move_stack) > common_stack_len + 1: + self.send_line("remove") + self.board.pop() + self.board.pop() - if "random" in self.config: - del self.config["random"] + while len(self.board.move_stack) > common_stack_len: + self.send_line("undo") + self.board.pop() + + # Play moves from board stack. + for move in board.move_stack[common_stack_len:]: + self.send_line(self.board.xboard(move)) + self.board.push(move) @asyncio.coroutine def ping(self): @@ -1537,46 +1580,7 @@ class Command(BaseCommand): def start(self, engine): self.stopped = False - # Setup start position. - root = board.root() - new_game = engine.game != game or root != engine.board.root() - if new_game: - engine.board = root - engine._new() - - variant = type(board).uci_variant - if variant == "chess" and board.chess960: - engine._variant("fischerandom") - elif variant != "chess": - engine._variant(variant) - - fen = root.fen() - if variant != "chess" or fen != chess.STARTING_FEN or board.chess960: - engine.end_line("setboard {}".format(root.shredder_fen() if board.chess960 else fen)) - - # Undo moves until common position. - engine.send_line("force") - common_stack_len = 0 - if not new_game: - for left, right in zip(engine.board.move_stack, board.move_stack): - if left == right: - common_stack_len += 1 - else: - break - - while len(engine.board.move_stack) > common_stack_len + 1: - self.send_line("remove") - engine.board.pop() - engine.board.pop() - - while len(engine.board.move_stack) > common_stack_len: - self.send_line("undo") - engine.board.pop() - - # Play moves from board stack. - for move in board.move_stack[common_stack_len:]: - engine.send_line(move.uci()) - engine.board.push(move) + engine._new(board, game, options) # Limit or time control. increment = limit.white_inc if board.turn else limit.black_inc @@ -1672,11 +1676,13 @@ def _configure(self, options): try: option = self.options[name] except KeyError: - raise EngineError("unsupported xboard option: {}".format(name)) + raise EngineError("unsupported xboard option or command: {}".format(name)) self.config[name] = value = option.parse(value) - if name in ["memory", "cores"] or name.startswith("egtpath "): + if name in ["random", "computer"]: + pass + elif name in ["memory", "cores"] or name.startswith("egtpath "): self.send_line("{} {}".format(name, value)) elif value is None: self.send_line("option {}".format(name)) From 041885153906b84e56ace73160f507a68696a50d Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 15:13:02 +0100 Subject: [PATCH 0184/1451] implement nps for play --- chess/engine.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index 00eee2f6d..dc6416e36 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1580,6 +1580,7 @@ class Command(BaseCommand): def start(self, engine): self.stopped = False + # Set game, position and configure. engine._new(board, game, options) # Limit or time control. @@ -1588,6 +1589,17 @@ def start(self, engine): base_mins, base_secs = divmod(int(limit.white_clock if board.turn else limit.black_clock), 60) engine.send_line("level {} {}:{02d} {}".format(limit.remaining_moves or 0, base_mins, base_secs, increment)) + if limit.nodes is not None: + if limit.time is not None or limit.white_clock is not None or limit.black_clock is not None or increment is not None: + raise EngineError("xboard does not support mixing node limits with time limits") + + if "nps" not in engine.features: + LOGGER.warning("%s: Engine did not declare explicit support for node limits (feature nps=?)") + elif not engine.features["nps"]: + raise EngineError("xboard engine does not support node limits (feature nps=0)") + + engine.send_line("nps 100") + engine.send_line("st {}".format(int(limit.nodes))) if limit.depth is not None: engine.send_line("sd {}".format(limit.depth)) if limit.time is not None: From bb01a33658acef465989da23ac0ab26111236315 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 15:21:35 +0100 Subject: [PATCH 0185/1451] todo --- chess/engine.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index dc6416e36..1da2d24e5 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1662,8 +1662,12 @@ def cancel(self, engine): def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, root_moves=None, options={}): previous_config = self.config.copy() + if multipv is not None: + raise NotImplementedError("TODO: xboard multipv not implemented yet") if root_moves is not None: - raise NotImplementedError("xboard root_moves not implemented yet") + raise NotImplementedError("TODO: xboard root_moves not implemented yet") + + # TODO: Implement analysis class Command(BaseCommand): def start(self, engine): From 60838669ba2867a0beaadc7ac35daa1bf9a9eadf Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 15:25:12 +0100 Subject: [PATCH 0186/1451] factor out end in uci play --- chess/engine.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 1da2d24e5..73e675850 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1165,13 +1165,16 @@ def _bestmove(self, engine, arg): engine._go(limit, ponder=True) finally: if not self.pondering: - for name, value in previous_config.items(): - engine._setoption(name, value) - for name, option in engine.options.items(): - if name not in ["UCI_AnalyseMode", "Ponder"] and name not in previous_config: - engine._setoption(name, option.default) + self.end(engine) - self.set_finished() + def end(self, engine): + for name, value in previous_config.items(): + engine._setoption(name, value) + for name, option in engine.options.items(): + if name not in ["UCI_AnalyseMode", "Ponder"] and name not in previous_config: + engine._setoption(name, option.default) + + self.set_finished() def cancel(self, engine): engine.send_line("stop") From 344f1d1e8d4745ff2e3e6b7cac974006da1f7512 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 15:44:54 +0100 Subject: [PATCH 0187/1451] finish xboard play --- chess/engine.py | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 73e675850..e227f3d20 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1582,6 +1582,7 @@ def play(self, board, limit, *, game=None, info=INFO_NONE, ponder=False, root_mo class Command(BaseCommand): def start(self, engine): self.stopped = False + self.final_pong = None # Set game, position and configure. engine._new(board, game, options) @@ -1620,44 +1621,41 @@ def start(self, engine): def line_received(self, engine, line): if line.startswith("move "): self._move(engine, line.split(" ", 1)[1]) - elif line.startswith("pong "): - self._pong(engine, line.split(" ", 1)[1]) + elif line == self.final_pong: + if not self.result.done(): + self.result.set_exception(EngineError("xboard engine answered final pong before sending move")) + self.set_finished() + elif line.startswith("#") or line.startswith("Hint:"): + pass else: LOGGER.warning("%s: Unexpected engine output: %s", engine, line) - def _pong(self, engine, n): - try: - n = int(n) - except ValueError: - LOGGER.error("%s: Invalid pong: %s", self, n) - - if n == id(self) & 0xffff: - self.set_finished() - def _move(self, engine, arg): - if self.result.cancelled(): - return - else: + if not self.result.cancelled(): try: move = engine.board.push_uci(arg) except ValueError: move = engine.board.push_san(arg) self.result.set_result(PlayResult(move, None, {})) - engine._ping(id(self) & 0xffff) + + if not ponder: + self.set_finished() def cancel(self, engine): if self.stopped: return self.stopped = True + if self.result.cancelled(): + engine.send_line("?") + if ponder: engine.send_line("easy") - if self.result.cancelled(): - engine._ping(id(self) & 0xffff) - else: - engine.send_line("?") + n = id(self) & 0xffff + self.final_pong = "pong {}".format(n) + engine._ping(n) return (yield from self.communicate(Command)) From 866cf2c42e4de90e63c4df9835db736b6720f3c6 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 15:55:48 +0100 Subject: [PATCH 0188/1451] xboard: edge cases for game termination --- chess/engine.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index e227f3d20..9acb6bcc2 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -264,13 +264,14 @@ def __repr__(self): class PlayResult: """Returned by :func:`chess.engine.EngineProtocol.play()`.""" - def __init__(self, move, ponder, info=None): + def __init__(self, move, ponder, info=None, draw_offered=False): self.move = move self.ponder = ponder self.info = info or {} + self.draw_offered = draw_offered def __repr__(self): - return "<{} at {} (move={}, ponder={}, info={})>".format(type(self).__name__, hex(id(self)), self.move, self.ponder, self.info) + return "<{} at {} (move={}, ponder={}, info={}, draw_offered={})>".format(type(self).__name__, hex(id(self)), self.move, self.ponder, self.info, self.draw_offered) class Info(_IntFlag): @@ -1157,7 +1158,7 @@ def _bestmove(self, engine, arg): except ValueError: LOGGER.exception("engine sent invalid ponder move") - self.result.set_result(PlayResult(bestmove, pondermove, self.info)) + self.result.set_result(PlayResult(bestmove, pondermove, self.info, False)) if ponder and pondermove: self.pondering = True @@ -1581,8 +1582,10 @@ def play(self, board, limit, *, game=None, info=INFO_NONE, ponder=False, root_mo class Command(BaseCommand): def start(self, engine): + self.info = {} self.stopped = False self.final_pong = None + self.draw_offered = False # Set game, position and configure. engine._new(board, game, options) @@ -1625,6 +1628,14 @@ def line_received(self, engine, line): if not self.result.done(): self.result.set_exception(EngineError("xboard engine answered final pong before sending move")) self.set_finished() + elif line == "offer draw": + self.draw_offered = True + elif line == "resign": + self.result.set_result(EngineError("xboard engine resigned")) + self.set_finished() + elif line.startswith("1-0") or line.startswith("0-1") or line.startswith("1/2-1/2"): + self.result.set_result(PlayResult(None, None, self.info, self.draw_offered)) + self.set_finished() elif line.startswith("#") or line.startswith("Hint:"): pass else: @@ -1633,11 +1644,11 @@ def line_received(self, engine, line): def _move(self, engine, arg): if not self.result.cancelled(): try: - move = engine.board.push_uci(arg) + move = engine.board.push_xboard(arg) except ValueError: - move = engine.board.push_san(arg) + self.result.set_exception(EngineError(err)) - self.result.set_result(PlayResult(move, None, {})) + self.result.set_result(PlayResult(move, None, self.info, self.draw_offered)) if not ponder: self.set_finished() From 61ee24f3471a6a479b9e3fa57944cbd6e4c6a46c Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 16:00:26 +0100 Subject: [PATCH 0189/1451] ignore xboard debug output while waiting for pong --- chess/engine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index 9acb6bcc2..1369effc7 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1568,7 +1568,7 @@ def start(self, engine): def line_received(self, engine, line): if line == self.pong: self.set_finished() - else: + elif not line.startswith("#"): LOGGER.warning("%s: Unexpected engine output: %s", engine, line) return (yield from self.communicate(Command)) From eb37c6ee5fd24be24cf2bd8ec7b3abe4c2b35cd2 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 16:09:33 +0100 Subject: [PATCH 0190/1451] prepare handling post output --- chess/engine.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index 1369effc7..c91caf37a 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1638,9 +1638,14 @@ def line_received(self, engine, line): self.set_finished() elif line.startswith("#") or line.startswith("Hint:"): pass + elif len(line.split()) >= 4 and line.lstrip()[0].isdigit(): + self._post(engine, line) else: LOGGER.warning("%s: Unexpected engine output: %s", engine, line) + def _post(self, engine, line): + print(line) + def _move(self, engine, arg): if not self.result.cancelled(): try: From 55c4247b70915df77f70ae2d0310da42b76c0fe3 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 16:46:53 +0100 Subject: [PATCH 0191/1451] implement _parse_xboard_post --- chess/engine.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index c91caf37a..14aba6c13 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1644,7 +1644,8 @@ def line_received(self, engine, line): LOGGER.warning("%s: Unexpected engine output: %s", engine, line) def _post(self, engine, line): - print(line) + if not self.result.done(): + self.info = _parse_xboard_post(line, engine.board, info) def _move(self, engine, arg): if not self.result.cancelled(): @@ -1741,6 +1742,61 @@ def quit(self): yield from self.returncode +def _parse_xboard_post(line, root_board, selector=INFO_ALL): + # Format: depth score time nodes [seldepth [nps [tbhits]]] pv + info = {} + + # Split leading integer tokens from pv. + pv_tokens = line.split() + integer_tokens = [] + while pv_tokens: + token = pv_tokens.pop(0) + try: + integer_tokens.append(int(token)) + except ValueError: + pv_tokens.insert(0, token) + break + + if len(integer_tokens) < 4 or not selector: + return info + + # Required integer tokens. + info["depth"] = integer_tokens.pop(0) + info["score"] = Cp(integer_tokens.pop(0)) + info["time"] = float(integer_tokens.pop(0)) / 100 + info["nodes"] = int(integer_tokens.pop(0)) + + # Optional integer tokens. + if integer_tokens: + info["seldepth"] = integer_tokens.pop(0) + if integer_tokens: + info["nps"] = integer_tokens.pop(0) + + while len(integer_tokens) > 1: + # Reserved for future extensions. + integer_tokens.pop(0) + + if integer_tokens: + info["tbhits"] = integer_tokens.pop(0) + + # Principal variation. + if not (selector & INFO_PV): + return info + + info["pv"] = [] + board = root_board.copy(stack=False) + for token in pv_tokens: + if token.rstrip(".").isdigit(): + continue + + try: + info["pv"].append(board.push_xboard(token)) + except ValueError: + break + + return info + + class AnalysisResult: """ Handle to ongoing engine analysis. From 2dbb0c012cbfdb63dc34fad2b50545c68e7b18ef Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 16:49:14 +0100 Subject: [PATCH 0192/1451] handle xboard mate scores --- chess/engine.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index 14aba6c13..31a8ee686 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1762,10 +1762,18 @@ def _parse_xboard_post(line, root_board, selector=INFO_ALL): # Required integer tokens. info["depth"] = integer_tokens.pop(0) - info["score"] = Cp(integer_tokens.pop(0)) + cp = integer_tokens.pop(0) info["time"] = float(integer_tokens.pop(0)) / 100 info["nodes"] = int(integer_tokens.pop(0)) + # Score. + if cp <= -100000: + info["score"] = Mate.minus(abs(cp) - 100000) + elif cp >= 100000: + info["score"] = Mate.plus(cp - 100000) + else: + info["score"] = Cp(cp) + # Optional integer tokens. if integer_tokens: info["seldepth"] = integer_tokens.pop(0) From 7ad7bfa19ed34b457f988815b671e962dfb2ae22 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 17:07:02 +0100 Subject: [PATCH 0193/1451] update readme doctest --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 7a3ee6190..44d64a736 100644 --- a/README.rst +++ b/README.rst @@ -276,9 +276,9 @@ Features >>> engine = chess.engine.SimpleEngine.popen_uci("stockfish") >>> board = chess.Board("1k1r4/pp1b1R2/3q2pp/4p3/2B5/4Q3/PPP2B2/2K5 b - - 0 1") - >>> limit = chess.engine.Limit(movetime=2000) + >>> limit = chess.engine.Limit(time=2.0) >>> engine.play(board, limit) # doctest: +ELLIPSIS - + >>> engine.quit() From 285f20940174256b3cbce989d421984b00b889c5 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 17:12:22 +0100 Subject: [PATCH 0194/1451] convert time to seconds --- chess/engine.py | 7 ++++++- test.py | 18 ++++++++---------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 31a8ee686..31c9f033b 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1302,11 +1302,16 @@ def end_of_parameter(): board = root_board.copy(stack=False) elif current_parameter == "currline" and selector & INFO_CURRLINE: board = root_board.copy(stack=False) - elif current_parameter in ["depth", "seldepth", "time", "nodes", "multipv", "currmovenumber", "hashfull", "nps", "tbhits", "cpuload"]: + elif current_parameter in ["depth", "seldepth", "nodes", "multipv", "currmovenumber", "hashfull", "nps", "tbhits", "cpuload"]: try: info[current_parameter] = int(token) except ValueError: LOGGER.error("exception parsing %s from info: %r", current_parameter, arg) + elif current_parameter == "time": + try: + info[current_parameter] = int(token) / 1000.0 + except ValueError: + LOGGER.error("exception parsing %s from info: %r", current_parameter, arg) elif current_parameter == "pv" and pv is not None: try: pv.append(board.push_uci(token)) diff --git a/test.py b/test.py index d0b17307f..470d36667 100755 --- a/test.py +++ b/test.py @@ -3190,40 +3190,38 @@ def main(): loop.run_until_complete(main()) def test_uci_info(self): - from chess.engine import INFO_ALL - # Info: refutation. board = chess.Board("8/8/6k1/8/8/8/1K6/3B4 w - - 0 1") - info = chess.engine._parse_uci_info("refutation d1h5 g6h5", board, INFO_ALL) + info = chess.engine._parse_uci_info("refutation d1h5 g6h5", board) self.assertEqual(info["refutation"][chess.Move.from_uci("d1h5")], [chess.Move.from_uci("g6h5")]) - info = chess.engine._parse_uci_info("refutation d1h5", board, INFO_ALL) + info = chess.engine._parse_uci_info("refutation d1h5", board) self.assertEqual(info["refutation"][chess.Move.from_uci("d1h5")], []) # Info: string. - info = chess.engine._parse_uci_info("string goes to end no matter score cp 4 what", None, INFO_ALL) + info = chess.engine._parse_uci_info("string goes to end no matter score cp 4 what", None) self.assertEqual(info["string"], "goes to end no matter score cp 4 what") # Info: currline. - info = chess.engine._parse_uci_info("currline 0 e2e4 e7e5", chess.Board(), INFO_ALL) + info = chess.engine._parse_uci_info("currline 0 e2e4 e7e5", chess.Board()) self.assertEqual(info["currline"][0], [chess.Move.from_uci("e2e4"), chess.Move.from_uci("e7e5")]) # Info: ebf. - info = chess.engine._parse_uci_info("ebf 0.42", None, INFO_ALL) + info = chess.engine._parse_uci_info("ebf 0.42", None) self.assertEqual(info["ebf"], 0.42) # Info: depth, seldepth, score mate. - info = chess.engine._parse_uci_info("depth 7 seldepth 8 score mate 3", None, INFO_ALL) + info = chess.engine._parse_uci_info("depth 7 seldepth 8 score mate 3", None) self.assertEqual(info["depth"], 7) self.assertEqual(info["seldepth"], 8) self.assertEqual(info["score"], chess.engine.Mate.plus(3)) # Info: tbhits, cpuload, hashfull, time, nodes, nps. - info = chess.engine._parse_uci_info("tbhits 123 cpuload 456 hashfull 789 time 987 nodes 654 nps 321", None, INFO_ALL) + info = chess.engine._parse_uci_info("tbhits 123 cpuload 456 hashfull 789 time 987 nodes 654 nps 321", None) self.assertEqual(info["tbhits"], 123) self.assertEqual(info["cpuload"], 456) self.assertEqual(info["hashfull"], 789) - self.assertEqual(info["time"], 987) + self.assertEqual(info["time"], 0.987) self.assertEqual(info["nodes"], 654) self.assertEqual(info["nps"], 321) From 0d7a29fbb016bd17827ecfe3f41a4daf9b4ef29f Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 17:21:30 +0100 Subject: [PATCH 0195/1451] restore temporary xboard options after search --- chess/engine.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 31c9f033b..b24cae3f5 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1172,7 +1172,7 @@ def end(self, engine): for name, value in previous_config.items(): engine._setoption(name, value) for name, option in engine.options.items(): - if name not in ["UCI_AnalyseMode", "Ponder"] and name not in previous_config: + if name not in ["UCI_AnalyseMode", "Ponder"] and name not in previous_config and option.default is not None: engine._setoption(name, option.default) self.set_finished() @@ -1226,7 +1226,7 @@ def _bestmove(self, engine, arg): for name, value in previous_config.items(): engine._setoption(name, value) for name, option in engine.options.items(): - if name not in ["UCI_AnalyseMode", "Ponder", "MultiPV"] and name not in previous_config: + if name not in ["UCI_AnalyseMode", "Ponder", "MultiPV"] and name not in previous_config and option.default is not None: engine._setoption(name, option.default) self.analysis.set_finished() @@ -1632,15 +1632,15 @@ def line_received(self, engine, line): elif line == self.final_pong: if not self.result.done(): self.result.set_exception(EngineError("xboard engine answered final pong before sending move")) - self.set_finished() + self.end(engine) elif line == "offer draw": self.draw_offered = True elif line == "resign": self.result.set_result(EngineError("xboard engine resigned")) - self.set_finished() + self.end(engine) elif line.startswith("1-0") or line.startswith("0-1") or line.startswith("1/2-1/2"): self.result.set_result(PlayResult(None, None, self.info, self.draw_offered)) - self.set_finished() + self.end(engine) elif line.startswith("#") or line.startswith("Hint:"): pass elif len(line.split()) >= 4 and line.lstrip()[0].isdigit(): @@ -1662,7 +1662,7 @@ def _move(self, engine, arg): self.result.set_result(PlayResult(move, None, self.info, self.draw_offered)) if not ponder: - self.set_finished() + self.end(engine) def cancel(self, engine): if self.stopped: @@ -1679,6 +1679,15 @@ def cancel(self, engine): self.final_pong = "pong {}".format(n) engine._ping(n) + def end(self, engine): + engine._configure(previous_config) + for name, option in engine.options.items(): + if name not in previous_config and option.default is not None: + engine._configure({name: option.default}) + + self.set_finished() + + return (yield from self.communicate(Command)) @asyncio.coroutine From e0be0b45e66defffa1c76b48876d5c89bb8cfff9 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 17:48:55 +0100 Subject: [PATCH 0196/1451] documentation tweaks and updates --- chess/engine.py | 26 +++++++++++++++----------- docs/engine.rst | 31 +++++++++++++++++-------------- 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index b24cae3f5..8288b3c65 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -665,13 +665,14 @@ def play(self, board, limit, *, game=None, info=INFO_NONE, ponder=False, root_mo :param board: The position. The entire move stack will be sent to the engine. - :param limit: An instance of :class:`~chess.engine.Limit` that + :param limit: An instance of :class:`chess.engine.Limit` that determines when to stop thinking. :param game: Optional. An arbitrary object that identifies the game. - Will automatically clear hashtables if the object is not equal - to the previous game. + Will automatically inform the engine if the object is not equal + to the previous game (e.g. ``ucinewgame``, ``new``). :param info: Selects which additional information to retrieve from the - engine. ``INFO_NONE``, ``INFO_BASE``, ``INFO_SCORE``, ``INFO_PV``, + engine. ``INFO_NONE``, ``INFO_BASE`` (basic information that is + trivial to obtain), ``INFO_SCORE``, ``INFO_PV``, ``INFO_REFUTATION``, ``INFO_CURRLINE``, ``INFO_ALL`` or any bitwise combination. Some overhead is associated with parsing extra information. @@ -687,20 +688,22 @@ def play(self, board, limit, *, game=None, info=INFO_NONE, ponder=False, root_mo @asyncio.coroutine def analyse(self, board, limit, *, multipv=None, game=None, info=INFO_ALL, root_moves=None, options={}): """ - Analyses a position and returns an info dictionary. + Analyses a position and returns a dictionary of + `information <#chess.engine.PlayResult.info>`_. :param board: The position to analyse. The entire move stack will be sent to the engine. - :param limit: An instance of :class:`~chess.engine.Limit` that + :param limit: An instance of :class:`chess.engine.Limit` that determines when to stop the analysis. :param multipv: Optional. Analyse multiple root moves. Will return a list of at most *multipv* dictionaries rather than just a single info dictionary. :param game: Optional. An arbitrary object that identifies the game. - Will automatically clear hashtables if the object is not equal - to the previous game. + Will automatically inform the engine if the object is not equal + to the previous game (e.g. ``ucinewgame``, ``new``). :param info: Selects which information to retrieve from the - engine. ``INFO_NONE``, ``INFO_BASE``, ``INFO_SCORE``, ``INFO_PV``, + engine. ``INFO_NONE``, ``INFO_BASE`` (basic information that is + trivial to obtain), ``INFO_SCORE``, ``INFO_PV``, ``INFO_REFUTATION``, ``INFO_CURRLINE``, ``INFO_ALL`` or any bitwise combination. Some overhead is associated with parsing extra information. @@ -725,7 +728,7 @@ def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, :param board: The position to analyse. The entire move stack will be sent to the engine. - :param limit: Optional. An instance of :class:`~chess.engine.Limit` + :param limit: Optional. An instance of :class:`chess.engine.Limit` that determines when to stop the analysis. Analysis is infinite by default. :param multipv: Optional. Analyse multiple root moves. @@ -733,7 +736,8 @@ def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, Will automatically clear hashtables if the object is not equal to the previous game. :param info: Selects which information to retrieve from the - engine. ``INFO_NONE``, ``INFO_BASE``, ``INFO_SCORE``, ``INFO_PV``, + engine. ``INFO_NONE``, ``INFO_BASE`` (basic information that is + trivial to obtain), ``INFO_SCORE``, ``INFO_PV``, ``INFO_REFUTATION``, ``INFO_CURRLINE``, ``INFO_ALL`` or any bitwise combination. Some overhead is associated with parsing extra information. diff --git a/docs/engine.rst b/docs/engine.rst index 7a1d5cbf6..0e6006aab 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -1,4 +1,4 @@ -Engine communication [experimental] +Engine communication (experimental) =================================== UCI and XBoard are protocols for communicating with chess engines. This module @@ -6,15 +6,13 @@ implements an abstraction for playing moves and analysing positions with both kinds of engines. :warning: This is an experimental module that may change in semver incompatible - ways. Please weigh in on the design if the provided APIs do not cover - your use case. + ways. Please `weigh in `_ + on the design if the provided APIs do not cover your use case. The intention is to eventually replace ``chess.uci`` and ``chess.xboard``, but not before things have settled down and there has been a transition period. - The XBoard implementation is currently only a skeleton. - The preferred way to use the API is with an `asyncio `_ event loop. The examples also show a simple synchronous wrapper @@ -35,7 +33,7 @@ Example: Let Stockfish play against itself, 100 milliseconds per move. board = chess.Board() while not board.is_game_over(): - result = engine.play(board, chess.engine.Limit(movetime=100)) + result = engine.play(board, chess.engine.Limit(time=0.100)) board.push(result.move) engine.quit() @@ -51,7 +49,7 @@ Example: Let Stockfish play against itself, 100 milliseconds per move. board = chess.Board() while not board.is_game_over(): - result = await engine.play(board, chess.engine.Limit(movetime=100)) + result = await engine.play(board, chess.engine.Limit(time=0.100)) board.push(result.move) await engine.quit() @@ -115,11 +113,16 @@ Example: Let Stockfish play against itself, 100 milliseconds per move. .. py:attribute:: info - A dictionary of extra information sent by the engine. Known keys are - ``score``, ``depth``, ``seldepth``, ``time``, ``nodes``, ``pv``, - ``multipv``, ``currmove``, ``currmovenumber``, ``hashfull``, ``nps``, - ``tbhits``, ``cpuload``, ``refutation``, ``currline``, ``ebf`` and - ``string``. + A dictionary of extra information sent by the engine. Commonly used + keys are: ``score``, ``pv``, ``depth``, ``seldepth``, ``time`` + (in seconds), ``nodes``, ``nps``, ``tbhits``, ``multipv``. + + Others: ``currmove``, ``currmovenumber``, ``hashfull`` + ``cpuload``, ``refutation``, ``currline``, ``ebf`` and ``string``. + + .. py:attribute:: draw_offered + + Whether the engine offered a draw before moving. Analysing and evaluating a position ----------------------------------- @@ -134,7 +137,7 @@ Example: engine = chess.engine.SimpleEngine.popen_uci("stockfish") board = chess.Board() - info = engine.analyse(board, chess.engine.Limit(movetime=100)) + info = engine.analyse(board, chess.engine.Limit(time=0.100)) print("Score:", info["score"]) # Score: +20 @@ -155,7 +158,7 @@ Example: transport, engine = await chess.engine.popen_uci("stockfish") board = chess.Board() - info = await engine.analyse(board, chess.engine.Limit(movetime=100)) + info = await engine.analyse(board, chess.engine.Limit(time=0.100)) print(info["score"]) # Score: +20 From 2c1c02180f5583015305537d715211a0eeef1799 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 18:01:02 +0100 Subject: [PATCH 0197/1451] implement xboard analysis --- chess/engine.py | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index 8288b3c65..930602a24 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1707,17 +1707,54 @@ def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, class Command(BaseCommand): def start(self, engine): + self.stopped = False self.analysis = AnalysisResult(stop=lambda: self.cancel(engine)) + self.final_pong = None + + engine._new(board, game, options) if engine.game != game: engine._new() engine.game = game - engine.send_line("post" if info else "nopost") + engine.send_line("post") engine.send_line("analyze") self.result.set_result(self.analysis) + def line_received(self, engine, line): + if line.startswith("#"): + pass + elif len(line.split()) >= 4 and line.lstrip()[0].isdigit(): + self._post(engine, line) + elif line == self.final_pong: + self.end(engine) + else: + LOGGER.warning("%s: Unexpected engine output: %s", engine, line) + + def _post(self, engine, line): + post_info = _parse_xboard_post(line, engine.board, info | INFO_BASIC) + self.analysis.post(post_info) + + def end(self, engine): + engine._configure(previous_config) + for name, option in engine.options.items(): + if name not in previous_config and option.default is not None: + engine._configure({name: option.default}) + + self.set_finished() + + def cancel(self, engine): + if self.stopped: + return + self.stopped = True + + engine.send_line("exit") + + n = id(self) & 0xffff + self.final_pong = "pong {}".format(n) + engine._ping(n) + return (yield from self.communicate(Command)) def _configure(self, options): From b49598b6298fcfe7817aa8efb531903bc1dd27ee Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 18:10:56 +0100 Subject: [PATCH 0198/1451] complete xboard analysis --- chess/engine.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 930602a24..bebd13cf9 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1699,11 +1699,10 @@ def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, previous_config = self.config.copy() if multipv is not None: - raise NotImplementedError("TODO: xboard multipv not implemented yet") - if root_moves is not None: - raise NotImplementedError("TODO: xboard root_moves not implemented yet") + raise EngineError("xboard engine does not support multipv") - # TODO: Implement analysis + if limit is not None and (limit.white_clock is not None or limit.black_clock is not None): + raise EngineError("xboard analysis does not support clock limits") class Command(BaseCommand): def start(self, engine): @@ -1713,9 +1712,13 @@ def start(self, engine): engine._new(board, game, options) - if engine.game != game: - engine._new() - engine.game = game + if root_moves is not None: + if not engine.features.get("exclude", 0): + raise EngineError("xboard engine does not support root_moves (feature exclude=0)") + + engine.send_line("exclude all") + for move in root_moves: + engine.send_line("include {}".format(engine.board.xboard(move))) engine.send_line("post") engine.send_line("analyze") @@ -1736,6 +1739,14 @@ def _post(self, engine, line): post_info = _parse_xboard_post(line, engine.board, info | INFO_BASIC) self.analysis.post(post_info) + if limit is not None: + if limit.time is not None and post_info.get("time", 0) >= limit.time: + self.cancel(engine) + elif limit.nodes is not None and post_info.get("nodes", 0) >= limit.nodes: + self.cancel(engine) + elif limit.depth is not None and post_info.get("depth", 0) >= limit.depth: + self.cancel(engine) + def end(self, engine): engine._configure(previous_config) for name, option in engine.options.items(): From 50423d27e5122f78bd8f5afb178692c2bbfb7a33 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 18:15:09 +0100 Subject: [PATCH 0199/1451] support mate limit in xboard analysis --- chess/engine.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index bebd13cf9..5a793fdeb 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1746,6 +1746,8 @@ def _post(self, engine, line): self.cancel(engine) elif limit.depth is not None and post_info.get("depth", 0) >= limit.depth: self.cancel(engine) + elif limit.mate is not None and post_info.get("score", Cp(0)) >= Mate.plus(limit.mate): + self.cancel(engine) def end(self, engine): engine._configure(previous_config) From 3692bda2a6926987fd6445f9221b1044a4d8f339 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 18:19:07 +0100 Subject: [PATCH 0200/1451] tweak analysis game paramater docs --- chess/engine.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 5a793fdeb..b67a47ded 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -733,8 +733,8 @@ def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, by default. :param multipv: Optional. Analyse multiple root moves. :param game: Optional. An arbitrary object that identifies the game. - Will automatically clear hashtables if the object is not equal - to the previous game. + Will automatically inform the engine if the object is not equal + to the previous game (e.g. ``ucinewgame``, ``new``). :param info: Selects which information to retrieve from the engine. ``INFO_NONE``, ``INFO_BASE`` (basic information that is trivial to obtain), ``INFO_SCORE``, ``INFO_PV``, From 89cd382044edd263d7c3b0957a6db512209063e6 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 18:25:44 +0100 Subject: [PATCH 0201/1451] test chess.engine.run_in_background() --- test.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test.py b/test.py index 470d36667..49053393c 100755 --- a/test.py +++ b/test.py @@ -3225,6 +3225,27 @@ def test_uci_info(self): self.assertEqual(info["nodes"], 654) self.assertEqual(info["nps"], 321) + def test_run_in_background(self): + class ExpectedError(Exception): + pass + + @asyncio.coroutine + def raise_expected_error(future): + yield from asyncio.sleep(0.001) + raise ExpectedError + + with self.assertRaises(ExpectedError): + chess.engine.run_in_background(raise_expected_error) + + @asyncio.coroutine + def resolve(future): + yield from asyncio.sleep(0.001) + future.set_result("resolved") + yield from asyncio.sleep(0.001) + + result = chess.engine.run_in_background(resolve) + self.assertEqual(result, "resolved") + class SyzygyTestCase(unittest.TestCase): From 5f22135eab3eda34ebfb7cc66a1d381ca42792e2 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 18:27:46 +0100 Subject: [PATCH 0202/1451] fix problem found by flake8 --- chess/engine.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index b67a47ded..671a8834c 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1660,7 +1660,7 @@ def _move(self, engine, arg): if not self.result.cancelled(): try: move = engine.board.push_xboard(arg) - except ValueError: + except ValueError as err: self.result.set_exception(EngineError(err)) self.result.set_result(PlayResult(move, None, self.info, self.draw_offered)) @@ -1691,7 +1691,6 @@ def end(self, engine): self.set_finished() - return (yield from self.communicate(Command)) @asyncio.coroutine From 75b77c375e34f917c0f900feacee2ae8215d6138 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 18:29:54 +0100 Subject: [PATCH 0203/1451] remove chess.engine.Cp arithmetic --- chess/engine.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 671a8834c..b7bccc3c6 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -376,26 +376,6 @@ def __repr__(self): def __str__(self): return "+{}".format(self.cp) if self.cp > 0 else str(self.cp) - def __add__(self, other): - try: - return Cp(self.cp + other.cp) - except AttributeError: - return NotImplemented - - def __sub__(self, other): - try: - return Cp(self.cp - other.cp) - except AttributeError: - return NotImplemented - - def __mul__(self, scalar): - try: - return Cp(self.cp * scalar) - except TypeError: - return NotImplemented - - __rmul__ = __mul__ - def __neg__(self): return Cp(-self.cp) From 5fde369ca4b6a9d18fdb22503e6e48d7c1069590 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 18:36:01 +0100 Subject: [PATCH 0204/1451] test with stockfish 10 --- .travis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index a16002490..30b7ff4cc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,10 +18,10 @@ cache: - data/syzygy/suicide before_install: - # Stockfish - - wget https://stockfish.s3.amazonaws.com/stockfish-8-linux.zip - - unzip stockfish-8-linux.zip + - wget https://stockfish.s3.amazonaws.com/stockfish-10-linux.zip + - unzip stockfish-10-linux.zip - mkdir -p bin - - cp stockfish-8-linux/Linux/stockfish_8_x64 bin/stockfish + - cp stockfish-10-linux/Linux/stockfish_8_x64 bin/stockfish - export PATH="`pwd`/bin:${PATH}" - which stockfish || (echo $PATH && false) - # Crafty From 24badb1bba6c437bab89b201c7a694f0f5cd55e4 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 18:41:31 +0100 Subject: [PATCH 0205/1451] test crafty quit --- test.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test.py b/test.py index 49053393c..254b619cd 100755 --- a/test.py +++ b/test.py @@ -3121,6 +3121,11 @@ def test_sf_quit(self): with chess.engine.SimpleEngine.popen_uci("stockfish") as engine: engine.quit() + @catchAndSkip(FileNotFoundError, "need crafty") + def test_crafty_quit(self): + with chess.engine.SimpleEngine.popen_xboard("crafty") as engine: + engine.quit() + def test_uci_ping(self): @asyncio.coroutine def main(): From d9bd7609df915a3adbd59dd545d2cd3da18822ba Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 19:15:14 +0100 Subject: [PATCH 0206/1451] add test_crafty_analyse --- chess/engine.py | 13 ++++++++++++- test.py | 9 +++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index b7bccc3c6..a1be4d521 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1178,7 +1178,7 @@ def start(self, engine): engine._setoption("UCI_AnalyseMode", True) if "MultiPV" in engine.options or (multipv and multipv > 1): - engine._setoption("MultiPV", multipv) + engine._setoption("MultiPV", 1 if multipv is None else multipv) engine._configure(options) @@ -1704,6 +1704,11 @@ def start(self, engine): self.result.set_result(self.analysis) + if limit is not None and limit.time is not None: + self.time_limit_handle = engine.loop.call_later(limit.time, lambda: self.cancel(engine)) + else: + self.time_limit_handle = None + def line_received(self, engine, line): if line.startswith("#"): pass @@ -1729,6 +1734,11 @@ def _post(self, engine, line): self.cancel(engine) def end(self, engine): + if self.time_limit_handle: + self.time_limit_handle.cancel() + + self.analysis.set_finished() + engine._configure(previous_config) for name, option in engine.options.items(): if name not in previous_config and option.default is not None: @@ -1741,6 +1751,7 @@ def cancel(self, engine): return self.stopped = True + engine.send_line(".") engine.send_line("exit") n = id(self) & 0xffff diff --git a/test.py b/test.py index 254b619cd..8f9224c4d 100755 --- a/test.py +++ b/test.py @@ -3121,6 +3121,15 @@ def test_sf_quit(self): with chess.engine.SimpleEngine.popen_uci("stockfish") as engine: engine.quit() + @catchAndSkip(FileNotFoundError, "need crafty") + def test_crafty_analyse(self): + with chess.engine.SimpleEngine.popen_xboard("crafty") as engine: + board = chess.Board("2bqkbn1/2pppp2/np2N3/r3P1p1/p2N2B1/5Q2/PPPPKPP1/RNB2r2 w KQkq - 0 1") + limit = chess.engine.Limit(depth=7, time=2.0) + info = engine.analyse(board, limit) + self.assertTrue(info["score"] > chess.engine.Cp(1000)) + engine.quit() + @catchAndSkip(FileNotFoundError, "need crafty") def test_crafty_quit(self): with chess.engine.SimpleEngine.popen_xboard("crafty") as engine: From 006c1804b5dc310514d482c45bf726ab01e9138c Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 19:27:04 +0100 Subject: [PATCH 0207/1451] misc fixes after adding crafty play test case --- chess/engine.py | 18 ++++++++++-------- test.py | 31 +++++++++++++++++++++++++------ 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index a1be4d521..1bce005c6 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1599,7 +1599,7 @@ def start(self, engine): if limit.depth is not None: engine.send_line("sd {}".format(limit.depth)) if limit.time is not None: - engine.send_line("st {}".format(int(limit.movetime * 100))) + engine.send_line("st {}".format(int(limit.time * 100))) if limit.white_clock is not None: engine.send_line("{} {}".format("time" if board.turn else "otim", int(limit.white_clock * 100))) if limit.black_clock is not None: @@ -1620,10 +1620,11 @@ def line_received(self, engine, line): elif line == "offer draw": self.draw_offered = True elif line == "resign": - self.result.set_result(EngineError("xboard engine resigned")) + self.result.set_exception(EngineError("xboard engine resigned")) self.end(engine) elif line.startswith("1-0") or line.startswith("0-1") or line.startswith("1/2-1/2"): - self.result.set_result(PlayResult(None, None, self.info, self.draw_offered)) + if not self.result.done(): + self.result.set_result(PlayResult(None, None, self.info, self.draw_offered)) self.end(engine) elif line.startswith("#") or line.startswith("Hint:"): pass @@ -1664,12 +1665,13 @@ def cancel(self, engine): engine._ping(n) def end(self, engine): - engine._configure(previous_config) - for name, option in engine.options.items(): - if name not in previous_config and option.default is not None: - engine._configure({name: option.default}) + if not self.finished.done(): + engine._configure(previous_config) + for name, option in engine.options.items(): + if name not in previous_config and option.default is not None: + engine._configure({name: option.default}) - self.set_finished() + self.set_finished() return (yield from self.communicate(Command)) diff --git a/test.py b/test.py index 8f9224c4d..a5b02dfd3 100755 --- a/test.py +++ b/test.py @@ -3121,14 +3121,33 @@ def test_sf_quit(self): with chess.engine.SimpleEngine.popen_uci("stockfish") as engine: engine.quit() + @catchAndSkip(FileNotFoundError, "need crafty") + def test_crafty_play_to_mate(self): + logging.disable(logging.WARNING) + try: + with chess.engine.SimpleEngine.popen_xboard("crafty") as engine: + board = chess.Board("2bqkbn1/2pppp2/np2N3/r3P1p1/p2N2B1/5Q2/PPPPKPP1/RNB2r2 w KQkq - 0 1") + limit = chess.engine.Limit(depth=10) + while not board.is_game_over() and len(board.move_stack) < 5: + result = engine.play(board, limit) + board.push(result.move) + self.assertTrue(board.is_checkmate()) + engine.quit() + finally: + logging.disable(logging.NOTSET) + @catchAndSkip(FileNotFoundError, "need crafty") def test_crafty_analyse(self): - with chess.engine.SimpleEngine.popen_xboard("crafty") as engine: - board = chess.Board("2bqkbn1/2pppp2/np2N3/r3P1p1/p2N2B1/5Q2/PPPPKPP1/RNB2r2 w KQkq - 0 1") - limit = chess.engine.Limit(depth=7, time=2.0) - info = engine.analyse(board, limit) - self.assertTrue(info["score"] > chess.engine.Cp(1000)) - engine.quit() + logging.disable(logging.WARNING) + try: + with chess.engine.SimpleEngine.popen_xboard("crafty") as engine: + board = chess.Board("2bqkbn1/2pppp2/np2N3/r3P1p1/p2N2B1/5Q2/PPPPKPP1/RNB2r2 w KQkq - 0 1") + limit = chess.engine.Limit(depth=7, time=2.0) + info = engine.analyse(board, limit) + self.assertTrue(info["score"] > chess.engine.Cp(1000)) + engine.quit() + finally: + logging.disable(logging.NOTSET) @catchAndSkip(FileNotFoundError, "need crafty") def test_crafty_quit(self): From 6b569746cfafb1fb1820c48efa101d7c9cbb2e2d Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 19:33:24 +0100 Subject: [PATCH 0208/1451] show engine id in readme --- README.rst | 2 ++ chess/engine.py | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/README.rst b/README.rst index 44d64a736..00eea8165 100644 --- a/README.rst +++ b/README.rst @@ -274,6 +274,8 @@ Features >>> import chess.engine >>> engine = chess.engine.SimpleEngine.popen_uci("stockfish") + >>> engine.id.get("name") + 'Stockfish 10 64 POPCNT' >>> board = chess.Board("1k1r4/pp1b1R2/3q2pp/4p3/2B5/4Q3/PPP2B2/2K5 b - - 0 1") >>> limit = chess.engine.Limit(time=2.0) diff --git a/chess/engine.py b/chess/engine.py index 1bce005c6..870514ee1 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -2017,6 +2017,13 @@ def _get(): return self.protocol.id.copy() return asyncio.run_coroutine_threadsafe(_get(), self.protocol.loop).result() + @property + def config(self): + @asyncio.coroutine + def _get(): + return self.protocol.config.copy() + return asyncio.run_coroutine_threadsafe(_get(), self.protocol.loop).result() + def configure(self, options): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.configure(options), self.timeout), self.protocol.loop).result() From 3c3b63d8a94df72d32e12e743e757a7afb86e2c8 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 19:36:16 +0100 Subject: [PATCH 0209/1451] try to fix .travis.yml after sf 10 update --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 30b7ff4cc..5cd21dcb6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,7 +21,7 @@ before_install: - wget https://stockfish.s3.amazonaws.com/stockfish-10-linux.zip - unzip stockfish-10-linux.zip - mkdir -p bin - - cp stockfish-10-linux/Linux/stockfish_8_x64 bin/stockfish + - cp stockfish-10-linux/Linux/stockfish_10_modern bin/stockfish - export PATH="`pwd`/bin:${PATH}" - which stockfish || (echo $PATH && false) - # Crafty From 09fc4efa7b81e6076c688504de1b06255cb2b7e0 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 19:39:59 +0100 Subject: [PATCH 0210/1451] try to fix location of sf binary in .travis.yml --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 5cd21dcb6..c943b87fc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,7 +21,7 @@ before_install: - wget https://stockfish.s3.amazonaws.com/stockfish-10-linux.zip - unzip stockfish-10-linux.zip - mkdir -p bin - - cp stockfish-10-linux/Linux/stockfish_10_modern bin/stockfish + - cp stockfish-10-linux/Linux/stockfish_10_x64_modern bin/stockfish - export PATH="`pwd`/bin:${PATH}" - which stockfish || (echo $PATH && false) - # Crafty From 77a8dec2bdafb48f5ef8862dc91c9953b4bc0055 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 19:43:26 +0100 Subject: [PATCH 0211/1451] chmod +x stockfish in .travis.yml --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index c943b87fc..2de99db58 100644 --- a/.travis.yml +++ b/.travis.yml @@ -22,6 +22,7 @@ before_install: - unzip stockfish-10-linux.zip - mkdir -p bin - cp stockfish-10-linux/Linux/stockfish_10_x64_modern bin/stockfish + - chmod +x bin/stockfish - export PATH="`pwd`/bin:${PATH}" - which stockfish || (echo $PATH && false) - # Crafty From 242714a29474d247c989fd419a32e2cd455a8efb Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 19:44:42 +0100 Subject: [PATCH 0212/1451] mention uci/xboard in engine docs title --- docs/engine.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/engine.rst b/docs/engine.rst index 0e6006aab..23e35615e 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -1,5 +1,5 @@ -Engine communication (experimental) -=================================== +UCI/XBoard engine communication (experimental) +============================================== UCI and XBoard are protocols for communicating with chess engines. This module implements an abstraction for playing moves and analysing positions with From 106153cab5305e08bcbb1e81bf871a59f5cf206d Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 20:06:13 +0100 Subject: [PATCH 0213/1451] test sf analysis --- chess/engine.py | 2 ++ test.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index 870514ee1..e2e97b30a 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -16,6 +16,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +# TODO: Timeouts on synchronous wrapper + import abc import asyncio import collections diff --git a/test.py b/test.py index a5b02dfd3..ee9d8eee6 100755 --- a/test.py +++ b/test.py @@ -3116,6 +3116,18 @@ def test_sf_options(self): self.assertEqual(engine.options["uci_Chess960"].type, "check") self.assertEqual(engine.options["UCI_CHESS960"].default, False) + @catchAndSkip(FileNotFoundError, "need stockfish") + def test_sf_analysis(self): + with chess.engine.SimpleEngine.popen_uci("stockfish") as engine: + board = chess.Board("8/6K1/1p1B1RB1/8/2Q5/2n1kP1N/3b4/4n3 w - - 0 1") + limit = chess.engine.Limit(depth=20) + with engine.analysis(board, limit) as analysis: + for info in analysis: + if info.get("score", chess.engine.Cp(0)) >= chess.engine.Mate.plus(3): + break + self.assertEqual(analysis.multipv[0]["score"], chess.engine.Mate.plus(3)) + engine.quit() + @catchAndSkip(FileNotFoundError, "need stockfish") def test_sf_quit(self): with chess.engine.SimpleEngine.popen_uci("stockfish") as engine: From ebeeba36a6cd725c940523a16b6e17d827a8eab0 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 20:17:02 +0100 Subject: [PATCH 0214/1451] test xboard pondering --- test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test.py b/test.py index ee9d8eee6..7458e612a 100755 --- a/test.py +++ b/test.py @@ -3141,7 +3141,7 @@ def test_crafty_play_to_mate(self): board = chess.Board("2bqkbn1/2pppp2/np2N3/r3P1p1/p2N2B1/5Q2/PPPPKPP1/RNB2r2 w KQkq - 0 1") limit = chess.engine.Limit(depth=10) while not board.is_game_over() and len(board.move_stack) < 5: - result = engine.play(board, limit) + result = engine.play(board, limit, ponder=True) board.push(result.move) self.assertTrue(board.is_checkmate()) engine.quit() From ed9bcf4d992bb62fb7a21e822404856eee4dbbc9 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 20:25:11 +0100 Subject: [PATCH 0215/1451] add missing ponder on SimpleEngine --- chess/engine.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index e2e97b30a..444e31619 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -2032,8 +2032,8 @@ def configure(self, options): def ping(self): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.ping(), self.timeout), self.protocol.loop).result() - def play(self, board, limit, *, game=None, info=INFO_NONE, root_moves=None, options={}): - return asyncio.run_coroutine_threadsafe(self.protocol.play(board, limit, game=game, info=info, root_moves=root_moves, options=options), self.protocol.loop).result() + def play(self, board, limit, *, game=None, info=INFO_NONE, ponder=False, root_moves=None, options={}): + return asyncio.run_coroutine_threadsafe(self.protocol.play(board, limit, game=game, info=info, ponder=ponder, root_moves=root_moves, options=options), self.protocol.loop).result() def analyse(self, board, limit, *, multipv=None, game=None, info=INFO_ALL, root_moves=None, options={}): return asyncio.run_coroutine_threadsafe(self.protocol.analyse(board, limit, multipv=multipv, game=game, info=info, root_moves=root_moves, options=options), self.protocol.loop).result() From 5ce45f8a40da634ad26c3fe2cdce76a6aa5135e7 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 20:40:39 +0100 Subject: [PATCH 0216/1451] Better chess.svg test coverage --- test.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test.py b/test.py index 1af3be402..e2b27bdf2 100755 --- a/test.py +++ b/test.py @@ -3427,10 +3427,14 @@ def test_svg_arrows(self): self.assertIn(" Date: Wed, 9 Jan 2019 20:48:07 +0100 Subject: [PATCH 0217/1451] Move test_set_chess960_pos to BoardTestCase --- test.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/test.py b/test.py index e2b27bdf2..cfb327cc1 100755 --- a/test.py +++ b/test.py @@ -1400,6 +1400,25 @@ def test_mirror(self): mirrored = chess.Board("r1b1k2r/pp3pp1/2n5/1B1N2q1/3PpPPp/4n2K/PP2N3/R1BQ1R2 b kq g3 0 15") self.assertEqual(board.mirror(), mirrored) + def test_chess960_pos(self): + board = chess.Board() + + board.set_chess960_pos(0) + self.assertEqual(board.board_fen(), "bbqnnrkr/pppppppp/8/8/8/8/PPPPPPPP/BBQNNRKR") + self.assertEqual(board.chess960_pos(), 0) + + board.set_chess960_pos(631) + self.assertEqual(board.board_fen(), "rnbkqrnb/pppppppp/8/8/8/8/PPPPPPPP/RNBKQRNB") + self.assertEqual(board.chess960_pos(), 631) + + board.set_chess960_pos(518) + self.assertEqual(board.board_fen(), chess.STARTING_BOARD_FEN) + self.assertEqual(board.chess960_pos(), 518) + + board.set_chess960_pos(959) + self.assertEqual(board.board_fen(), "rkrnnqbb/pppppppp/8/8/8/8/PPPPPPPP/RKRNNQBB") + self.assertEqual(board.chess960_pos(), 959) + class LegalMoveGeneratorTestCase(unittest.TestCase): @@ -1441,25 +1460,6 @@ def generate_legal_moves(self): class BaseBoardTestCase(unittest.TestCase): - def test_set_chess960_pos(self): - board = chess.BaseBoard() - - board.set_chess960_pos(0) - self.assertEqual(board.board_fen(), "bbqnnrkr/pppppppp/8/8/8/8/PPPPPPPP/BBQNNRKR") - self.assertEqual(board.chess960_pos(), 0) - - board.set_chess960_pos(631) - self.assertEqual(board.board_fen(), "rnbkqrnb/pppppppp/8/8/8/8/PPPPPPPP/RNBKQRNB") - self.assertEqual(board.chess960_pos(), 631) - - board.set_chess960_pos(518) - self.assertEqual(board.board_fen(), chess.STARTING_BOARD_FEN) - self.assertEqual(board.chess960_pos(), 518) - - board.set_chess960_pos(959) - self.assertEqual(board.board_fen(), "rkrnnqbb/pppppppp/8/8/8/8/PPPPPPPP/RKRNNQBB") - self.assertEqual(board.chess960_pos(), 959) - def test_set_piece_map(self): a = chess.BaseBoard.empty() b = chess.BaseBoard() From ac105c152a8843b08d94ccedc409e4afa05a4af4 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 21:07:31 +0100 Subject: [PATCH 0218/1451] add timeout on SimpleEngine.analysis --- chess/engine.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 444e31619..7e78f8a45 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -16,8 +16,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -# TODO: Timeouts on synchronous wrapper - import abc import asyncio import collections @@ -2040,7 +2038,7 @@ def analyse(self, board, limit, *, multipv=None, game=None, info=INFO_ALL, root_ def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, root_moves=None, options={}): return SimpleAnalysisResult( - asyncio.run_coroutine_threadsafe(self.protocol.analysis(board, limit, multipv=multipv, game=game, info=info, root_moves=root_moves, options=options), self.protocol.loop).result(), + asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.analysis(board, limit, multipv=multipv, game=game, info=info, root_moves=root_moves, options=options), self.timeout), self.protocol.loop).result(), self.protocol.loop) def quit(self): From b34cf419ab84c74a387a317dc00d9b383deee876 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 21:40:23 +0100 Subject: [PATCH 0219/1451] test xboard options --- chess/engine.py | 41 +++++++++++++++++++++++++++++-- test.py | 65 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 7e78f8a45..7d42ebce8 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1435,8 +1435,9 @@ def _feature(self, engine, arg): for feature in shlex.split(arg): key, value = feature.split("=", 1) if key == "option": - # TODO: Implement xboard option parsing - pass + option = _parse_xboard_option(value) + if option.name not in ["random", "computer", "cores", "memory"]: + engine.options[option.name] = option else: try: engine.features[key] = int(value) @@ -1802,6 +1803,42 @@ def quit(self): yield from self.returncode +def _parse_xboard_option(feature): + params = feature.split() + + name = params[0] + type = params[1][1:] + default = None + min = None + max = None + var = None + + if type == "combo": + var = [] + choices = params[2:] + for choice in choices: + if choice == "///": + continue + elif choice[0] == "*": + default = choice[1:] + var.append(choice[1:]) + else: + var.append(choice) + elif type == "check": + default = int(params[2]) + elif type in ["string", "file", "path"]: + if len(params) > 2: + default = params[2] + else: + default = "" + elif type == "spin": + default = int(params[2]) + min = int(params[3]) + max = int(params[4]) + + return Option(name, type, default, min, max, var) + + def _parse_xboard_post(line, root_board, selector=INFO_ALL): # Format: depth score time nodes [seldepth [nps [tbhits]]] pv info = {} diff --git a/test.py b/test.py index 7458e612a..8c4678781 100755 --- a/test.py +++ b/test.py @@ -3270,6 +3270,71 @@ def test_uci_info(self): self.assertEqual(info["nodes"], 654) self.assertEqual(info["nps"], 321) + def test_xboard_options(self): + @asyncio.coroutine + def main(): + protocol = chess.engine.XBoardProtocol() + mock = chess.engine.MockTransport(protocol) + + mock.expect("xboard") + mock.expect("protover 2", [ + "feature egt=syzygy,gaviota", + "feature option=\"spinvar -spin 50 0 100\"", + "feature option=\"combovar -combo HI /// HELLO /// BYE\"", + "feature option=\"checkvar -check 0\"", + "feature option=\"stringvar -string \"\"\"", + "feature option=\"filevar -file \"\"\"", + "feature option=\"pathvar -path \"\"\"", + "feature option=\"buttonvar -button\"", + "feature option=\"resetvar -reset\"", + "feature option=\"savevar -save\"", + "feature ping=1 setboard=1 done=1", + ]) + mock.expect("accept egt") + yield from protocol._initialize() + mock.assert_done() + + self.assertEqual(protocol.options["egtpath syzygy"].type, "path") + self.assertEqual(protocol.options["egtpath gaviota"].name, "egtpath gaviota") + self.assertEqual(protocol.options["spinvar"].type, "spin") + self.assertEqual(protocol.options["spinvar"].default, 50) + self.assertEqual(protocol.options["spinvar"].min, 0) + self.assertEqual(protocol.options["spinvar"].max, 100) + self.assertEqual(protocol.options["combovar"].type, "combo") + self.assertEqual(protocol.options["combovar"].var, ["HI", "HELLO", "BYE"]) + self.assertEqual(protocol.options["checkvar"].type, "check") + self.assertEqual(protocol.options["checkvar"].default, False) + self.assertEqual(protocol.options["stringvar"].type, "string") + self.assertEqual(protocol.options["filevar"].type, "file") + self.assertEqual(protocol.options["pathvar"].type, "path") + self.assertEqual(protocol.options["buttonvar"].type, "button") + self.assertEqual(protocol.options["resetvar"].type, "reset") + self.assertEqual(protocol.options["savevar"].type, "save") + + mock.expect("option combovar=HI") + yield from protocol.configure({"combovar": "HI"}) + mock.assert_done() + + mock.expect("option spinvar=42") + yield from protocol.configure({"spinvar": 42}) + mock.assert_done() + + mock.expect("option checkvar=1") + yield from protocol.configure({"checkvar": True}) + mock.assert_done() + + mock.expect("option pathvar=.") + yield from protocol.configure({"pathvar": "."}) + mock.assert_done() + + mock.expect("option buttonvar") + yield from protocol.configure({"buttonvar": None}) + mock.assert_done() + + asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy()) + with contextlib.closing(asyncio.get_event_loop()) as loop: + loop.run_until_complete(main()) + def test_run_in_background(self): class ExpectedError(Exception): pass From d00de12f061c12fabf7958e6adaef04ad200dedf Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 21:53:10 +0100 Subject: [PATCH 0220/1451] also test SimpleAnalysis.info --- test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test.py b/test.py index 8c4678781..f2c543f24 100755 --- a/test.py +++ b/test.py @@ -3125,6 +3125,7 @@ def test_sf_analysis(self): for info in analysis: if info.get("score", chess.engine.Cp(0)) >= chess.engine.Mate.plus(3): break + self.assertEqual(analysis.info["score"], chess.engine.Mate.plus(3)) self.assertEqual(analysis.multipv[0]["score"], chess.engine.Mate.plus(3)) engine.quit() From cd5ee470af7b36cfd32c02afa085e780b522fac6 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 21:53:55 +0100 Subject: [PATCH 0221/1451] do not expose config on SimpleEngine --- chess/engine.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 7d42ebce8..8f711777e 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -2054,13 +2054,6 @@ def _get(): return self.protocol.id.copy() return asyncio.run_coroutine_threadsafe(_get(), self.protocol.loop).result() - @property - def config(self): - @asyncio.coroutine - def _get(): - return self.protocol.config.copy() - return asyncio.run_coroutine_threadsafe(_get(), self.protocol.loop).result() - def configure(self, options): return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.configure(options), self.timeout), self.protocol.loop).result() From 2aaa2e668f6be36591edd66109585e836047fca3 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 21:57:15 +0100 Subject: [PATCH 0222/1451] test crafty ping --- test.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test.py b/test.py index f2c543f24..6c151ce1b 100755 --- a/test.py +++ b/test.py @@ -3163,8 +3163,9 @@ def test_crafty_analyse(self): logging.disable(logging.NOTSET) @catchAndSkip(FileNotFoundError, "need crafty") - def test_crafty_quit(self): + def test_crafty_ping(self): with chess.engine.SimpleEngine.popen_xboard("crafty") as engine: + engine.ping() engine.quit() def test_uci_ping(self): From 36f9133ee0b673f125f0d9f38f681c9ce0fbd1c6 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 22:08:32 +0100 Subject: [PATCH 0223/1451] configure contempt in test_sf_forced_mates --- test.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test.py b/test.py index 6c151ce1b..c95089763 100755 --- a/test.py +++ b/test.py @@ -3097,6 +3097,8 @@ def test_score_ordering(self): @catchAndSkip(FileNotFoundError, "need stockfish") def test_sf_forced_mates(self): with chess.engine.SimpleEngine.popen_uci("stockfish") as engine: + engine.configure({"Contempt": 23}) + epds = [ "1k1r4/pp1b1R2/3q2pp/4p3/2B5/4Q3/PPP2B2/2K5 b - - bm Qd1+; id \"BK.01\";", "6k1/N1p3pp/2p5/3n1P2/4K3/1P5P/P1Pr1r2/R1R5 b - - bm Rf4+; id \"Clausthal 2014\";", From e88bdf1f71588c1978e344b91fce42903d491c21 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 9 Jan 2019 22:29:14 +0100 Subject: [PATCH 0224/1451] test and fix xboard replay --- chess/engine.py | 1 + test.py | 61 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index 8f711777e..674304298 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1504,6 +1504,7 @@ def _new(self, board, game, options): root = board.root() new_options = "random" in options or "computer" in options new_game = self.game != game or new_options or root != self.board.root() + self.game = game if new_game: self.board = root self.send_line("new") diff --git a/test.py b/test.py index c95089763..89ea0587c 100755 --- a/test.py +++ b/test.py @@ -3339,6 +3339,67 @@ def main(): with contextlib.closing(asyncio.get_event_loop()) as loop: loop.run_until_complete(main()) + def test_xboard_replay(self): + @asyncio.coroutine + def main(): + protocol = chess.engine.XBoardProtocol() + mock = chess.engine.MockTransport(protocol) + + mock.expect("xboard") + mock.expect("protover 2", ["feature ping=1 setboard=1 done=1"]) + yield from protocol._initialize() + mock.assert_done() + + limit = chess.engine.Limit(time=1.5, depth=17) + board = chess.Board() + board.push_san("d4") + board.push_san("Nf6") + board.push_san("c4") + + mock.expect("new") + mock.expect("force") + mock.expect("d2d4") + mock.expect("g8f6") + mock.expect("c2c4") + mock.expect("sd 17") + mock.expect("st 150") + mock.expect("nopost") + mock.expect("easy") + mock.expect("go", ["move e7e6"]) + result = yield from protocol.play(board, limit, game="game") + self.assertEqual(result.move, board.parse_san("e6")) + mock.assert_done() + + board.pop() + mock.expect("force") + mock.expect("remove") + mock.expect("sd 17") + mock.expect("st 150") + mock.expect("nopost") + mock.expect("easy") + mock.expect("go", ["move c2c4"]) + result = yield from protocol.play(board, limit, game="game") + self.assertEqual(result.move, board.parse_san("c4")) + mock.assert_done() + + board.pop() + board.pop() + mock.expect("force") + mock.expect("remove") + mock.expect("undo") + mock.expect("sd 17") + mock.expect("st 150") + mock.expect("nopost") + mock.expect("easy") + mock.expect("go", ["move d2d4"]) + result = yield from protocol.play(board, limit, game="game") + self.assertEqual(result.move, board.parse_san("d4")) + mock.assert_done() + + asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy()) + with contextlib.closing(asyncio.get_event_loop()) as loop: + loop.run_until_complete(main()) + def test_run_in_background(self): class ExpectedError(Exception): pass From ded9cef0a8d0d8fb91e26c1f77d2564fe0f5e109 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 11 Jan 2019 00:14:05 +0100 Subject: [PATCH 0225/1451] preserve hakkapeliitta double spaces test --- test.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test.py b/test.py index 89ea0587c..4b3483ad4 100755 --- a/test.py +++ b/test.py @@ -3274,6 +3274,16 @@ def test_uci_info(self): self.assertEqual(info["nodes"], 654) self.assertEqual(info["nps"], 321) + # Hakkapeliitta double spaces. + info = chess.engine._parse_uci_info("depth 10 seldepth 9 score cp 22 time 17 nodes 48299 nps 2683000 tbhits 0", None) + self.assertEqual(info["depth"], 10) + self.assertEqual(info["seldepth"], 9) + self.assertEqual(info["score"], chess.engine.Cp(22)) + self.assertEqual(info["time"], 0.017) + self.assertEqual(info["nodes"], 48299) + self.assertEqual(info["nps"], 2683000) + self.assertEqual(info["tbhits"], 0) + def test_xboard_options(self): @asyncio.coroutine def main(): From 1415335aab7a1d19a2338002dd6848b940dd5ff5 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 11 Jan 2019 00:42:03 +0100 Subject: [PATCH 0226/1451] test and fix xboard variant analyze --- chess/engine.py | 28 +++++++++++++++++++--------- test.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 674304298..0ca6f1226 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -480,12 +480,16 @@ class MockTransport: def __init__(self, protocol): self.protocol = protocol self.expectations = collections.deque() + self.expected_pings = 0 self.stdin_buffer = bytearray() self.protocol.connection_made(self) def expect(self, expectation, responses=[]): self.expectations.append((expectation, responses)) + def expect_ping(self): + self.expected_pings += 1 + def assert_done(self): assert not self.expectations, "pending expectations: {}".format(self.expectations) @@ -499,10 +503,14 @@ def write(self, data): line, self.stdin_buffer = self.stdin_buffer.split(b"\n", 1) line = line.decode("utf-8") - assert self.expectations, "unexpected: {}".format(line) - expectation, responses = self.expectations.popleft() - assert expectation == line, "expected {}, got: {}".format(expectation, line) - self.protocol.loop.call_soon(lambda: self.protocol.pipe_data_received(1, "\n".join(responses).encode("utf-8") + b"\n")) + if line.startswith("ping ") and self.expected_pings: + self.expected_pings -= 1 + self.protocol.loop.call_soon(lambda: self.protocol.pipe_data_received(1, line.replace("ping ", "pong ").encode("utf-8") + b"\n")) + else: + assert self.expectations, "unexpected: {}".format(line) + expectation, responses = self.expectations.popleft() + assert expectation == line, "expected {}, got: {}".format(expectation, line) + self.protocol.loop.call_soon(lambda: self.protocol.pipe_data_received(1, "\n".join(responses).encode("utf-8") + b"\n")) def get_pid(self): return id(self) @@ -1491,11 +1499,11 @@ def _ping(self, n): self.send_line("ping {}".format(n)) def _variant(self, variant): - supported_variants = self.features.get("variant", "").split(",") - if not variant or variant not in supported_variants: - raise EngineError("unsupported xboard variant: {} (available: {})".format(variant, ", ".join(supported_variants))) + variants = self.features.get("variants", "").split(",") + if not variant or variant not in variants: + raise EngineError("unsupported xboard variant: {} (available: {})".format(variant, ", ".join(variants))) - self.send_line("variant {}", variant) + self.send_line("variant {}".format(variant)) def _new(self, board, game, options): self._configure(options) @@ -1520,12 +1528,14 @@ def _new(self, board, game, options): if self.config.get("computer"): self.send_line("computer") + self.send_line("force") + + if new_game: fen = root.fen() if variant != "normal" or fen != chess.STARTING_FEN or board.chess960: self.send_line("setboard {}".format(root.shredder_fen() if board.chess960 else fen)) # Undo moves until common position. - self.send_line("force") common_stack_len = 0 if not new_game: for left, right in zip(self.board.move_stack, board.move_stack): diff --git a/test.py b/test.py index 4b3483ad4..5394543d9 100755 --- a/test.py +++ b/test.py @@ -3410,6 +3410,46 @@ def main(): with contextlib.closing(asyncio.get_event_loop()) as loop: loop.run_until_complete(main()) + def test_xboard_analyse(self): + @asyncio.coroutine + def main(): + protocol = chess.engine.XBoardProtocol() + mock = chess.engine.MockTransport(protocol) + + mock.expect("xboard") + mock.expect("protover 2", [ + "feature done=0 ping=1 setboard=1", + "feature exclude=1", + "feature variants=\"normal,atomic\" done=1", + ]) + yield from protocol._initialize() + mock.assert_done() + + board = chess.variant.AtomicBoard("rnbqkbnr/pppppppp/8/8/8/5N2/PPPPPPPP/RNBQKB1R b KQkq - 1 1") + limit = chess.engine.Limit(depth=1) + mock.expect("new") + mock.expect("variant atomic") + mock.expect("force") + mock.expect("setboard rnbqkbnr/pppppppp/8/8/8/5N2/PPPPPPPP/RNBQKB1R b KQkq - 1 1") + mock.expect("exclude all") + mock.expect("include f7f6") + mock.expect("post") + mock.expect("analyze", ["4 116 23 2252 1... f6 2. e4 e6"]) + mock.expect(".") + mock.expect("exit") + mock.expect_ping() + info = yield from protocol.analyse(board, limit, root_moves=[board.parse_san("f6")]) + self.assertEqual(info["depth"], 4) + self.assertEqual(info["score"], chess.engine.Cp(116)) + self.assertEqual(info["time"], 0.23) + self.assertEqual(info["nodes"], 2252) + self.assertEqual(info["pv"], [chess.Move.from_uci(move) for move in ["f7f6", "e2e4", "e7e6"]]) + mock.assert_done() + + asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy()) + with contextlib.closing(asyncio.get_event_loop()) as loop: + loop.run_until_complete(main()) + def test_run_in_background(self): class ExpectedError(Exception): pass From c08c98ff539dd373fcd23a8882ff0073e2935959 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 11 Jan 2019 02:03:23 +0100 Subject: [PATCH 0227/1451] Some more test coverage --- test.py | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/test.py b/test.py index 080582127..a2735b03c 100755 --- a/test.py +++ b/test.py @@ -66,7 +66,7 @@ def test_square(self): for square in chess.SQUARES: file_index = chess.square_file(square) rank_index = chess.square_rank(square) - self.assertEqual(chess.square(file_index, rank_index), square, chess.SQUARE_NAMES[square]) + self.assertEqual(chess.square(file_index, rank_index), square, chess.square_name(square)) def test_shifts(self): shifts = [ @@ -870,6 +870,18 @@ def test_status(self): board = chess.Board("bbqnnrkr/pppppppp/8/8/8/8/PPPPPPPP/BBQNNRKR w KQkq - 0 1", chess960=False) self.assertEqual(board.status(), chess.STATUS_BAD_CASTLING_RIGHTS) + # Opposite check. + board = chess.Board("4k3/8/8/8/8/8/4Q3/4K3 w - - 0 1") + self.assertEqual(board.status(), chess.STATUS_OPPOSITE_CHECK) + + # Empty board. + board = chess.Board(None) + self.assertEqual(board.status(), chess.STATUS_EMPTY | chess.STATUS_NO_WHITE_KING | chess.STATUS_NO_BLACK_KING) + + # Too many kings. + board = chess.Board("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBKKBNR w KQkq - 0 1") + self.assertEqual(board.status(), chess.STATUS_TOO_MANY_KINGS) + def test_one_king_movegen(self): board = chess.Board.empty() board.set_piece_at(chess.A1, chess.Piece(chess.KING, chess.WHITE)) @@ -1264,6 +1276,26 @@ def test_string_conversion(self): ♙ ♙ · · · · ♙ ♙ · · · · · · ♔ ·""")) + self.assertEqual(board.unicode(invert_color=True, borders=True), textwrap.dedent(u"""\ + ----------------- + 8 |·|·|·|·|·|·|·|♔| + ----------------- + 7 |·|♙|·|♕|♘|·|♗|·| + ----------------- + 6 |♙|♝|·|♙|·|♘|·|·| + ----------------- + 5 |·|·|·|♟|♙|·|·|·| + ----------------- + 4 |·|·|·|·|♟|♙|·|♙| + ----------------- + 3 |·|·|♛|♞|·|♝|·|·| + ----------------- + 2 |♟|♟|·|·|·|·|♟|♟| + ----------------- + 1 |·|·|·|·|·|·|♚|·| + ----------------- + a b c d e f g h""")) + def test_move_info(self): board = chess.Board("r1bqkb1r/p3np2/2n1p2p/1p4pP/2pP4/4PQ1N/1P2BPP1/RNB1K2R w KQkq g6 0 11") @@ -1442,9 +1474,11 @@ def test_list_conversion(self): def test_nonzero(self): self.assertTrue(chess.Board().legal_moves) + self.assertTrue(chess.Board().pseudo_legal_moves) caro_kann_mate = chess.Board("r1bqkb1r/pp1npppp/2pN1n2/8/3P4/8/PPP1QPPP/R1B1KBNR b KQkq - 4 6") self.assertFalse(caro_kann_mate.legal_moves) + self.assertTrue(chess.Board().pseudo_legal_moves) def test_string_conversion(self): board = chess.Board("r3k1nr/ppq1pp1p/2p3p1/8/1PPR4/2N5/P3QPPP/5RK1 b kq b3 0 16") @@ -1553,6 +1587,9 @@ def test_arithmetic(self): self.assertEqual(bb, chess.BB_C1) def test_immutable_set_operations(self): + self.assertTrue(chess.SquareSet(chess.BB_RANK_1).isdisjoint(chess.BB_RANK_2)) + self.assertFalse(chess.SquareSet(chess.BB_RANK_2).isdisjoint(chess.BB_FILE_E)) + self.assertFalse(chess.SquareSet(chess.BB_A1).issubset(chess.BB_RANK_1)) self.assertTrue(chess.SquareSet(chess.BB_RANK_1).issubset(chess.BB_A1)) @@ -1638,6 +1675,14 @@ def test_len_of_complenent(self): squares = ~chess.SquareSet(chess.BB_BACKRANKS) self.assertEqual(len(squares), 48) + def test_int_conversion(self): + self.assertEqual(int(chess.SquareSet(chess.BB_CENTER)), 0x1818000000) + self.assertEqual(hex(chess.SquareSet(chess.BB_CENTER)), "0x1818000000") + self.assertEqual(bin(chess.SquareSet(chess.BB_CENTER)), "0b1100000011000000000000000000000000000") + + def test_tolist(self): + self.assertEqual(chess.SquareSet(chess.BB_LIGHT_SQUARES).tolist().count(True), 32) + class PolyglotTestCase(unittest.TestCase): From 08b8a5a399c898c0f59d0faac465f7484d99986a Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 11 Jan 2019 13:11:09 +0100 Subject: [PATCH 0228/1451] Improve engine docs --- chess/engine.py | 14 ++++++++++---- docs/engine.rst | 2 +- test.py | 14 ++++++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 0ca6f1226..bc892dad4 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -319,9 +319,9 @@ class Score(abc.ABC): """ @abc.abstractmethod - def score(self, mate_score=None): + def score(self, *, mate_score=None): """ - Returns a centi-pawn score or ``None``. + Returns the centi-pawn score as an integer or ``None``. You can optionally pass a large value to convert mate scores to centi-pawn scores. @@ -335,14 +335,15 @@ def score(self, mate_score=None): >>> mate = Mate.from_moves(5) >>> mate.score() is None True - >>> mate.score(100000) + >>> mate.score(mate_score=100000) 99995 """ @abc.abstractmethod def mate(self): """ - Returns a mate score or ``None``. + Returns the number of plies to mate, negative if we are getting mated, + or ``None``. :warning: This conflates ``Mate.minus(0)`` (we are mated) and ``Mate.plus(0)`` (we have given mate) to ``0``. @@ -2044,6 +2045,11 @@ class SimpleEngine: *timeout* seconds longer than expected (unless *timeout* is ``None``). Automatically closes the transport when used as a context manager. + + You may not concurrently modify objects passed to any of the methods. Other + than that :class:`~chess.engine.SimpleEngine` is thread-safe. When sending + a new command to the engine, any previous running command will be cancelled + as soon as possible. """ def __init__(self, transport, protocol, *, timeout=10.0): diff --git a/docs/engine.rst b/docs/engine.rst index 23e35615e..32739f88c 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -15,7 +15,7 @@ both kinds of engines. The preferred way to use the API is with an `asyncio `_ event loop. -The examples also show a simple synchronous wrapper +The examples also show a synchronous wrapper :class:`~chess.engine.SimpleEngine` that automatically spawns an event loop in the background. diff --git a/test.py b/test.py index a2735b03c..52c383037 100755 --- a/test.py +++ b/test.py @@ -3183,6 +3183,20 @@ def test_score_ordering(self): self.assertEqual(i > j, a > b) self.assertEqual(i >= j, a >= b) + def test_score(self): + # Negation. + self.assertEqual(-chess.engine.Cp(20), chess.engine.Cp(-20)) + self.assertEqual(-chess.engine.Mate.from_moves(4), chess.engine.Mate.minus(4)) + + # Score. + self.assertEqual(chess.engine.Cp(-300).score(), -300) + self.assertEqual(chess.engine.Mate.from_moves(5).score(), None) + self.assertEqual(chess.engine.Mate.from_moves(5).score(100000), 99995) + + # Mate. + self.assertEqual(chess.engine.Cp(-300).mate(), None) + self.assertEqual(chess.engine.Mate.from_moves(5).mate(), 5) + @catchAndSkip(FileNotFoundError, "need stockfish") def test_sf_forced_mates(self): with chess.engine.SimpleEngine.popen_uci("stockfish") as engine: From 2e82ea4c7a184134aef1c111723107c736ecf2e9 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 11 Jan 2019 13:23:11 +0100 Subject: [PATCH 0229/1451] Add timeouts for SimpleEngine.{play,analyse} --- chess/engine.py | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index bc892dad4..76ceffa3c 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -2071,25 +2071,34 @@ def _get(): return self.protocol.id.copy() return asyncio.run_coroutine_threadsafe(_get(), self.protocol.loop).result() + def _timeout_for(self, limit): + if self.timeout is None or limit is None or limit.time is None: + return None + return self.timeout + limit.time + def configure(self, options): - return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.configure(options), self.timeout), self.protocol.loop).result() + coro = asyncio.wait_for(self.protocol.configure(options), self.timeout) + return asyncio.run_coroutine_threadsafe(coro, self.protocol.loop).result() def ping(self): - return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.ping(), self.timeout), self.protocol.loop).result() + coro = asyncio.wait_for(self.protocol.ping(), self.timeout) + return asyncio.run_coroutine_threadsafe(coro, self.protocol.loop).result() def play(self, board, limit, *, game=None, info=INFO_NONE, ponder=False, root_moves=None, options={}): - return asyncio.run_coroutine_threadsafe(self.protocol.play(board, limit, game=game, info=info, ponder=ponder, root_moves=root_moves, options=options), self.protocol.loop).result() + coro = asyncio.wait_for(self.protocol.play(board, limit, game=game, info=info, ponder=ponder, root_moves=root_moves, options=options), self._timeout_for(limit)) + return asyncio.run_coroutine_threadsafe(coro, self.protocol.loop).result() def analyse(self, board, limit, *, multipv=None, game=None, info=INFO_ALL, root_moves=None, options={}): - return asyncio.run_coroutine_threadsafe(self.protocol.analyse(board, limit, multipv=multipv, game=game, info=info, root_moves=root_moves, options=options), self.protocol.loop).result() + coro = asyncio.wait_for(self.protocol.analyse(board, limit, multipv=multipv, game=game, info=info, root_moves=root_moves, options=options), self._timeout_for(limit)) + return asyncio.run_coroutine_threadsafe(coro, self.protocol.loop).result() def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, root_moves=None, options={}): - return SimpleAnalysisResult( - asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.analysis(board, limit, multipv=multipv, game=game, info=info, root_moves=root_moves, options=options), self.timeout), self.protocol.loop).result(), - self.protocol.loop) + coro = asyncio.wait_for(self.protocol.analysis(board, limit, multipv=multipv, game=game, info=info, root_moves=root_moves, options=options), self.timeout) + return SimpleAnalysisResult(asyncio.run_coroutine_threadsafe(coro, self.protocol.loop).result(), self.protocol.loop) def quit(self): - return asyncio.run_coroutine_threadsafe(asyncio.wait_for(self.protocol.quit(), self.timeout), self.protocol.loop).result() + coro = asyncio.wait_for(self.protocol.quit(), self.timeout) + return asyncio.run_coroutine_threadsafe(coro, self.protocol.loop).result() def close(self): """Closes the transport.""" From cd3c2fd0b8414c029e48b4bd3cc86bd12797096c Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 11 Jan 2019 13:31:55 +0100 Subject: [PATCH 0230/1451] Run crafty tests in a temporary directory --- test.py | 44 ++++++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/test.py b/test.py index 52c383037..b3c043cd2 100755 --- a/test.py +++ b/test.py @@ -26,6 +26,7 @@ import os.path import platform import sys +import tempfile import textwrap import threading import unittest @@ -2447,9 +2448,11 @@ def test_is_wild(self): class CraftyTestCase(unittest.TestCase): def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory(prefix="crafty") try: - self.engine = chess.xboard.popen_engine("crafty") + self.engine = chess.xboard.popen_engine("crafty", cwd=self.tmpdir.name) except OSError: + self.tmpdir.cleanup() self.skipTest("need crafty") self.engine.xboard() @@ -2459,6 +2462,8 @@ def tearDown(self): if self.engine.is_alive(): self.engine.quit() + self.tmpdir.cleanup() + def test_undo(self): self.engine.new() board = chess.Board() @@ -3243,14 +3248,15 @@ def test_sf_quit(self): def test_crafty_play_to_mate(self): logging.disable(logging.WARNING) try: - with chess.engine.SimpleEngine.popen_xboard("crafty") as engine: - board = chess.Board("2bqkbn1/2pppp2/np2N3/r3P1p1/p2N2B1/5Q2/PPPPKPP1/RNB2r2 w KQkq - 0 1") - limit = chess.engine.Limit(depth=10) - while not board.is_game_over() and len(board.move_stack) < 5: - result = engine.play(board, limit, ponder=True) - board.push(result.move) - self.assertTrue(board.is_checkmate()) - engine.quit() + with tempfile.TemporaryDirectory(prefix="crafty") as tmpdir: + with chess.engine.SimpleEngine.popen_xboard("crafty", cwd=tmpdir) as engine: + board = chess.Board("2bqkbn1/2pppp2/np2N3/r3P1p1/p2N2B1/5Q2/PPPPKPP1/RNB2r2 w KQkq - 0 1") + limit = chess.engine.Limit(depth=10) + while not board.is_game_over() and len(board.move_stack) < 5: + result = engine.play(board, limit, ponder=True) + board.push(result.move) + self.assertTrue(board.is_checkmate()) + engine.quit() finally: logging.disable(logging.NOTSET) @@ -3258,20 +3264,22 @@ def test_crafty_play_to_mate(self): def test_crafty_analyse(self): logging.disable(logging.WARNING) try: - with chess.engine.SimpleEngine.popen_xboard("crafty") as engine: - board = chess.Board("2bqkbn1/2pppp2/np2N3/r3P1p1/p2N2B1/5Q2/PPPPKPP1/RNB2r2 w KQkq - 0 1") - limit = chess.engine.Limit(depth=7, time=2.0) - info = engine.analyse(board, limit) - self.assertTrue(info["score"] > chess.engine.Cp(1000)) - engine.quit() + with tempfile.TemporaryDirectory(prefix="crafty") as tmpdir: + with chess.engine.SimpleEngine.popen_xboard("crafty", cwd=tmpdir) as engine: + board = chess.Board("2bqkbn1/2pppp2/np2N3/r3P1p1/p2N2B1/5Q2/PPPPKPP1/RNB2r2 w KQkq - 0 1") + limit = chess.engine.Limit(depth=7, time=2.0) + info = engine.analyse(board, limit) + self.assertTrue(info["score"] > chess.engine.Cp(1000)) + engine.quit() finally: logging.disable(logging.NOTSET) @catchAndSkip(FileNotFoundError, "need crafty") def test_crafty_ping(self): - with chess.engine.SimpleEngine.popen_xboard("crafty") as engine: - engine.ping() - engine.quit() + with tempfile.TemporaryDirectory(prefix="crafty") as tmpdir: + with chess.engine.SimpleEngine.popen_xboard("crafty", cwd=tmpdir) as engine: + engine.ping() + engine.quit() def test_uci_ping(self): @asyncio.coroutine From d9d6af3ca088c7b505947d575cb7a28c74c00ab2 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 11 Jan 2019 14:13:35 +0100 Subject: [PATCH 0231/1451] Call exception handler if lingering command fails --- chess/engine.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 76ceffa3c..bf68a3db5 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -784,23 +784,25 @@ def __init__(self, loop): self.finished = asyncio.Future(loop=loop) def _engine_terminated(self, engine, code): - exc = EngineTerminatedError("engine process died unexpectedly (exit code: {})".format(type(self).__name__, code)) - self._handle_exception(exc) + exc = EngineTerminatedError("engine process died unexpectedly (exit code: {})".format(code)) + self._handle_exception(engine, exc) if self.state in [CommandState.Active, CommandState.Cancelling]: self.engine_terminated(engine, exc) - def _handle_exception(self, exc): + def _handle_exception(self, engine, exc): if not self.result.done(): self.result.set_exception(exc) - if not self.finished.done(): - self.finished.set_exception(exc) + else: + self.loop.call_exception_handler({ + "message": "engine command failed after returning preliminary result", + "exception": exc, + "protocol": engine, + "transport": engine.transport, + }) - # Prevent warning when the exception is not retrieved. - try: - self.finished.result() - except Exception: - pass + if not self.finished.done(): + self.finished.set_result(None) def set_finished(self): assert self.state in [CommandState.Active, CommandState.Cancelling] @@ -819,7 +821,7 @@ def _start(self, engine): try: self.start(engine) except EngineError as err: - self._handle_exception(err) + self._handle_exception(engine, err) def _done(self): assert self.state != CommandState.Done From db517b2eb4decce33dab43b1bda93250c0ea93fe Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 11 Jan 2019 16:48:47 +0100 Subject: [PATCH 0232/1451] Fix SimpleEngine.close() data race --- chess/engine.py | 128 ++++++++++++++++++++++++++++++++++-------------- test.py | 19 ++++--- 2 files changed, 103 insertions(+), 44 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index bf68a3db5..e2b79625a 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -20,6 +20,7 @@ import asyncio import collections import concurrent.futures +import contextlib import enum import functools import logging @@ -145,7 +146,7 @@ def set_event_loop(self, loop): self.get_child_watcher().attach_loop(loop) -def run_in_background(coroutine, _policy_lock=threading.Lock()): +def run_in_background(coroutine, *, debug=False, _policy_lock=threading.Lock()): """ Runs ``coroutine(future)`` in a new event loop on a background thread. @@ -167,6 +168,7 @@ def run_in_background(coroutine, _policy_lock=threading.Lock()): def background(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) + loop.set_debug(debug) try: loop.run_until_complete(coroutine(future)) @@ -2059,79 +2061,118 @@ def __init__(self, transport, protocol, *, timeout=10.0): self.protocol = protocol self.timeout = timeout + self._shutdown_lock = threading.Lock() + self._shutdown = False + self._shutdown_event = asyncio.Event() + + def _timeout_for(self, limit): + if self.timeout is None or limit is None or limit.time is None: + return None + return self.timeout + limit.time + + @contextlib.contextmanager + def _not_shut_down(self): + with self._shutdown_lock: + if self._shutdown: + raise EngineTerminatedError("engine event loop dead") + yield + + def _await(self, future): + if isinstance(future.exception(), EngineTerminatedError): + self.close() + return future.result() + @property def options(self): @asyncio.coroutine def _get(): return self.protocol.options.copy() - return asyncio.run_coroutine_threadsafe(_get(), self.protocol.loop).result() + + with self._not_shut_down(): + future = asyncio.run_coroutine_threadsafe(_get(), self.protocol.loop) + return self._await(future) @property def id(self): @asyncio.coroutine def _get(): return self.protocol.id.copy() - return asyncio.run_coroutine_threadsafe(_get(), self.protocol.loop).result() - def _timeout_for(self, limit): - if self.timeout is None or limit is None or limit.time is None: - return None - return self.timeout + limit.time + with self._not_shut_down(): + future = asyncio.run_coroutine_threadsafe(_get(), self.protocol.loop) + return self._await(future) + def configure(self, options): - coro = asyncio.wait_for(self.protocol.configure(options), self.timeout) - return asyncio.run_coroutine_threadsafe(coro, self.protocol.loop).result() + with self._not_shut_down(): + coro = asyncio.wait_for(self.protocol.configure(options), self.timeout) + future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop) + return self._await(future) def ping(self): - coro = asyncio.wait_for(self.protocol.ping(), self.timeout) - return asyncio.run_coroutine_threadsafe(coro, self.protocol.loop).result() + with self._not_shut_down(): + coro = asyncio.wait_for(self.protocol.ping(), self.timeout) + future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop) + return self._await(future) def play(self, board, limit, *, game=None, info=INFO_NONE, ponder=False, root_moves=None, options={}): - coro = asyncio.wait_for(self.protocol.play(board, limit, game=game, info=info, ponder=ponder, root_moves=root_moves, options=options), self._timeout_for(limit)) - return asyncio.run_coroutine_threadsafe(coro, self.protocol.loop).result() + with self._not_shut_down(): + coro = asyncio.wait_for(self.protocol.play(board, limit, game=game, info=info, ponder=ponder, root_moves=root_moves, options=options), self._timeout_for(limit)) + future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop) + return self._await(future) def analyse(self, board, limit, *, multipv=None, game=None, info=INFO_ALL, root_moves=None, options={}): - coro = asyncio.wait_for(self.protocol.analyse(board, limit, multipv=multipv, game=game, info=info, root_moves=root_moves, options=options), self._timeout_for(limit)) - return asyncio.run_coroutine_threadsafe(coro, self.protocol.loop).result() + with self._not_shut_down(): + coro = asyncio.wait_for(self.protocol.analyse(board, limit, multipv=multipv, game=game, info=info, root_moves=root_moves, options=options), self._timeout_for(limit)) + future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop) + return self._await(future) def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, root_moves=None, options={}): - coro = asyncio.wait_for(self.protocol.analysis(board, limit, multipv=multipv, game=game, info=info, root_moves=root_moves, options=options), self.timeout) - return SimpleAnalysisResult(asyncio.run_coroutine_threadsafe(coro, self.protocol.loop).result(), self.protocol.loop) + with self._not_shut_down(): + coro = asyncio.wait_for(self.protocol.analysis(board, limit, multipv=multipv, game=game, info=info, root_moves=root_moves, options=options), self.timeout) + future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop) + return SimpleAnalysisResult(self, self._await(future)) def quit(self): - coro = asyncio.wait_for(self.protocol.quit(), self.timeout) - return asyncio.run_coroutine_threadsafe(coro, self.protocol.loop).result() + with self._not_shut_down(): + coro = asyncio.wait_for(self.protocol.quit(), self.timeout) + future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop) + return self._await(future) def close(self): - """Closes the transport.""" - # This happens to be threadsafe. - self.transport.close() + """Closes the transport and the background event loop.""" + with self._shutdown_lock: + if not self._shutdown: + self._shutdown = True + self.protocol.loop.call_soon_threadsafe(lambda: (self.transport.close(), self._shutdown_event.set())) @classmethod - def popen(cls, Protocol, command, *, timeout=10.0, setpgrp=False, **popen_args): + def popen(cls, Protocol, command, *, timeout=10.0, debug=False, setpgrp=False, **popen_args): @asyncio.coroutine def background(future): transport, protocol = yield from asyncio.wait_for(Protocol.popen(command, setpgrp=setpgrp, **popen_args), timeout) - future.set_result(cls(transport, protocol, timeout=timeout)) + simple_engine = cls(transport, protocol, timeout=timeout) + future.set_result(simple_engine) + yield from simple_engine._shutdown_event.wait() yield from protocol.returncode - return run_in_background(background) + return run_in_background(background, debug=debug) @classmethod - def popen_uci(cls, command, *, timeout=10.0, setpgrp=False, **popen_args): + def popen_uci(cls, command, *, timeout=10.0, debug=False, setpgrp=False, **popen_args): """ Spawns and initializes an UCI engine. Returns a :class:`~chess.engine.SimpleEngine` instance. """ - return cls.popen(UciProtocol, command, timeout=timeout, setpgrp=setpgrp, **popen_args) + return cls.popen(UciProtocol, command, timeout=timeout, debug=debug, setpgrp=setpgrp, **popen_args) @classmethod - def popen_xboard(cls, command, *, timeout=10.0, setpgrp=False, **popen_args): + def popen_xboard(cls, command, *, timeout=10.0, debug=False, setpgrp=False, **popen_args): """ Spawns and initializes an XBoard engine. Returns a :class:`~chess.engine.SimpleEngine` instance. """ - return cls.popen(XBoardProtocol, command, timeout=timeout, setpgrp=setpgrp, **popen_args) + return cls.popen(XBoardProtocol, command, timeout=timeout, debug=debug, setpgrp=setpgrp, **popen_args) def __enter__(self): return self @@ -2151,39 +2192,52 @@ class SimpleAnalysisResult: by :func:`chess.engine.SimpleEngine.analysis()`. """ - def __init__(self, inner, loop): + def __init__(self, simple_engine, inner): + self.simple_engine = simple_engine self.inner = inner - self.loop = loop @property def info(self): @asyncio.coroutine def _get(): return self.inner.info.copy() - return asyncio.run_coroutine_threadsafe(_get(), self.loop).result() + + with self.simple_engine._not_shut_down(): + future = asyncio.run_coroutine_threadsafe(_get(), self.simple_engine.protocol.loop) + return self.simple_engine._await(future) @property def multipv(self): @asyncio.coroutine def _get(): return [info.copy() for info in self.inner.multipv] - return asyncio.run_coroutine_threadsafe(_get(), self.loop).result() + + with self.simple_engine._not_shut_down(): + future = asyncio.run_coroutine_threadsafe(_get(), self.simple_engine.protocol.loop) + return self.simple_engine._await(future) def stop(self): - self.loop.call_soon_threadsafe(self.inner.stop) + with self.simple_engine._not_shut_down(): + self.simple_engine.protocol.loop.call_soon_threadsafe(self.inner.stop) def wait(self): - return asyncio.run_coroutine_threadsafe(self.inner.wait(), self.loop).result() + with self.simple_engine._not_shut_down(): + future = asyncio.run_coroutine_threadsafe(self.inner.wait(), self.simple_engine.protocol.loop) + return self.simple_engine._await(future) def next(self): - return asyncio.run_coroutine_threadsafe(self.inner.next(), self.loop).result() + with self.simple_engine._not_shut_down(): + future = asyncio.run_coroutine_threadsafe(self.inner.next(), self.simple_engine.protocol.loop) + return self.simple_engine._await(future) def __iter__(self): return self def __next__(self): try: - return asyncio.run_coroutine_threadsafe(self.inner.__anext__(), self.loop).result() + with self.simple_engine._not_shut_down(): + future = asyncio.run_coroutine_threadsafe(self.inner.__anext__(), self.simple_engine.protocol.loop) + return self.simple_engine._await(future) except StopAsyncIteration: raise StopIteration diff --git a/test.py b/test.py index b3c043cd2..5d8daad25 100755 --- a/test.py +++ b/test.py @@ -3204,7 +3204,7 @@ def test_score(self): @catchAndSkip(FileNotFoundError, "need stockfish") def test_sf_forced_mates(self): - with chess.engine.SimpleEngine.popen_uci("stockfish") as engine: + with chess.engine.SimpleEngine.popen_uci("stockfish", debug=True) as engine: engine.configure({"Contempt": 23}) epds = [ @@ -3221,14 +3221,14 @@ def test_sf_forced_mates(self): @catchAndSkip(FileNotFoundError, "need stockfish") def test_sf_options(self): - with chess.engine.SimpleEngine.popen_uci("stockfish") as engine: + with chess.engine.SimpleEngine.popen_uci("stockfish", debug=True) as engine: self.assertEqual(engine.options["UCI_Chess960"].name, "UCI_Chess960") self.assertEqual(engine.options["uci_Chess960"].type, "check") self.assertEqual(engine.options["UCI_CHESS960"].default, False) @catchAndSkip(FileNotFoundError, "need stockfish") def test_sf_analysis(self): - with chess.engine.SimpleEngine.popen_uci("stockfish") as engine: + with chess.engine.SimpleEngine.popen_uci("stockfish", debug=True) as engine: board = chess.Board("8/6K1/1p1B1RB1/8/2Q5/2n1kP1N/3b4/4n3 w - - 0 1") limit = chess.engine.Limit(depth=20) with engine.analysis(board, limit) as analysis: @@ -3241,7 +3241,7 @@ def test_sf_analysis(self): @catchAndSkip(FileNotFoundError, "need stockfish") def test_sf_quit(self): - with chess.engine.SimpleEngine.popen_uci("stockfish") as engine: + with chess.engine.SimpleEngine.popen_uci("stockfish", debug=True) as engine: engine.quit() @catchAndSkip(FileNotFoundError, "need crafty") @@ -3249,7 +3249,7 @@ def test_crafty_play_to_mate(self): logging.disable(logging.WARNING) try: with tempfile.TemporaryDirectory(prefix="crafty") as tmpdir: - with chess.engine.SimpleEngine.popen_xboard("crafty", cwd=tmpdir) as engine: + with chess.engine.SimpleEngine.popen_xboard("crafty", debug=True, cwd=tmpdir) as engine: board = chess.Board("2bqkbn1/2pppp2/np2N3/r3P1p1/p2N2B1/5Q2/PPPPKPP1/RNB2r2 w KQkq - 0 1") limit = chess.engine.Limit(depth=10) while not board.is_game_over() and len(board.move_stack) < 5: @@ -3265,7 +3265,7 @@ def test_crafty_analyse(self): logging.disable(logging.WARNING) try: with tempfile.TemporaryDirectory(prefix="crafty") as tmpdir: - with chess.engine.SimpleEngine.popen_xboard("crafty", cwd=tmpdir) as engine: + with chess.engine.SimpleEngine.popen_xboard("crafty", debug=True, cwd=tmpdir) as engine: board = chess.Board("2bqkbn1/2pppp2/np2N3/r3P1p1/p2N2B1/5Q2/PPPPKPP1/RNB2r2 w KQkq - 0 1") limit = chess.engine.Limit(depth=7, time=2.0) info = engine.analyse(board, limit) @@ -3277,7 +3277,7 @@ def test_crafty_analyse(self): @catchAndSkip(FileNotFoundError, "need crafty") def test_crafty_ping(self): with tempfile.TemporaryDirectory(prefix="crafty") as tmpdir: - with chess.engine.SimpleEngine.popen_xboard("crafty", cwd=tmpdir) as engine: + with chess.engine.SimpleEngine.popen_xboard("crafty", debug=True, cwd=tmpdir) as engine: engine.ping() engine.quit() @@ -3297,6 +3297,7 @@ def main(): asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy()) with contextlib.closing(asyncio.get_event_loop()) as loop: + loop.set_debug(True) loop.run_until_complete(main()) def test_uci_debug(self): @@ -3315,6 +3316,7 @@ def main(): asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy()) with contextlib.closing(asyncio.get_event_loop()) as loop: + loop.set_debug(True) loop.run_until_complete(main()) def test_uci_go(self): @@ -3347,6 +3349,7 @@ def main(): asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy()) with contextlib.closing(asyncio.get_event_loop()) as loop: + loop.set_debug(True) loop.run_until_complete(main()) def test_uci_info(self): @@ -3458,6 +3461,7 @@ def main(): asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy()) with contextlib.closing(asyncio.get_event_loop()) as loop: + loop.set_debug(True) loop.run_until_complete(main()) def test_xboard_replay(self): @@ -3559,6 +3563,7 @@ def main(): asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy()) with contextlib.closing(asyncio.get_event_loop()) as loop: + loop.set_debug(True) loop.run_until_complete(main()) def test_run_in_background(self): From ce387ab5a55bae8e5c1b059bc101811eee3f8405 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 11 Jan 2019 17:25:01 +0100 Subject: [PATCH 0233/1451] Properly close AnalysisResult if engine dies --- chess/engine.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index e2b79625a..12b0eaeb4 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1232,6 +1232,10 @@ def _bestmove(self, engine, arg): def cancel(self, engine): engine.send_line("stop") + def engine_terminated(self, engine, exc): + LOGGER.debug("%s: Closing analysis because engine has been terminated (error: %s)", engine, exc) + self.analysis.set_exception(exc) + return (yield from self.communicate(Command)) @asyncio.coroutine @@ -1777,6 +1781,14 @@ def cancel(self, engine): self.final_pong = "pong {}".format(n) engine._ping(n) + def engine_terminated(self, engine, exc): + LOGGER.debug("%s: Closing analysis because engine has been terminated (error: %s)", engine, exc) + + if self.time_limit_handle: + self.time_limit_handle.cancel() + + self.analysis.set_exception(exc) + return (yield from self.communicate(Command)) def _configure(self, options): @@ -1932,7 +1944,7 @@ def __init__(self, stop=None): self._stop = stop self._queue = asyncio.Queue() self._seen_kork = False - self._finished = asyncio.Event() + self._finished = asyncio.Future() self.multipv = [{}] def post(self, info): @@ -1945,7 +1957,11 @@ def post(self, info): def set_finished(self): self._queue.put_nowait(KORK) - self._finished.set() + self._finished.set_result(None) + + def set_exception(self, exc): + self._queue.put_nowait(KORK) + self._finished.set_exception(exc) @property def info(self): @@ -1953,14 +1969,14 @@ def info(self): def stop(self): """Stops the analysis as soon as possible.""" - if self._stop and not self._finished.is_set(): + if self._stop and not self._finished.done(): self._stop() self._stop = None @asyncio.coroutine def wait(self): """Waits until the analysis is complete (or stopped).""" - return (yield from self._finished.wait()) + yield from self._finished def __aiter__(self): return self @@ -1988,6 +2004,7 @@ def __anext__(self): info = yield from self._queue.get() if info is KORK: self._seen_kork = True + yield from self._finished raise StopAsyncIteration return info From d6e1636581c6e2380bbc5d98faac4bcd40342355 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 11 Jan 2019 17:30:18 +0100 Subject: [PATCH 0234/1451] Close SimpleEngine is process exits --- chess/engine.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index 12b0eaeb4..865bfafc5 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -2170,8 +2170,9 @@ def background(future): transport, protocol = yield from asyncio.wait_for(Protocol.popen(command, setpgrp=setpgrp, **popen_args), timeout) simple_engine = cls(transport, protocol, timeout=timeout) future.set_result(simple_engine) - yield from simple_engine._shutdown_event.wait() yield from protocol.returncode + simple_engine.close() + yield from simple_engine._shutdown_event.wait() return run_in_background(background, debug=debug) From df36ce7cf6e84f611036f1e7212a8ca5c6b29840 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 11 Jan 2019 17:37:02 +0100 Subject: [PATCH 0235/1451] Add EngineError types to reference docs --- docs/engine.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/engine.rst b/docs/engine.rst index 32739f88c..7641e5077 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -344,6 +344,10 @@ when submitting bug reports. Reference --------- +.. autoclass:: EngineError + +.. autoclass:: EngineTerminatedError + .. autofunction:: chess.engine.popen_uci .. autofunction:: chess.engine.popen_xboard From 57c9bbaec4533e5e4c0085ade380fe2cc8502877 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 11 Jan 2019 17:48:04 +0100 Subject: [PATCH 0236/1451] Fix chess.engine.Limit doc: black_time -> black_clock --- docs/engine.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/engine.rst b/docs/engine.rst index 7641e5077..2dd8af683 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -83,7 +83,7 @@ Example: Let Stockfish play against itself, 100 milliseconds per move. Time in seconds remaining for White. - .. py:attribute:: black_time + .. py:attribute:: black_clock Time in seconds remaining for Black. From bc56ab377002b3b0dd3df4c9384210d5a681241d Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 11 Jan 2019 18:15:27 +0100 Subject: [PATCH 0237/1451] Add ellipsis to engine configure example --- docs/engine.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/engine.rst b/docs/engine.rst index 2dd8af683..8d3bf63fa 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -256,6 +256,8 @@ Option(name='Hash', type='spin', default=16, min=1, max=131072, var=[]) >>> >>> # Set an option. >>> engine.configure({"Hash": 32}) +>>> +>>> # [...] .. code:: python @@ -272,6 +274,8 @@ Option(name='Hash', type='spin', default=16, min=1, max=131072, var=[]) # Set an option. await engine.configure({"Hash": 32}) + # [...] + asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy()) asyncio.run(main()) From 78e51a8e17a17049560cf61a7235f2e1478f11c7 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 11 Jan 2019 18:21:55 +0100 Subject: [PATCH 0238/1451] Fix autoclass in engine docs --- docs/engine.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/engine.rst b/docs/engine.rst index 8d3bf63fa..90b06c985 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -348,9 +348,9 @@ when submitting bug reports. Reference --------- -.. autoclass:: EngineError +.. autoclass:: chess.engine.EngineError -.. autoclass:: EngineTerminatedError +.. autoclass:: chess.engine.EngineTerminatedError .. autofunction:: chess.engine.popen_uci From 40463cbc25b606787c17f2ce3c6bd4226498b3e6 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 11 Jan 2019 19:13:22 +0100 Subject: [PATCH 0239/1451] Normalize scores to White's point of view --- chess/engine.py | 20 ++++++++++++-------- docs/engine.rst | 5 +++-- test.py | 18 +++++++++--------- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 865bfafc5..bf92f5a15 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -310,8 +310,8 @@ class Score(abc.ABC): >>> Mate.minus(0) < Mate.minus(1) < Cp(-50) < Cp(200) < Mate.plus(4) < Mate.plus(0) True - Scores are usually given from the point of view of the side to move. They - can be negated to change the point of view: + Scores are usually given from White's point of view. They can be negated to + change the point of view: >>> -Cp(20) Cp(-20) @@ -1327,9 +1327,9 @@ def end_of_parameter(): elif token == "upperbound": info["upperbound"] = True elif score_kind == "cp": - info["score"] = Cp(int(token)) + info["score"] = Cp(int(token)) if root_board.turn else -Cp(int(token)) elif score_kind == "mate": - info["score"] = Mate.from_moves(int(token)) + info["score"] = Mate.from_moves(int(token)) if root_board.turn else -Mate.from_moves(int(token)) except ValueError: LOGGER.error("exception parsing score %s from info: %r", score_kind, arg) elif current_parameter == "currmove": @@ -1753,7 +1753,10 @@ def _post(self, engine, line): self.cancel(engine) elif limit.depth is not None and post_info.get("depth", 0) >= limit.depth: self.cancel(engine) - elif limit.mate is not None and post_info.get("score", Cp(0)) >= Mate.plus(limit.mate): + elif limit.mate is not None: + score = post_info.get("score", Cp(0)) + pov_score = score if engine.board.turn else -score + elif limit.mate is not None and pov_score >= Mate.plus(limit.mate): self.cancel(engine) def end(self, engine): @@ -1893,11 +1896,12 @@ def _parse_xboard_post(line, root_board, selector=INFO_ALL): # Score. if cp <= -100000: - info["score"] = Mate.minus(abs(cp) - 100000) + score = Mate.minus(abs(cp) - 100000) elif cp >= 100000: - info["score"] = Mate.plus(cp - 100000) + score = Mate.plus(cp - 100000) else: - info["score"] = Cp(cp) + score = Cp(cp) + info["score"] = score if root_board.turn else -score # Optional integer tokens. if integer_tokens: diff --git a/docs/engine.rst b/docs/engine.rst index 90b06c985..5132e0637 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -114,8 +114,9 @@ Example: Let Stockfish play against itself, 100 milliseconds per move. .. py:attribute:: info A dictionary of extra information sent by the engine. Commonly used - keys are: ``score``, ``pv``, ``depth``, ``seldepth``, ``time`` - (in seconds), ``nodes``, ``nps``, ``tbhits``, ``multipv``. + keys are: ``score`` (from White's point of view), ``pv``, ``depth``, + ``seldepth``, ``time`` (in seconds), ``nodes``, ``nps``, ``tbhits``, + ``multipv``. Others: ``currmove``, ``currmovenumber``, ``hashfull`` ``cpuload``, ``refutation``, ``currline``, ``ebf`` and ``string``. diff --git a/test.py b/test.py index 5d8daad25..372f9ad70 100755 --- a/test.py +++ b/test.py @@ -3233,10 +3233,10 @@ def test_sf_analysis(self): limit = chess.engine.Limit(depth=20) with engine.analysis(board, limit) as analysis: for info in analysis: - if info.get("score", chess.engine.Cp(0)) >= chess.engine.Mate.plus(3): + if info.get("score", chess.engine.Cp(0)) >= chess.engine.Mate.plus(2): break - self.assertEqual(analysis.info["score"], chess.engine.Mate.plus(3)) - self.assertEqual(analysis.multipv[0]["score"], chess.engine.Mate.plus(3)) + self.assertEqual(analysis.info["score"], chess.engine.Mate.plus(2)) + self.assertEqual(analysis.multipv[0]["score"], chess.engine.Mate.plus(2)) engine.quit() @catchAndSkip(FileNotFoundError, "need stockfish") @@ -3362,7 +3362,7 @@ def test_uci_info(self): self.assertEqual(info["refutation"][chess.Move.from_uci("d1h5")], []) # Info: string. - info = chess.engine._parse_uci_info("string goes to end no matter score cp 4 what", None) + info = chess.engine._parse_uci_info("string goes to end no matter score cp 4 what", board) self.assertEqual(info["string"], "goes to end no matter score cp 4 what") # Info: currline. @@ -3370,17 +3370,17 @@ def test_uci_info(self): self.assertEqual(info["currline"][0], [chess.Move.from_uci("e2e4"), chess.Move.from_uci("e7e5")]) # Info: ebf. - info = chess.engine._parse_uci_info("ebf 0.42", None) + info = chess.engine._parse_uci_info("ebf 0.42", board) self.assertEqual(info["ebf"], 0.42) # Info: depth, seldepth, score mate. - info = chess.engine._parse_uci_info("depth 7 seldepth 8 score mate 3", None) + info = chess.engine._parse_uci_info("depth 7 seldepth 8 score mate 3", board) self.assertEqual(info["depth"], 7) self.assertEqual(info["seldepth"], 8) self.assertEqual(info["score"], chess.engine.Mate.plus(3)) # Info: tbhits, cpuload, hashfull, time, nodes, nps. - info = chess.engine._parse_uci_info("tbhits 123 cpuload 456 hashfull 789 time 987 nodes 654 nps 321", None) + info = chess.engine._parse_uci_info("tbhits 123 cpuload 456 hashfull 789 time 987 nodes 654 nps 321", board) self.assertEqual(info["tbhits"], 123) self.assertEqual(info["cpuload"], 456) self.assertEqual(info["hashfull"], 789) @@ -3389,7 +3389,7 @@ def test_uci_info(self): self.assertEqual(info["nps"], 321) # Hakkapeliitta double spaces. - info = chess.engine._parse_uci_info("depth 10 seldepth 9 score cp 22 time 17 nodes 48299 nps 2683000 tbhits 0", None) + info = chess.engine._parse_uci_info("depth 10 seldepth 9 score cp 22 time 17 nodes 48299 nps 2683000 tbhits 0", board) self.assertEqual(info["depth"], 10) self.assertEqual(info["seldepth"], 9) self.assertEqual(info["score"], chess.engine.Cp(22)) @@ -3555,7 +3555,7 @@ def main(): mock.expect_ping() info = yield from protocol.analyse(board, limit, root_moves=[board.parse_san("f6")]) self.assertEqual(info["depth"], 4) - self.assertEqual(info["score"], chess.engine.Cp(116)) + self.assertEqual(info["score"], chess.engine.Cp(-116)) self.assertEqual(info["time"], 0.23) self.assertEqual(info["nodes"], 2252) self.assertEqual(info["pv"], [chess.Move.from_uci(move) for move in ["f7f6", "e2e4", "e7e6"]]) From e8376c01285a15ed0bca92989a45e6e3138e1507 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 11 Jan 2019 19:35:34 +0100 Subject: [PATCH 0240/1451] Fix test case with Stockfish 10 --- test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test.py b/test.py index 372f9ad70..a9ee8d6b9 100755 --- a/test.py +++ b/test.py @@ -3233,10 +3233,10 @@ def test_sf_analysis(self): limit = chess.engine.Limit(depth=20) with engine.analysis(board, limit) as analysis: for info in analysis: - if info.get("score", chess.engine.Cp(0)) >= chess.engine.Mate.plus(2): + if info.get("score", chess.engine.Cp(0)) >= chess.engine.Mate.plus(3): break - self.assertEqual(analysis.info["score"], chess.engine.Mate.plus(2)) - self.assertEqual(analysis.multipv[0]["score"], chess.engine.Mate.plus(2)) + self.assertEqual(analysis.info["score"], chess.engine.Mate.plus(3)) + self.assertEqual(analysis.multipv[0]["score"], chess.engine.Mate.plus(3)) engine.quit() @catchAndSkip(FileNotFoundError, "need stockfish") From cccb3d5670be029935bb941602cf01516d2c7889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Schoentgen?= Date: Sat, 12 Jan 2019 12:42:51 +0100 Subject: [PATCH 0241/1451] Fix DeprecationWarning: invalid escape sequence in setup.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mickaël Schoentgen --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 860122194..83b241fa2 100755 --- a/setup.py +++ b/setup.py @@ -60,7 +60,7 @@ def read_description(): "//travis-ci.org/niklasf/python-chess.svg?branch=v{}".format(chess.__version__)) # Remove doctest comments. - description = re.sub("\s*# doctest:.*", "", description) + description = re.sub(r"\s*# doctest:.*", "", description) return description From f258a0c05ed88aa2589b9e9669c5011365a84db0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Schoentgen?= Date: Sat, 12 Jan 2019 12:52:46 +0100 Subject: [PATCH 0242/1451] Fix ResourceWarning: unclosed file in setup.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Mickaël Schoentgen --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 860122194..dd8a40f52 100755 --- a/setup.py +++ b/setup.py @@ -42,7 +42,8 @@ def read_description(): Reads the description from README.rst and substitutes mentions of the latest version with a concrete version number. """ - description = io.open(os.path.join(os.path.dirname(__file__), "README.rst"), encoding="utf-8").read() + with io.open(os.path.join(os.path.dirname(__file__), "README.rst"), encoding="utf-8") as f: + description = f.read() # Link to the documentation of the specific version. description = description.replace( From b6fb521c404e2987fa41f5e825c3bc8f6d70c26c Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 12 Jan 2019 16:20:17 +0100 Subject: [PATCH 0243/1451] setup.py: open supports encoding in Python 3 --- setup.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 6971f8fcf..581976928 100755 --- a/setup.py +++ b/setup.py @@ -17,7 +17,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import io import os import platform import re @@ -42,7 +41,7 @@ def read_description(): Reads the description from README.rst and substitutes mentions of the latest version with a concrete version number. """ - with io.open(os.path.join(os.path.dirname(__file__), "README.rst"), encoding="utf-8") as f: + with open(os.path.join(os.path.dirname(__file__), "README.rst"), encoding="utf-8") as f: description = f.read() # Link to the documentation of the specific version. From 1a630de5b8da1f9a86da22e16d0ec8262686df9b Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 12 Jan 2019 17:46:07 +0100 Subject: [PATCH 0244/1451] Rewrite Cp and Mate, add MateGiven --- chess/engine.py | 172 +++++++++++++++++++++++------------------------- test.py | 36 +++++----- 2 files changed, 99 insertions(+), 109 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index bf92f5a15..0f3fe0399 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -286,7 +286,6 @@ class Info(_IntFlag): CURRLINE = 16 ALL = BASIC | SCORE | PV | REFUTATION | CURRLINE - INFO_NONE = Info.NONE INFO_BASIC = Info.BASIC INFO_SCORE = Info.SCORE @@ -296,28 +295,32 @@ class Info(_IntFlag): INFO_ALL = Info.ALL +@functools.total_ordering class Score(abc.ABC): """ Evaluation of a position. - The score can be :class:`~chess.engine.Cp` (centi-pawns) or - :class:`~chess.engine.Mate`. A positive value indicates an advantage. + The score can be :class:`~chess.engine.Cp` (centi-pawns), + :class:`~chess.engine.Mate` or ``MateGiven``. A positive value indicates + an advantage. There is a total order defined on centi-pawn and mate scores. - >>> from chess.engine import Cp, Mate + >>> from chess.engine import Cp, Mate, MateGiven >>> - >>> Mate.minus(0) < Mate.minus(1) < Cp(-50) < Cp(200) < Mate.plus(4) < Mate.plus(0) + >>> Mate(-0) < Mate(-1) < Cp(-50) < Cp(200) < Mate(4) < Mate(1) < MateGiven True - Scores are usually given from White's point of view. They can be negated to - change the point of view: + Scores can be negated to change the point of view: >>> -Cp(20) Cp(-20) - >>> -Mate.from_moves(4) - Mate.minus(4) + >>> Mate(-4) + Mate(+4) + + >>> Mate(0) + MateGiven """ @abc.abstractmethod @@ -328,27 +331,22 @@ def score(self, *, mate_score=None): You can optionally pass a large value to convert mate scores to centi-pawn scores. - >>> from chess.engine import Cp, Mate - >>> - >>> cp = Cp(-300) - >>> cp.score() + >>> Cp(-300).score() -300 - >>> - >>> mate = Mate.from_moves(5) - >>> mate.score() is None + >>> Mate(5).score() is None True - >>> mate.score(mate_score=100000) + >>> Mate(5).score(mate_score=100000) 99995 """ @abc.abstractmethod def mate(self): """ - Returns the number of plies to mate, negative if we are getting mated, - or ``None``. + Returns the number of plies to mate, negative if we are getting + mated, or ``None``. - :warning: This conflates ``Mate.minus(0)`` (we are mated) and - ``Mate.plus(0)`` (we have given mate) to ``0``. + :warning: This conflates ``Mate(0)`` (we lost) and ``MateGiven`` + (we won) to ``0``. """ def is_mate(self): @@ -359,8 +357,28 @@ def is_mate(self): def __neg__(self): pass + def _score_tuple(self): + return ( + isinstance(self, _MateGiven), + self.is_mate() and self.mate() > 0, + not self.is_mate(), + -(self.mate() or 0), + self.score(), + ) + + def __eq__(self, other): + try: + return self._score_tuple() == other._score_tuple() + except AttributeError: + return NotImplemented + + def __lt__(self, other): + try: + return self._score_tuple() < other._score_tuple() + except AttributeError: + return NotImplemented + -@functools.total_ordering class Cp(Score): """Centi-pawn score.""" @@ -373,12 +391,12 @@ def mate(self): def score(self, mate_score=None): return self.cp - def __repr__(self): - return "Cp({})".format(self.cp) - def __str__(self): return "+{}".format(self.cp) if self.cp > 0 else str(self.cp) + def __repr__(self): + return "Cp({})".format(str(self)) + def __neg__(self): return Cp(-self.cp) @@ -388,95 +406,65 @@ def __pos__(self): def __abs__(self): return Cp(abs(self.cp)) - def __eq__(self, other): - try: - return self.cp == other.score() - except AttributeError: - return NotImplemented - - def __lt__(self, other): - try: - return other.winning - except AttributeError: - pass - - try: - return self.cp < other.cp - except AttributeError: - pass - - return NotImplemented - -@functools.total_ordering class Mate(Score): """Mate score.""" - def __init__(self, moves, winning): - self.moves = abs(moves) - self.winning = winning ^ (moves < 0) + def __init__(self, moves): + self.moves = moves + + def mate(self): + return self.moves def score(self, mate_score=None): if mate_score is None: return None - elif self.winning: + elif self.moves > 0: return mate_score - self.moves else: return -mate_score + self.moves - def mate(self): - # Careful: Conflates Mate.plus(0) and Mate.minus(0)! - return self.moves if self.winning else -self.moves + def __str__(self): + return "#+{}".format(self.moves) if self.moves > 0 else "#-{}".format(abs(self.moves)) - @classmethod - def from_moves(cls, moves): - return Mate(abs(moves), moves > 0) + def __repr__(self): + return ("Mate(+{})" if self.moves > 0 else "Mate(-{})").format(abs(self.moves)) - @classmethod - def plus(self, moves): - return Mate(moves, True) + def __neg__(self): + return MateGiven if not self.moves else Mate(-self.moves) - @classmethod - def minus(self, moves): - return Mate(moves, False) + def __pos__(self): + return Mate(self.moves) - def __repr__(self): - if self.winning: - return "Mate.plus({})".format(self.moves) - else: - return "Mate.minus({})".format(self.moves) + def __abs__(self): + return MateGiven if not self.moves else Mate(abs(self.moves)) - def __str__(self): - return "#{}".format(self.moves) if self.winning else "#-{}".format(self.moves) +class _MateGiven(Score): + """Winning mate score, equivalent to ``-Mate(0)``.""" + + def mate(self): + return 0 + + def score(self, *, mate_score=None): + return mate_score def __neg__(self): - return Mate(self.moves, not self.winning) + return Mate(0) def __pos__(self): - return Mate(self.moves, self.winning) + return self def __abs__(self): - return Mate(self.moves, True) + return self - def __eq__(self, other): - try: - return other.is_mate() and self.moves == other.moves and self.winning == other.winning - except AttributeError: - return NotImplemented + def __repr__(self): + return "MateGiven" - def __lt__(self, other): - try: - if self.winning != other.winning: - return self.winning < other.winning + def __str__(self): + return "#+0" - if self.winning: - return self.moves > other.moves - else: - return self.moves < other.moves - except AttributeError: - pass - return other > self +MateGiven = _MateGiven() class MockTransport: @@ -1329,7 +1317,7 @@ def end_of_parameter(): elif score_kind == "cp": info["score"] = Cp(int(token)) if root_board.turn else -Cp(int(token)) elif score_kind == "mate": - info["score"] = Mate.from_moves(int(token)) if root_board.turn else -Mate.from_moves(int(token)) + info["score"] = Mate(int(token)) if root_board.turn else -Mate(int(token)) except ValueError: LOGGER.error("exception parsing score %s from info: %r", score_kind, arg) elif current_parameter == "currmove": @@ -1756,7 +1744,7 @@ def _post(self, engine, line): elif limit.mate is not None: score = post_info.get("score", Cp(0)) pov_score = score if engine.board.turn else -score - elif limit.mate is not None and pov_score >= Mate.plus(limit.mate): + elif limit.mate is not None and pov_score >= Mate(limit.mate): self.cancel(engine) def end(self, engine): @@ -1896,9 +1884,11 @@ def _parse_xboard_post(line, root_board, selector=INFO_ALL): # Score. if cp <= -100000: - score = Mate.minus(abs(cp) - 100000) + score = Mate(cp + 100000) + elif cp == 100000: + score = MateGiven elif cp >= 100000: - score = Mate.plus(cp - 100000) + score = Mate(cp - 100000) else: score = Cp(cp) info["score"] = score if root_board.turn else -score diff --git a/test.py b/test.py index a9ee8d6b9..cefdb7119 100755 --- a/test.py +++ b/test.py @@ -3166,22 +3166,22 @@ def test_uci_option_map_len(self): def test_score_ordering(self): order = [ - chess.engine.Mate.minus(0), - chess.engine.Mate.minus(1), - chess.engine.Mate.minus(99), + chess.engine.Mate(-0), + chess.engine.Mate(-1), + chess.engine.Mate(-99), chess.engine.Cp(-123), chess.engine.Cp(-50), chess.engine.Cp(0), - chess.engine.Cp(30), - chess.engine.Cp(800), - chess.engine.Mate.plus(77), - chess.engine.Mate.plus(1), - chess.engine.Mate.plus(0), + chess.engine.Cp(+30), + chess.engine.Cp(+800), + chess.engine.Mate(+77), + chess.engine.Mate(+1), + chess.engine.MateGiven, ] for i, a in enumerate(order): for j, b in enumerate(order): - self.assertEqual(i < j, a < b) + self.assertEqual(i < j, a < b, "{} < {}".format(a, b)) self.assertEqual(i <= j, a <= b) self.assertEqual(i == j, a == b) self.assertEqual(i != j, a != b) @@ -3190,17 +3190,17 @@ def test_score_ordering(self): def test_score(self): # Negation. - self.assertEqual(-chess.engine.Cp(20), chess.engine.Cp(-20)) - self.assertEqual(-chess.engine.Mate.from_moves(4), chess.engine.Mate.minus(4)) + self.assertEqual(-chess.engine.Cp(+20), chess.engine.Cp(-20)) + self.assertEqual(-chess.engine.Mate(+4), chess.engine.Mate(-4)) # Score. self.assertEqual(chess.engine.Cp(-300).score(), -300) - self.assertEqual(chess.engine.Mate.from_moves(5).score(), None) - self.assertEqual(chess.engine.Mate.from_moves(5).score(100000), 99995) + self.assertEqual(chess.engine.Mate(+5).score(), None) + self.assertEqual(chess.engine.Mate(+5).score(100000), 99995) # Mate. self.assertEqual(chess.engine.Cp(-300).mate(), None) - self.assertEqual(chess.engine.Mate.from_moves(5).mate(), 5) + self.assertEqual(chess.engine.Mate(+5).mate(), 5) @catchAndSkip(FileNotFoundError, "need stockfish") def test_sf_forced_mates(self): @@ -3233,10 +3233,10 @@ def test_sf_analysis(self): limit = chess.engine.Limit(depth=20) with engine.analysis(board, limit) as analysis: for info in analysis: - if info.get("score", chess.engine.Cp(0)) >= chess.engine.Mate.plus(3): + if info.get("score", chess.engine.Cp(0)) >= chess.engine.Mate(+3): break - self.assertEqual(analysis.info["score"], chess.engine.Mate.plus(3)) - self.assertEqual(analysis.multipv[0]["score"], chess.engine.Mate.plus(3)) + self.assertEqual(analysis.info["score"], chess.engine.Mate(+3)) + self.assertEqual(analysis.multipv[0]["score"], chess.engine.Mate(+3)) engine.quit() @catchAndSkip(FileNotFoundError, "need stockfish") @@ -3377,7 +3377,7 @@ def test_uci_info(self): info = chess.engine._parse_uci_info("depth 7 seldepth 8 score mate 3", board) self.assertEqual(info["depth"], 7) self.assertEqual(info["seldepth"], 8) - self.assertEqual(info["score"], chess.engine.Mate.plus(3)) + self.assertEqual(info["score"], chess.engine.Mate(+3)) # Info: tbhits, cpuload, hashfull, time, nodes, nps. info = chess.engine._parse_uci_info("tbhits 123 cpuload 456 hashfull 789 time 987 nodes 654 nps 321", board) From 6eaabd6d8aacf47f740f8f3fa98d011327093b2d Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 12 Jan 2019 18:09:43 +0100 Subject: [PATCH 0245/1451] Introduce chess.engine.PovScore --- chess/engine.py | 46 ++++++++++++++++++++++++++++++++++++++-------- docs/engine.rst | 18 +++++++++++++++--- test.py | 12 ++++++------ 3 files changed, 59 insertions(+), 17 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 0f3fe0399..57d84a902 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -295,6 +295,38 @@ class Info(_IntFlag): INFO_ALL = Info.ALL +class PovScore: + """A relative :class:`~chess.engine.Score` and the point of view.""" + + def __init__(self, relative, turn): + self.relative = relative + self.turn = turn + + def white(self): + return self.relative if self.turn else -self.relative + + def black(self): + return -self.relative if self.turn else self.relative + + def __repr__(self): + return "PovScore({}, {})".format(repr(self.relative), "WHITE" if self.turn else "BLACK") + + def __str__(self): + return str(self.relative) + + def __eq__(self, other): + try: + return self.relative == other.relative and self.turn == other.turn + except AttributeError: + return NotImplemented + + def __ne__(self, other): + try: + return self.relative != other.relative or self.turn != other.turn + except AttributeError: + return NotImplemented + + @functools.total_ordering class Score(abc.ABC): """ @@ -1315,9 +1347,9 @@ def end_of_parameter(): elif token == "upperbound": info["upperbound"] = True elif score_kind == "cp": - info["score"] = Cp(int(token)) if root_board.turn else -Cp(int(token)) + info["score"] = PovScore(Cp(int(token)), root_board.turn) elif score_kind == "mate": - info["score"] = Mate(int(token)) if root_board.turn else -Mate(int(token)) + info["score"] = PovScore(Mate(int(token)), root_board.turn) except ValueError: LOGGER.error("exception parsing score %s from info: %r", score_kind, arg) elif current_parameter == "currmove": @@ -1741,11 +1773,9 @@ def _post(self, engine, line): self.cancel(engine) elif limit.depth is not None and post_info.get("depth", 0) >= limit.depth: self.cancel(engine) - elif limit.mate is not None: - score = post_info.get("score", Cp(0)) - pov_score = score if engine.board.turn else -score - elif limit.mate is not None and pov_score >= Mate(limit.mate): - self.cancel(engine) + elif limit.mate is not None and "score" in post_info: + if post_info["score"].relative >= limit.mate: + self.cancel(engine) def end(self, engine): if self.time_limit_handle: @@ -1891,7 +1921,7 @@ def _parse_xboard_post(line, root_board, selector=INFO_ALL): score = Mate(cp - 100000) else: score = Cp(cp) - info["score"] = score if root_board.turn else -score + info["score"] = PovScore(score, root_board.turn) # Optional integer tokens. if integer_tokens: diff --git a/docs/engine.rst b/docs/engine.rst index 5132e0637..971057b4a 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -114,9 +114,10 @@ Example: Let Stockfish play against itself, 100 milliseconds per move. .. py:attribute:: info A dictionary of extra information sent by the engine. Commonly used - keys are: ``score`` (from White's point of view), ``pv``, ``depth``, - ``seldepth``, ``time`` (in seconds), ``nodes``, ``nps``, ``tbhits``, - ``multipv``. + keys are: ``score`` (a :class:`~chess.engine.PovScore`), + ``pv`` (a list of :class:`~chess.Move` objects), + ``depth``, ``seldepth``, ``time`` (in seconds), ``nodes``, ``nps``, + ``tbhits``, ``multipv``. Others: ``currmove``, ``currmovenumber``, ``hashfull`` ``cpuload``, ``refutation``, ``currline``, ``ebf`` and ``string``. @@ -176,6 +177,17 @@ Example: .. autoclass:: chess.engine.EngineProtocol :members: analyse +.. autoclass:: chess.engine.PovScore + :members: + + .. py:attribute:: relative + + The relative :class:`~chess.engine.Score`. + + .. py:attribute:: turn + + The point of view (`chess.WHITE` or `chess.BLACK`). + .. autoclass:: chess.engine.Score :members: diff --git a/test.py b/test.py index cefdb7119..e10153ebe 100755 --- a/test.py +++ b/test.py @@ -3233,10 +3233,10 @@ def test_sf_analysis(self): limit = chess.engine.Limit(depth=20) with engine.analysis(board, limit) as analysis: for info in analysis: - if info.get("score", chess.engine.Cp(0)) >= chess.engine.Mate(+3): + if "score" in info and info["score"].white() >= chess.engine.Mate(+3): break - self.assertEqual(analysis.info["score"], chess.engine.Mate(+3)) - self.assertEqual(analysis.multipv[0]["score"], chess.engine.Mate(+3)) + self.assertEqual(analysis.info["score"].relative, chess.engine.Mate(+3)) + self.assertEqual(analysis.multipv[0]["score"].black(), chess.engine.Mate(-3)) engine.quit() @catchAndSkip(FileNotFoundError, "need stockfish") @@ -3377,7 +3377,7 @@ def test_uci_info(self): info = chess.engine._parse_uci_info("depth 7 seldepth 8 score mate 3", board) self.assertEqual(info["depth"], 7) self.assertEqual(info["seldepth"], 8) - self.assertEqual(info["score"], chess.engine.Mate(+3)) + self.assertEqual(info["score"], chess.engine.PovScore(chess.engine.Mate(+3), chess.WHITE)) # Info: tbhits, cpuload, hashfull, time, nodes, nps. info = chess.engine._parse_uci_info("tbhits 123 cpuload 456 hashfull 789 time 987 nodes 654 nps 321", board) @@ -3392,7 +3392,7 @@ def test_uci_info(self): info = chess.engine._parse_uci_info("depth 10 seldepth 9 score cp 22 time 17 nodes 48299 nps 2683000 tbhits 0", board) self.assertEqual(info["depth"], 10) self.assertEqual(info["seldepth"], 9) - self.assertEqual(info["score"], chess.engine.Cp(22)) + self.assertEqual(info["score"], chess.engine.PovScore(chess.engine.Cp(22), chess.WHITE)) self.assertEqual(info["time"], 0.017) self.assertEqual(info["nodes"], 48299) self.assertEqual(info["nps"], 2683000) @@ -3555,7 +3555,7 @@ def main(): mock.expect_ping() info = yield from protocol.analyse(board, limit, root_moves=[board.parse_san("f6")]) self.assertEqual(info["depth"], 4) - self.assertEqual(info["score"], chess.engine.Cp(-116)) + self.assertEqual(info["score"], chess.engine.PovScore(chess.engine.Cp(116), chess.BLACK)) self.assertEqual(info["time"], 0.23) self.assertEqual(info["nodes"], 2252) self.assertEqual(info["pv"], [chess.Move.from_uci(move) for move in ["f7f6", "e2e4", "e7e6"]]) From 52ba9b97a15c708189e62d9d9ea8dc6d35f3a987 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 12 Jan 2019 18:22:21 +0100 Subject: [PATCH 0246/1451] Document PovScore getters --- chess/engine.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index 57d84a902..d70acf52f 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -303,11 +303,17 @@ def __init__(self, relative, turn): self.turn = turn def white(self): + """Get the score from White's point of view.""" return self.relative if self.turn else -self.relative def black(self): + """Get the score from Black's point of view.""" return -self.relative if self.turn else self.relative + def pov(self, color): + """Get the score from the point of view of the given *color*.""" + return self.relative if self.turn == color else -self.relative + def __repr__(self): return "PovScore({}, {})".format(repr(self.relative), "WHITE" if self.turn else "BLACK") From 1baded93184a38aef9d24c25e8d36283fae852c7 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 12 Jan 2019 18:23:31 +0100 Subject: [PATCH 0247/1451] Fix test with Crafty after adding PovScore --- test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test.py b/test.py index e10153ebe..36c062dda 100755 --- a/test.py +++ b/test.py @@ -3269,7 +3269,7 @@ def test_crafty_analyse(self): board = chess.Board("2bqkbn1/2pppp2/np2N3/r3P1p1/p2N2B1/5Q2/PPPPKPP1/RNB2r2 w KQkq - 0 1") limit = chess.engine.Limit(depth=7, time=2.0) info = engine.analyse(board, limit) - self.assertTrue(info["score"] > chess.engine.Cp(1000)) + self.assertTrue(info["score"].relative > chess.engine.Cp(1000)) engine.quit() finally: logging.disable(logging.NOTSET) From db8875e47d6aaa8a7d0f08774fd41a069bc31f35 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 12 Jan 2019 18:25:55 +0100 Subject: [PATCH 0248/1451] Fix Score doctest --- chess/engine.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index d70acf52f..743d5969e 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -354,10 +354,10 @@ class Score(abc.ABC): >>> -Cp(20) Cp(-20) - >>> Mate(-4) + >>> -Mate(-4) Mate(+4) - >>> Mate(0) + >>> -Mate(0) MateGiven """ From 47a94e55f56d43764e6acc3513d9c950df23b751 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 12 Jan 2019 18:27:21 +0100 Subject: [PATCH 0249/1451] Minor style tweak --- docs/engine.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/engine.rst b/docs/engine.rst index 971057b4a..260bff368 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -186,7 +186,7 @@ Example: .. py:attribute:: turn - The point of view (`chess.WHITE` or `chess.BLACK`). + The point of view (``chess.WHITE`` or ``chess.BLACK``). .. autoclass:: chess.engine.Score :members: From fdfdc824e95a336590e563f525d5ae7fbaa65368 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 12 Jan 2019 23:40:50 +0100 Subject: [PATCH 0250/1451] No need to intercept exceptions in SimpleEngine --- chess/engine.py | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 743d5969e..7eb71daa9 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -2124,11 +2124,6 @@ def _not_shut_down(self): raise EngineTerminatedError("engine event loop dead") yield - def _await(self, future): - if isinstance(future.exception(), EngineTerminatedError): - self.close() - return future.result() - @property def options(self): @asyncio.coroutine @@ -2137,7 +2132,7 @@ def _get(): with self._not_shut_down(): future = asyncio.run_coroutine_threadsafe(_get(), self.protocol.loop) - return self._await(future) + return future.result() @property def id(self): @@ -2147,44 +2142,43 @@ def _get(): with self._not_shut_down(): future = asyncio.run_coroutine_threadsafe(_get(), self.protocol.loop) - return self._await(future) - + return future.result() def configure(self, options): with self._not_shut_down(): coro = asyncio.wait_for(self.protocol.configure(options), self.timeout) future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop) - return self._await(future) + return future.result() def ping(self): with self._not_shut_down(): coro = asyncio.wait_for(self.protocol.ping(), self.timeout) future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop) - return self._await(future) + return future.result() def play(self, board, limit, *, game=None, info=INFO_NONE, ponder=False, root_moves=None, options={}): with self._not_shut_down(): coro = asyncio.wait_for(self.protocol.play(board, limit, game=game, info=info, ponder=ponder, root_moves=root_moves, options=options), self._timeout_for(limit)) future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop) - return self._await(future) + return future.result() def analyse(self, board, limit, *, multipv=None, game=None, info=INFO_ALL, root_moves=None, options={}): with self._not_shut_down(): coro = asyncio.wait_for(self.protocol.analyse(board, limit, multipv=multipv, game=game, info=info, root_moves=root_moves, options=options), self._timeout_for(limit)) future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop) - return self._await(future) + return future.result() def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, root_moves=None, options={}): with self._not_shut_down(): coro = asyncio.wait_for(self.protocol.analysis(board, limit, multipv=multipv, game=game, info=info, root_moves=root_moves, options=options), self.timeout) future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop) - return SimpleAnalysisResult(self, self._await(future)) + return SimpleAnalysisResult(self, future.result()) def quit(self): with self._not_shut_down(): coro = asyncio.wait_for(self.protocol.quit(), self.timeout) future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop) - return self._await(future) + return future.result() def close(self): """Closes the transport and the background event loop.""" @@ -2252,7 +2246,7 @@ def _get(): with self.simple_engine._not_shut_down(): future = asyncio.run_coroutine_threadsafe(_get(), self.simple_engine.protocol.loop) - return self.simple_engine._await(future) + return future.result() @property def multipv(self): @@ -2262,7 +2256,7 @@ def _get(): with self.simple_engine._not_shut_down(): future = asyncio.run_coroutine_threadsafe(_get(), self.simple_engine.protocol.loop) - return self.simple_engine._await(future) + return future.result() def stop(self): with self.simple_engine._not_shut_down(): @@ -2271,12 +2265,12 @@ def stop(self): def wait(self): with self.simple_engine._not_shut_down(): future = asyncio.run_coroutine_threadsafe(self.inner.wait(), self.simple_engine.protocol.loop) - return self.simple_engine._await(future) + return future.result() def next(self): with self.simple_engine._not_shut_down(): future = asyncio.run_coroutine_threadsafe(self.inner.next(), self.simple_engine.protocol.loop) - return self.simple_engine._await(future) + return future.result() def __iter__(self): return self @@ -2285,7 +2279,7 @@ def __next__(self): try: with self.simple_engine._not_shut_down(): future = asyncio.run_coroutine_threadsafe(self.inner.__anext__(), self.simple_engine.protocol.loop) - return self.simple_engine._await(future) + return future.result() except StopAsyncIteration: raise StopIteration From 35b79c4d0f0efbd94678a1f5a7a7939a666294ce Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 12 Jan 2019 23:38:21 +0100 Subject: [PATCH 0251/1451] Make mate_score kwonly also in subclasses --- chess/engine.py | 4 ++-- test.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 7eb71daa9..2f1811136 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -426,7 +426,7 @@ def __init__(self, cp): def mate(self): return None - def score(self, mate_score=None): + def score(self, *, mate_score=None): return self.cp def __str__(self): @@ -454,7 +454,7 @@ def __init__(self, moves): def mate(self): return self.moves - def score(self, mate_score=None): + def score(self, *, mate_score=None): if mate_score is None: return None elif self.moves > 0: diff --git a/test.py b/test.py index 36c062dda..eafb79b91 100755 --- a/test.py +++ b/test.py @@ -3196,7 +3196,7 @@ def test_score(self): # Score. self.assertEqual(chess.engine.Cp(-300).score(), -300) self.assertEqual(chess.engine.Mate(+5).score(), None) - self.assertEqual(chess.engine.Mate(+5).score(100000), 99995) + self.assertEqual(chess.engine.Mate(+5).score(mate_score=100000), 99995) # Mate. self.assertEqual(chess.engine.Cp(-300).mate(), None) From 0a01dfa0c5685aca68f374332a01515b9da2fc37 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sun, 13 Jan 2019 18:08:49 +0100 Subject: [PATCH 0252/1451] Simplify Piece --- chess/__init__.py | 24 ++++++------------------ test.py | 4 ++++ 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/chess/__init__.py b/chess/__init__.py index 9c3899dc9..563ffa204 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -389,19 +389,15 @@ def symbol(self): Gets the symbol ``P``, ``N``, ``B``, ``R``, ``Q`` or ``K`` for white pieces or the lower-case variants for the black pieces. """ - if self.color == WHITE: - return PIECE_SYMBOLS[self.piece_type].upper() - else: - return PIECE_SYMBOLS[self.piece_type] + symbol = PIECE_SYMBOLS[self.piece_type] + return symbol.upper() if self.color else symbol def unicode_symbol(self, *, invert_color=False): """ Gets the Unicode character for the piece. """ - if not invert_color: - return UNICODE_PIECE_SYMBOLS[self.symbol()] - else: - return UNICODE_PIECE_SYMBOLS[self.symbol().swapcase()] + symbol = self.symbol().swapcase() if invert_color else self.symbol() + return UNICODE_PIECE_SYMBOLS[symbol] def __hash__(self): return hash(self.piece_type * (self.color + 1)) @@ -422,12 +418,7 @@ def __eq__(self, other): def __ne__(self, other): try: - if self.piece_type != other.piece_type: - return True - elif self.color != other.color: - return True - else: - return False + return self.piece_type != other.piece_type or self.color != other.color except AttributeError: return NotImplemented @@ -438,10 +429,7 @@ def from_symbol(cls, symbol): :raises: :exc:`ValueError` if the symbol is invalid. """ - if symbol.islower(): - return cls(PIECE_SYMBOLS.index(symbol), BLACK) - else: - return cls(PIECE_SYMBOLS.index(symbol.lower()), WHITE) + return cls(PIECE_SYMBOLS.index(symbol.lower()), symbol.isupper()) class Move: diff --git a/test.py b/test.py index eafb79b91..d3afbb940 100755 --- a/test.py +++ b/test.py @@ -156,6 +156,8 @@ def test_equality(self): d1 = chess.Piece(chess.BISHOP, chess.WHITE) d2 = chess.Piece(chess.BISHOP, chess.WHITE) + self.assertEqual(len(set([a, b, c, d1, d2])), 3) + self.assertEqual(a, d1) self.assertEqual(d1, a) self.assertEqual(d1, d2) @@ -179,12 +181,14 @@ def test_from_symbol(self): self.assertEqual(white_knight.color, chess.WHITE) self.assertEqual(white_knight.piece_type, chess.KNIGHT) self.assertEqual(white_knight.symbol(), "N") + self.assertEqual(str(white_knight), "N") black_queen = chess.Piece.from_symbol("q") self.assertEqual(black_queen.color, chess.BLACK) self.assertEqual(black_queen.piece_type, chess.QUEEN) self.assertEqual(black_queen.symbol(), "q") + self.assertEqual(str(black_queen), "q") class BoardTestCase(unittest.TestCase): From 53dced319f8addf4c7dc0f637d12be9015b93e8f Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sun, 13 Jan 2019 18:19:11 +0100 Subject: [PATCH 0253/1451] Test pawn attacks() --- chess/__init__.py | 6 ++---- test.py | 4 ++++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/chess/__init__.py b/chess/__init__.py index 563ffa204..ef007d7c7 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -666,10 +666,8 @@ def attacks_mask(self, square): bb_square = BB_SQUARES[square] if bb_square & self.pawns: - if bb_square & self.occupied_co[WHITE]: - return BB_PAWN_ATTACKS[WHITE][square] - else: - return BB_PAWN_ATTACKS[BLACK][square] + color = bool(bb_square & self.occupied_co[WHITE]) + return BB_PAWN_ATTACKS[color][square] elif bb_square & self.knights: return BB_KNIGHT_ATTACKS[square] elif bb_square & self.kings: diff --git a/test.py b/test.py index d3afbb940..88dc9df35 100755 --- a/test.py +++ b/test.py @@ -1026,6 +1026,10 @@ def test_attacks(self): self.assertNotIn(chess.C5, attacks) self.assertNotIn(chess.F4, attacks) + pawn_attacks = board.attacks(chess.B2) + self.assertIn(chess.A3, pawn_attacks) + self.assertNotIn(chess.B3, pawn_attacks) + self.assertFalse(board.attacks(chess.G1)) def test_clear(self): From c6be4bb481bdb70ec9a8c1523ae72d6f19b11ee4 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sun, 13 Jan 2019 18:31:10 +0100 Subject: [PATCH 0254/1451] Test pin() without king --- test.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test.py b/test.py index 88dc9df35..b13e27f67 100755 --- a/test.py +++ b/test.py @@ -1342,6 +1342,8 @@ def test_pin(self): self.assertEqual(board.pin(chess.WHITE, chess.D2), chess.BB_E1 | chess.BB_D2 | chess.BB_C3 | chess.BB_B4 | chess.BB_A5) + self.assertEqual(chess.Board(None).pin(chess.WHITE, chess.F7), chess.BB_ALL) + def test_pin_in_check(self): # The knight on the eighth rank is on the outer side of the rank attack. board = chess.Board("1n1R2k1/2b1qpp1/p3p2p/1p6/1P2Q2P/4PNP1/P4PB1/6K1 b - - 0 1") From b363edd30fc99e2c7213706ef85cd8e7427af28b Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sun, 13 Jan 2019 18:31:19 +0100 Subject: [PATCH 0255/1451] Simplify SquareSet.__str__() --- chess/__init__.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/chess/__init__.py b/chess/__init__.py index ef007d7c7..f013bf8c2 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -3704,17 +3704,12 @@ def __str__(self): for square in SQUARES_180: mask = BB_SQUARES[square] + builder.append("1" if self.mask & mask else ".") - if self.mask & mask: - builder.append("1") - else: - builder.append(".") - - if mask & BB_FILE_H: - if square != H1: - builder.append("\n") - else: + if not mask & BB_FILE_H: builder.append(" ") + elif square != H1: + builder.append("\n") return "".join(builder) From b0e263f9199461b8817b8dc34b2270462d0640a7 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sun, 13 Jan 2019 18:57:27 +0100 Subject: [PATCH 0256/1451] Condense validation in chess960_pos() --- chess/__init__.py | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/chess/__init__.py b/chess/__init__.py index f013bf8c2..de9870327 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -1058,28 +1058,16 @@ def chess960_pos(self): if self.promoted: return None - if popcount(self.bishops) != 4: - return None - if popcount(self.rooks) != 4: - return None - if popcount(self.knights) != 4: - return None - if popcount(self.queens) != 2: - return None - if popcount(self.kings) != 2: + # Piece counts. + brnqk = [self.bishops, self.rooks, self.knights, self.queens, self.kings] + if [popcount(pieces) for pieces in brnqk] != [4, 4, 4, 2, 2]: return None - if (BB_RANK_1 & self.knights) << 56 != BB_RANK_8 & self.knights: - return None - if (BB_RANK_1 & self.bishops) << 56 != BB_RANK_8 & self.bishops: - return None - if (BB_RANK_1 & self.rooks) << 56 != BB_RANK_8 & self.rooks: - return None - if (BB_RANK_1 & self.queens) << 56 != BB_RANK_8 & self.queens: - return None - if (BB_RANK_1 & self.kings) << 56 != BB_RANK_8 & self.kings: + # Symmetry. + if any((BB_RANK_1 & pieces) << 56 != BB_RANK_8 & pieces for pieces in brnqk): return None + # Algorithm from ChessX, src/database/bitboard.cpp, r2254. x = self.bishops & (2 + 8 + 32 + 128) if not x: return None @@ -1091,7 +1079,6 @@ def chess960_pos(self): bs2 = lsb(x) * 2 cc_pos += bs2 - # Algorithm from ChessX, src/database/bitboard.cpp, r2254. q = 0 qf = False n0 = 0 From 02bb48e05e78f5327fb11ef66cab434e237f4594 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 14 Jan 2019 13:00:41 +0100 Subject: [PATCH 0257/1451] Test chess.syzygy.dependencies() --- test.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test.py b/test.py index b13e27f67..4a83341fb 100755 --- a/test.py +++ b/test.py @@ -3635,6 +3635,9 @@ def test_normalize_tablename(self): def test_normalize_nnvbb(self): self.assertEqual(chess.syzygy.normalize_tablename("KNNvKBB"), "KBBvKNN") + def test_dependencies(self): + self.assertEqual(set(chess.syzygy.dependencies("KBNvK")), set(["KBvK", "KNvK"])) + def test_probe_pawnless_wdl_table(self): wdl = chess.syzygy.WdlTable("data/syzygy/regular/KBNvK.rtbw") wdl.init_table_wdl() From aef0acd8f81412bb3f4949ff5bde1afae5cd6fa9 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 14 Jan 2019 13:41:08 +0100 Subject: [PATCH 0258/1451] Fix Syzygy LRU cache (closes #352) --- chess/syzygy.py | 90 +++++++++++++++++++++++++++++++------------------ 1 file changed, 57 insertions(+), 33 deletions(-) diff --git a/chess/syzygy.py b/chess/syzygy.py index 0cfc8e3df..ca0a32e27 100644 --- a/chess/syzygy.py +++ b/chess/syzygy.py @@ -25,6 +25,7 @@ import chess + UINT64_BE = struct.Struct(">Q") UINT32 = struct.Struct("I") @@ -551,11 +552,14 @@ def __init__(self, path, *, variant=chess.Board): self.path = path self.variant = variant + self.write_lock = threading.RLock() self.initialized = False - self.lock = threading.Lock() self.fd = None self.data = None + self.read_condition = threading.Condition() + self.read_count = 0 + tablename, _ = os.path.splitext(os.path.basename(path)) self.key = normalize_tablename(tablename) self.mirrored_key = normalize_tablename(tablename, mirror=True) @@ -594,13 +598,14 @@ def __init__(self, path, *, variant=chess.Board): self.enc_type = 1 + j def init_mmap(self): - # Open fd. - if self.fd is None: - self.fd = os.open(self.path, os.O_RDONLY | os.O_BINARY if hasattr(os, "O_BINARY") else os.O_RDONLY) + with self.write_lock: + # Open fd. + if self.fd is None: + self.fd = os.open(self.path, os.O_RDONLY | os.O_BINARY if hasattr(os, "O_BINARY") else os.O_RDONLY) - # Open mmap. - if self.data is None: - self.data = mmap.mmap(self.fd, 0, access=mmap.ACCESS_READ) + # Open mmap. + if self.data is None: + self.data = mmap.mmap(self.fd, 0, access=mmap.ACCESS_READ) def check_magic(self, magic): return self.data[:min(len(self.data), len(magic))] == magic @@ -1008,17 +1013,22 @@ def read_uint16(self, data_ptr): return UINT16.unpack_from(self.data, data_ptr)[0] def close(self): - if self.data is not None: - self.data.close() + with self.write_lock: + with self.read_condition: + while self.read_count > 0: + self.read_condition.wait() - if self.fd is not None: - try: - os.close(self.fd) - except OSError: - pass + if self.data is not None: + self.data.close() - self.data = None - self.fd = None + if self.fd is not None: + try: + os.close(self.fd) + except OSError: + pass + + self.data = None + self.fd = None def __enter__(self): return self @@ -1030,7 +1040,7 @@ def __exit__(self, exc_type, exc_value, traceback): class WdlTable(Table): def init_table_wdl(self): - with self.lock: + with self.write_lock: self.init_mmap() if self.initialized: @@ -1169,6 +1179,16 @@ def setup_pieces_piece(self, p_data): self.tb_size[1] = self.calc_factors_piece(self.factor[1], order, self.norm[1]) def probe_wdl_table(self, board): + try: + with self.read_condition: + self.read_count += 1 + return self._probe_wdl_table(board) + finally: + with self.read_condition: + self.read_count -= 1 + self.read_condition.notify() + + def _probe_wdl_table(self, board): self.init_table_wdl() key = calc_key(board) @@ -1228,15 +1248,11 @@ def probe_wdl_table(self, board): return res - 2 - def close(self): - with self.lock: - super().close() - class DtzTable(Table): def init_table_dtz(self): - with self.lock: + with self.write_lock: self.init_mmap() if self.initialized: @@ -1334,6 +1350,16 @@ def init_table_dtz(self): self.initialized = True def probe_dtz_table(self, board, wdl): + try: + with self.read_condition: + self.read_count += 1 + return self._probe_dtz_table(board, wdl) + finally: + with self.read_condition: + self.read_count -= 1 + self.read_condition.notify() + + def _probe_dtz_table(self, board, wdl): self.init_table_dtz() key = calc_key(board) @@ -1435,10 +1461,6 @@ def setup_pieces_pawn_dtz(self, p_data, p_tb_size, f): self.files[f].factor = [0 for _ in range(TBPIECES)] self.tb_size[p_tb_size] = self.calc_factors_pawn(self.files[f].factor, order, order2, self.files[f].norm, f) - def close(self): - with self.lock: - super().close() - class Tablebase: """ @@ -1453,6 +1475,7 @@ def __init__(self, *, max_fds=128, VariantBoard=chess.Board): self.max_fds = max_fds self.lru = collections.deque() + self.lru_lock = threading.Lock() self.wdl = {} self.dtz = {} @@ -1461,14 +1484,15 @@ def _bump_lru(self, table): if self.max_fds is None: return - try: - self.lru.remove(table) - self.lru.appendleft(table) - except ValueError: - self.lru.appendleft(table) + with self.lru_lock: + try: + self.lru.remove(table) + self.lru.appendleft(table) + except ValueError: + self.lru.appendleft(table) - if len(self.lru) > self.max_fds: - self.lru.pop().close() + if len(self.lru) > self.max_fds: + self.lru.pop().close() def _open_table(self, hashtable, Table, path): table = Table(path, variant=self.variant) From d508be7c9bc0a168a4bb034240385cd8531b8376 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 14 Jan 2019 14:46:21 +0100 Subject: [PATCH 0259/1451] No longer ignore failed os.close() --- chess/syzygy.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/chess/syzygy.py b/chess/syzygy.py index ca0a32e27..a44ce61aa 100644 --- a/chess/syzygy.py +++ b/chess/syzygy.py @@ -1020,15 +1020,11 @@ def close(self): if self.data is not None: self.data.close() + self.data = None if self.fd is not None: - try: - os.close(self.fd) - except OSError: - pass - - self.data = None - self.fd = None + os.close(self.fd) + self.fd = None def __enter__(self): return self From 19bf1e6134f1a752f3df00ad8340d8657602e331 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 14 Jan 2019 14:52:38 +0100 Subject: [PATCH 0260/1451] More Syzygy test coverage --- test.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test.py b/test.py index 4a83341fb..44f183a14 100755 --- a/test.py +++ b/test.py @@ -3638,6 +3638,11 @@ def test_normalize_nnvbb(self): def test_dependencies(self): self.assertEqual(set(chess.syzygy.dependencies("KBNvK")), set(["KBvK", "KNvK"])) + def test_get_wdl_get_dtz(self): + with chess.syzygy.Tablebase() as tables: + board = chess.Board() + self.assertEqual(tables.get_dtz(board, tables.get_wdl(board)), None) + def test_probe_pawnless_wdl_table(self): wdl = chess.syzygy.WdlTable("data/syzygy/regular/KBNvK.rtbw") wdl.init_table_wdl() From 1f6ac4d63dd8164f5ba32d6cd8e83d026d870650 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 14 Jan 2019 15:03:06 +0100 Subject: [PATCH 0261/1451] Expand test_sf_analysis --- chess/engine.py | 4 ++++ test.py | 15 +++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 2f1811136..ac541ae2c 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -314,6 +314,10 @@ def pov(self, color): """Get the score from the point of view of the given *color*.""" return self.relative if self.turn == color else -self.relative + def is_mate(self): + """Tests if this is a mate score.""" + return self.relative.is_mate() + def __repr__(self): return "PovScore({}, {})".format(repr(self.relative), "WHITE" if self.turn else "BLACK") diff --git a/test.py b/test.py index 44f183a14..5f4866662 100755 --- a/test.py +++ b/test.py @@ -3241,13 +3241,20 @@ def test_sf_analysis(self): with chess.engine.SimpleEngine.popen_uci("stockfish", debug=True) as engine: board = chess.Board("8/6K1/1p1B1RB1/8/2Q5/2n1kP1N/3b4/4n3 w - - 0 1") limit = chess.engine.Limit(depth=20) - with engine.analysis(board, limit) as analysis: + analysis = engine.analysis(board, limit) + with analysis: + for info in iter(analysis.next, None): + if "score" in info and info["score"].is_mate(): + break + else: + self.fail("never found a mate score") + for info in analysis: if "score" in info and info["score"].white() >= chess.engine.Mate(+3): break - self.assertEqual(analysis.info["score"].relative, chess.engine.Mate(+3)) - self.assertEqual(analysis.multipv[0]["score"].black(), chess.engine.Mate(-3)) - engine.quit() + analysis.wait() + self.assertEqual(analysis.info["score"].relative, chess.engine.Mate(+3)) + self.assertEqual(analysis.multipv[0]["score"].black(), chess.engine.Mate(-3)) @catchAndSkip(FileNotFoundError, "need stockfish") def test_sf_quit(self): From 721546d1699805e7c2cb076e8a67656fabf42beb Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 14 Jan 2019 16:04:14 +0100 Subject: [PATCH 0262/1451] Cleanup string formatting and repr --- chess/__init__.py | 63 +++++++++++++++++++++++------------------------ chess/engine.py | 33 ++++++++++++------------- chess/gaviota.py | 4 +-- chess/pgn.py | 24 +++++++++--------- chess/svg.py | 6 ++--- chess/syzygy.py | 4 +-- chess/variant.py | 6 ++--- test.py | 2 +- 8 files changed, 70 insertions(+), 72 deletions(-) diff --git a/chess/__init__.py b/chess/__init__.py index de9870327..75174c42d 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -403,7 +403,7 @@ def __hash__(self): return hash(self.piece_type * (self.color + 1)) def __repr__(self): - return "Piece.from_symbol('{}')".format(self.symbol()) + return "Piece.from_symbol({!r})".format(self.symbol()) def __str__(self): return self.symbol() @@ -490,7 +490,7 @@ def __ne__(self, other): return NotImplemented def __repr__(self): - return "Move.from_uci('{}')".format(self.uci()) + return "Move.from_uci({!r})".format(self.uci()) def __str__(self): return self.uci() @@ -525,7 +525,7 @@ def from_uci(cls, uci): promotion = PIECE_SYMBOLS.index(uci[4]) return cls(SQUARE_NAMES.index(uci[0:2]), SQUARE_NAMES.index(uci[2:4]), promotion=promotion) else: - raise ValueError("expected uci string to be of length 4 or 5: {}".format(repr(uci))) + raise ValueError("expected uci string to be of length 4 or 5: {!r}".format(uci)) @classmethod def null(cls): @@ -897,12 +897,12 @@ def _set_board_fen(self, fen): # Compability with set_fen(). fen = fen.strip() if " " in fen: - raise ValueError("expected position part of fen, got multiple parts: {}".format(repr(fen))) + raise ValueError("expected position part of fen, got multiple parts: {!r}".format(fen)) # Ensure the FEN is valid. rows = fen.split("/") if len(rows) != 8: - raise ValueError("expected 8 rows in position part of fen: {}".format(repr(fen))) + raise ValueError("expected 8 rows in position part of fen: {!r}".format(fen)) # Validate each row. for row in rows: @@ -913,13 +913,13 @@ def _set_board_fen(self, fen): for c in row: if c in ["1", "2", "3", "4", "5", "6", "7", "8"]: if previous_was_digit: - raise ValueError("two subsequent digits in position part of fen: {}".format(repr(fen))) + raise ValueError("two subsequent digits in position part of fen: {!r}".format(fen)) field_sum += int(c) previous_was_digit = True previous_was_piece = False elif c == "~": if not previous_was_piece: - raise ValueError("~ not after piece in position part of fen: {}".format(repr(fen))) + raise ValueError("'~' not after piece in position part of fen: {!r}".format(fen)) previous_was_digit = False previous_was_piece = False elif c.lower() in PIECE_SYMBOLS: @@ -927,10 +927,10 @@ def _set_board_fen(self, fen): previous_was_digit = False previous_was_piece = True else: - raise ValueError("invalid character in position part of fen: {}".format(repr(fen))) + raise ValueError("invalid character in position part of fen: {!r}".format(fen)) if field_sum != 8: - raise ValueError("expected 8 columns per row in position part of fen: {}".format(repr(fen))) + raise ValueError("expected 8 columns per row in position part of fen: {!r}".format(fen)) # Clear the board. self._clear_board() @@ -978,7 +978,7 @@ def set_piece_map(self, pieces): def _set_chess960_pos(self, sharnagl): if not 0 <= sharnagl <= 959: - raise ValueError("chess960 position index not 0 <= {} <= 959".format(repr(sharnagl))) + raise ValueError("chess960 position index not 0 <= {:d} <= 959".format(sharnagl)) # See http://www.russellcottrell.com/Chess/Chess960.htm for # a description of the algorithm. @@ -1123,7 +1123,7 @@ def chess960_pos(self): return None def __repr__(self): - return "{}('{}')".format(type(self).__name__, self.board_fen()) + return "{}({!r})".format(type(self).__name__, self.board_fen()) def __str__(self): builder = [] @@ -2115,29 +2115,29 @@ def set_fen(self, fen): # Ensure there are six parts. parts = fen.split() if len(parts) != 6: - raise ValueError("fen string should consist of 6 parts: {}".format(repr(fen))) + raise ValueError("fen string should consist of 6 parts: {!r}".format(fen)) # Check that the turn part is valid. if not parts[1] in ["w", "b"]: - raise ValueError("expected 'w' or 'b' for turn part of fen: {}".format(repr(fen))) + raise ValueError("expected 'w' or 'b' for turn part of fen: {!r}".format(fen)) # Check that the castling part is valid. if not FEN_CASTLING_REGEX.match(parts[2]): - raise ValueError("invalid castling part in fen: {}".format(repr(fen))) + raise ValueError("invalid castling part in fen: {!r}".format(fen)) # Check that the en passant part is valid. if parts[3] != "-": if parts[3] not in SQUARE_NAMES: - raise ValueError("invalid en passant part in fen: {}".format(repr(fen))) + raise ValueError("invalid en passant part in fen: {!r}".format(fen)) # Check that the half-move part is valid. if int(parts[4]) < 0: - raise ValueError("half-move clock can not be negative: {}".format(repr(fen))) + raise ValueError("half-move clock can not be negative: {!r}".format(fen)) # Check that the full-move number part is valid. # 0 is allowed for compability, but later replaced with 1. if int(parts[5]) < 0: - raise ValueError("full-move number must be positive: {}".format(repr(fen))) + raise ValueError("full-move number must be positive: {!r}".format(fen)) # Validate the board part and set it. self._set_board_fen(parts[0]) @@ -2170,7 +2170,7 @@ def _set_castling_fen(self, castling_fen): return if not FEN_CASTLING_REGEX.match(castling_fen): - raise ValueError("invalid castling fen: {}".format(repr(castling_fen))) + raise ValueError("invalid castling fen: {!r}".format(castling_fen)) self.castling_rights = BB_EMPTY @@ -2461,7 +2461,7 @@ def set_epd(self, epd): # Split into 4 or 5 parts. parts = epd.strip().rstrip(";").split(None, 4) if len(parts) < 4: - raise ValueError("epd should consist of at least 4 parts: {}".format(repr(epd))) + raise ValueError("epd should consist of at least 4 parts: {!r}".format(epd)) # Parse ops. if len(parts) > 4: @@ -2629,7 +2629,7 @@ def parse_san(self, san): elif san in ["O-O-O", "O-O-O+", "O-O-O#"]: return next(move for move in self.generate_castling_moves() if self.is_queenside_castling(move)) except StopIteration: - raise ValueError("illegal san: {} in {}".format(repr(san), self.fen())) + raise ValueError("illegal san: {!r} in {}".format(san, self.fen())) # Match normal moves. match = SAN_REGEX.match(san) @@ -2638,7 +2638,7 @@ def parse_san(self, san): if san in ["--", "Z0"]: return Move.null() - raise ValueError("invalid san: {}".format(repr(san))) + raise ValueError("invalid san: {!r}".format(san)) # Get target square. to_square = SQUARE_NAMES.index(match.group(4)) @@ -2670,12 +2670,12 @@ def parse_san(self, san): continue if matched_move: - raise ValueError("ambiguous san: {} in {}".format(repr(san), self.fen())) + raise ValueError("ambiguous san: {!r} in {}".format(san, self.fen())) matched_move = move if not matched_move: - raise ValueError("illegal san: {} in {}".format(repr(san), self.fen())) + raise ValueError("illegal san: {!r} in {}".format(san, self.fen())) return matched_move @@ -2726,7 +2726,7 @@ def parse_uci(self, uci): move = self._from_chess960(self.chess960, move.from_square, move.to_square, move.promotion, move.drop) if not self.is_legal(move): - raise ValueError("illegal uci: {} in {}".format(repr(uci), self.fen())) + raise ValueError("illegal uci: {!r} in {}".format(uci, self.fen())) return move @@ -2758,14 +2758,14 @@ def parse_xboard(self, xboard): if xboard == "@@@@": return Move.null() elif "," in xboard: - raise ValueError("unsupported multi-leg xboard move: {}".format(repr(xboard))) + raise ValueError("unsupported multi-leg xboard move: {!r}".format(xboard)) try: move = Move.from_uci(xboard) move = self._to_chess960(move) move = self._from_chess960(self.chess960, move.from_square, move.to_square, move.promotion, move.drop) if not self.is_legal(move): - raise ValueError("illegal xboard move: {} in {}".format(repr(xboard), self.fen())) + raise ValueError("illegal xboard move: {!r} in {}".format(xboard, self.fen())) return move except ValueError: pass @@ -2773,7 +2773,7 @@ def parse_xboard(self, xboard): try: return self.parse_san(xboard) except ValueError: - raise ValueError("invalid or illegal xboard move: {} in {}".format(repr(xboard), self.fen())) + raise ValueError("invalid or illegal xboard move: {!r} in {}".format(xboard, self.fen())) def push_xboard(self, xboard): move = self.parse_xboard(xboard) @@ -3271,9 +3271,9 @@ def _transposition_key(self): def __repr__(self): if not self.chess960: - return "{}('{}')".format(type(self).__name__, self.fen()) + return "{}({!r})".format(type(self).__name__, self.fen()) else: - return "{}('{}', chess960=True)".format(type(self).__name__, self.fen()) + return "{}({!r}, chess960=True)".format(type(self).__name__, self.fen()) def _repr_svg_(self): import chess.svg @@ -3382,8 +3382,7 @@ def __repr__(self): builder.append(self.board.uci(move)) sans = ", ".join(builder) - - return "".format(hex(id(self)), sans) + return "".format(id(self), sans) class LegalMoveGenerator: @@ -3406,7 +3405,7 @@ def __contains__(self, move): def __repr__(self): sans = ", ".join(self.board.san(move) for move in self) - return "".format(hex(id(self)), sans) + return "".format(id(self), sans) class SquareSet(collections.abc.MutableSet): diff --git a/chess/engine.py b/chess/engine.py index ac541ae2c..92fd9c49a 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -213,23 +213,23 @@ def parse(self, value): try: value = int(value) except ValueError: - raise EngineError("expected integer for spin option {}, got: {}".format(self.name, repr(value))) + raise EngineError("expected integer for spin option {!r}, got: {!r}".format(self.name, value)) if self.min is not None and value < self.min: - raise EngineError("expected value for option {} to be at least {}, got: {}".format(self.name, self.min, value)) + raise EngineError("expected value for option {!r} to be at least {}, got: {}".format(self.name, self.min, value)) if self.max is not None and self.max < value: - raise EngineError("expected value for option {} to be at most {}, got: {}".format(self.name, self.max, value)) + raise EngineError("expected value for option {!r} to be at most {}, got: {}".format(self.name, self.max, value)) return value elif self.type == "combo": value = str(value) if value not in (self.var or []): - raise EngineError("invalid value for combo option {}, got: {} (available: {})".format(self.name, value, ", ".join(self.var))) + raise EngineError("invalid value for combo option {!r}, got: {} (available: {})".format(self.name, value, ", ".join(self.var))) return value elif self.type in ["button", "reset", "save"]: return None elif self.type in ["string", "file", "path"]: value = str(value) if "\n" in value or "\r" in value: - raise EngineError("invalid line-break in string option {}".format(self.name)) + raise EngineError("invalid line-break in string option {!r}".format(self.name)) return value else: raise EngineError("unknown option type: {}", self.type) @@ -258,7 +258,7 @@ def __init__(self, *, time=None, depth=None, nodes=None, mate=None, white_clock= def __repr__(self): return "{}({})".format( type(self).__name__, - ", ".join("{}={}".format(attr, repr(getattr(self, attr))) + ", ".join("{}={!r}".format(attr, getattr(self, attr)) for attr in ["time", "depth", "nodes", "mate", "white_clock", "black_clock", "white_inc", "black_inc", "remaining_moves"] if getattr(self, attr) is not None)) @@ -273,7 +273,7 @@ def __init__(self, move, ponder, info=None, draw_offered=False): self.draw_offered = draw_offered def __repr__(self): - return "<{} at {} (move={}, ponder={}, info={}, draw_offered={})>".format(type(self).__name__, hex(id(self)), self.move, self.ponder, self.info, self.draw_offered) + return "<{} at {:#x} (move={}, ponder={}, info={}, draw_offered={})>".format(type(self).__name__, id(self), self.move, self.ponder, self.info, self.draw_offered) class Info(_IntFlag): @@ -319,7 +319,7 @@ def is_mate(self): return self.relative.is_mate() def __repr__(self): - return "PovScore({}, {})".format(repr(self.relative), "WHITE" if self.turn else "BLACK") + return "PovScore({!r}, {})".format(self.relative, "WHITE" if self.turn else "BLACK") def __str__(self): return str(self.relative) @@ -434,10 +434,10 @@ def score(self, *, mate_score=None): return self.cp def __str__(self): - return "+{}".format(self.cp) if self.cp > 0 else str(self.cp) + return "+{:d}".format(self.cp) if self.cp > 0 else str(self.cp) def __repr__(self): - return "Cp({})".format(str(self)) + return "Cp({})".format(self) def __neg__(self): return Cp(-self.cp) @@ -470,7 +470,7 @@ def __str__(self): return "#+{}".format(self.moves) if self.moves > 0 else "#-{}".format(abs(self.moves)) def __repr__(self): - return ("Mate(+{})" if self.moves > 0 else "Mate(-{})").format(abs(self.moves)) + return "Mate({})".format(str(self).lstrip("#")) def __neg__(self): return MateGiven if not self.moves else Mate(-self.moves) @@ -657,7 +657,7 @@ def previous_command_finished(_): return (yield from command.result) def __repr__(self): - pid = self.transport.get_pid() if self.transport is not None else None + pid = self.transport.get_pid() if self.transport is not None else "?" return "<{} (pid={})>".format(type(self).__name__, pid) @abc.abstractmethod @@ -876,7 +876,7 @@ def engine_terminated(self, engine, exc): pass def __repr__(self): - return "<{} at {} (state={}, result={}, finished={}>".format(type(self).__name__, hex(id(self)), self.state, self.result, self.finished) + return "<{} at {:#x} (state={}, result={}, finished={}>".format(type(self).__name__, id(self), self.state, self.result, self.finished) class UciProtocol(EngineProtocol): @@ -1439,7 +1439,7 @@ def __copy__(self): return self.copy() def __repr__(self): - return "{}({})".format(type(self).__name__, dict(self.items())) + return "{}({!r})".format(type(self).__name__, dict(self.items())) class XBoardProtocol(EngineProtocol): @@ -2227,9 +2227,8 @@ def __exit__(self, a, b, c): self.close() def __repr__(self): - # This happens to be threadsafe. - pid = self.transport.get_pid() - return "<{} (pid={}>".format(type(self).__name__, pid) + pid = self.transport.get_pid() # This happens to be thread-safe. + return "<{} (pid={})>".format(type(self).__name__, pid) class SimpleAnalysisResult: diff --git a/chess/gaviota.py b/chess/gaviota.py index 20119412d..16c5e06a7 100644 --- a/chess/gaviota.py +++ b/chess/gaviota.py @@ -1536,7 +1536,7 @@ def add_directory(self, directory): """ directory = os.path.abspath(directory) if not os.path.isdir(directory): - raise IOError("not a directory: {}".format(repr(directory))) + raise IOError("not a directory: {!r}".format(directory)) for tbfile in fnmatch.filter(os.listdir(directory), "*.gtb.cp4"): self.available_tables[os.path.basename(tbfile).replace(".gtb.cp4", "")] = os.path.join(directory, tbfile) @@ -1937,7 +1937,7 @@ def __init__(self, libgtb): def add_directory(self, directory): if not os.path.isdir(directory): - raise IOError("not a directory: {}".format(repr(directory))) + raise IOError("not a directory: {!r}".format(directory)) self.paths.append(directory) self._tb_restart() diff --git a/chess/pgn.py b/chess/pgn.py index a1f6d5f18..2e2f700ab 100644 --- a/chess/pgn.py +++ b/chess/pgn.py @@ -392,8 +392,8 @@ def __str__(self): return self.accept(StringExporter(columns=None)) def __repr__(self): - return "".format( - hex(id(self)), + return "".format( + id(self), self.parent.board().fullmove_number, "." if self.parent.board().turn == chess.WHITE else "...", self.san()) @@ -491,10 +491,10 @@ def without_tag_roster(cls): return cls(headers={}) def __repr__(self): - return "".format( - hex(id(self)), - repr(self.headers.get("White", "?")), - repr(self.headers.get("Black", "?")), + return "".format( + id(self), + self.headers.get("White", "?"), + self.headers.get("Black", "?"), self.headers.get("Date", "????.??.??")) @@ -549,9 +549,9 @@ def __setitem__(self, key, value): if key in TAG_ROSTER: self._tag_roster[key] = value elif not TAG_NAME_REGEX.match(key): - raise ValueError("non-alphanumeric pgn header tag: {}".format(repr(key))) + raise ValueError("non-alphanumeric pgn header tag: {!r}".format(key)) elif "\n" in value or "\r" in value: - raise ValueError("line break in pgn header {}: {}".format(key, repr(value))) + raise ValueError("line break in pgn header {}: {!r}".format(key, value)) else: self._others[key] = value @@ -586,7 +586,7 @@ def __copy__(self): def __repr__(self): return "{}({})".format( type(self).__name__, - ", ".join("{}={}".format(key, repr(value)) for key, value in self.items())) + ", ".join("{}={!r}".format(key, value) for key, value in self.items())) class Mainline: @@ -619,7 +619,7 @@ def __str__(self): return self.accept(StringExporter(columns=None)) def __repr__(self): - return "".format(hex(id(self)), self.accept(StringExporter(columns=None, comments=False))) + return "".format(id(self), self.accept(StringExporter(columns=None, comments=False))) class ReverseMainline: @@ -647,7 +647,7 @@ def __reversed__(self): return Mainline(self.stop, self.f) def __repr__(self): - return "".format(hex(id(self)), " ".join(ReverseMainline(self.stop, lambda node: node.move.uci()))) + return "".format(id(self), " ".join(ReverseMainline(self.stop, lambda node: node.move.uci()))) class BaseVisitor: @@ -1013,7 +1013,7 @@ def result(self): return None def __repr__(self): - return "".format(hex(id(self))) + return "".format(id(self)) def __str__(self): return self.__repr__() diff --git a/chess/svg.py b/chess/svg.py index 78f51855b..bc84d6f58 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -75,7 +75,7 @@ def _svg(viewbox, size): "xmlns": "http://www.w3.org/2000/svg", "version": "1.1", "xmlns:xlink": "http://www.w3.org/1999/xlink", - "viewBox": "0 0 %d %d" % (viewbox, viewbox), + "viewBox": "0 0 {0:d} {0:d}".format(viewbox), }) if size is not None: @@ -199,8 +199,8 @@ def board(board=None, *, squares=None, flipped=False, coordinates=True, lastmove piece = board.piece_at(square) if piece: ET.SubElement(svg, "use", { - "xlink:href": "#%s-%s" % (chess.COLOR_NAMES[piece.color], chess.PIECE_NAMES[piece.piece_type]), - "transform": "translate(%d, %d)" % (x, y), + "xlink:href": "#{}-{}".format(chess.COLOR_NAMES[piece.color], chess.PIECE_NAMES[piece.piece_type]), + "transform": "translate({:d}, {:d})".format(x, y), }) # Render selected squares. diff --git a/chess/syzygy.py b/chess/syzygy.py index a44ce61aa..5ef65604a 100644 --- a/chess/syzygy.py +++ b/chess/syzygy.py @@ -1043,7 +1043,7 @@ def init_table_wdl(self): return if not self.check_magic(self.variant.tbw_magic) and (self.has_pawns or self.variant.pawnless_tbw_magic is None or not self.check_magic(self.variant.pawnless_tbw_magic)): - raise IOError("invalid magic header: ensure {} is a valid syzygy tablebase file".format(self.path)) + raise IOError("invalid magic header: ensure {!r} is a valid syzygy tablebase file".format(self.path)) self.tb_size = [0 for _ in range(8)] self.size = [0 for _ in range(8 * 3)] @@ -1255,7 +1255,7 @@ def init_table_dtz(self): return if not self.check_magic(self.variant.tbz_magic) and (self.has_pawns or self.variant.pawnless_tbz_magic is None or not self.check_magic(self.variant.pawnless_tbz_magic)): - raise IOError("invalid magic header: ensure {} is a valid syzygy tablebase file".format(self.path)) + raise IOError("invalid magic header: ensure {!r} is a valid syzygy tablebase file".format(self.path)) self.factor = [0 for _ in range(TBPIECES)] self.norm = [0 for _ in range(self.num)] diff --git a/chess/variant.py b/chess/variant.py index 7f4afc84c..64a93111e 100644 --- a/chess/variant.py +++ b/chess/variant.py @@ -567,8 +567,8 @@ def set_fen(self, fen): def epd(self, shredder=False, en_passant="legal", promoted=None, **operations): epd = [super().epd(shredder=shredder, en_passant=en_passant, promoted=promoted), - "%d+%d" % (max(self.remaining_checks[chess.WHITE], 0), - max(self.remaining_checks[chess.BLACK], 0))] + "{:d}+{:d}".format(max(self.remaining_checks[chess.WHITE], 0), + max(self.remaining_checks[chess.BLACK], 0))] if operations: epd.append(self._epd_operations(operations)) return " ".join(epd) @@ -826,7 +826,7 @@ def board_fen(self, promoted=None): def epd(self, shredder=False, en_passant="legal", promoted=None, **operations): epd = super().epd(shredder=shredder, en_passant=en_passant, promoted=promoted) board_part, info_part = epd.split(" ", 1) - return "%s[%s%s] %s" % (board_part, str(self.pockets[chess.WHITE]).upper(), str(self.pockets[chess.BLACK]), info_part) + return "{}[{}{}] {}".format(board_part, str(self.pockets[chess.WHITE]).upper(), str(self.pockets[chess.BLACK]), info_part) def copy(self, stack=True): board = super().copy(stack=stack) diff --git a/test.py b/test.py index 5f4866662..17b61660e 100755 --- a/test.py +++ b/test.py @@ -4342,7 +4342,7 @@ def test_uci_info_handler(self): engine.info_handlers.append(info_handler) mock.expect("setoption name UCI_Variant value crazyhouse") - mock.expect("position fen %s" % fen) + mock.expect("position fen {}".format(fen)) engine.position(board) mock.expect("go", ("info pv B@f4 P@g3", "bestmove B@f4")) From 16253156fa18b04de06ad1023782b303b969d496 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 14 Jan 2019 16:15:52 +0100 Subject: [PATCH 0263/1451] Test and fix negative mate scores --- chess/engine.py | 6 +++--- test.py | 8 ++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 92fd9c49a..7882287c4 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -304,11 +304,11 @@ def __init__(self, relative, turn): def white(self): """Get the score from White's point of view.""" - return self.relative if self.turn else -self.relative + return self.pov(chess.WHITE) def black(self): """Get the score from Black's point of view.""" - return -self.relative if self.turn else self.relative + return self.pov(chess.BLACK) def pov(self, color): """Get the score from the point of view of the given *color*.""" @@ -464,7 +464,7 @@ def score(self, *, mate_score=None): elif self.moves > 0: return mate_score - self.moves else: - return -mate_score + self.moves + return -mate_score - self.moves def __str__(self): return "#+{}".format(self.moves) if self.moves > 0 else "#-{}".format(abs(self.moves)) diff --git a/test.py b/test.py index 17b61660e..b711972d3 100755 --- a/test.py +++ b/test.py @@ -3191,22 +3191,26 @@ def test_score_ordering(self): for i, a in enumerate(order): for j, b in enumerate(order): - self.assertEqual(i < j, a < b, "{} < {}".format(a, b)) + self.assertEqual(i < j, a < b, "{!r} < {!r}".format(a, b)) + self.assertEqual(i == j, a == b, "{!r} == {!r}".format(a, b)) self.assertEqual(i <= j, a <= b) - self.assertEqual(i == j, a == b) self.assertEqual(i != j, a != b) self.assertEqual(i > j, a > b) self.assertEqual(i >= j, a >= b) + self.assertEqual(i < j, a.score(mate_score=100000) < b.score(mate_score=100000)) def test_score(self): # Negation. self.assertEqual(-chess.engine.Cp(+20), chess.engine.Cp(-20)) self.assertEqual(-chess.engine.Mate(+4), chess.engine.Mate(-4)) + self.assertEqual(-chess.engine.Mate(-0), chess.engine.MateGiven) + self.assertEqual(-chess.engine.MateGiven, chess.engine.Mate(-0)) # Score. self.assertEqual(chess.engine.Cp(-300).score(), -300) self.assertEqual(chess.engine.Mate(+5).score(), None) self.assertEqual(chess.engine.Mate(+5).score(mate_score=100000), 99995) + self.assertEqual(chess.engine.Mate(-7).score(mate_score=100000), -99993) # Mate. self.assertEqual(chess.engine.Cp(-300).mate(), None) From 6ae25418e09bebabba02ed584f53978e1a46c9d8 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 14 Jan 2019 16:19:27 +0100 Subject: [PATCH 0264/1451] Test AnalysisResult korking --- test.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test.py b/test.py index b711972d3..2413e394e 100755 --- a/test.py +++ b/test.py @@ -3260,6 +3260,12 @@ def test_sf_analysis(self): self.assertEqual(analysis.info["score"].relative, chess.engine.Mate(+3)) self.assertEqual(analysis.multipv[0]["score"].black(), chess.engine.Mate(-3)) + # Exhaust remaining information. + for info in analysis: + pass + for info in analysis: + self.fail("all info should have been consumed") + @catchAndSkip(FileNotFoundError, "need stockfish") def test_sf_quit(self): with chess.engine.SimpleEngine.popen_uci("stockfish", debug=True) as engine: From 1e47f7b5deeaf6f6294ce494d933fb2c326df2c5 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 14 Jan 2019 16:23:31 +0100 Subject: [PATCH 0265/1451] Test that ping after engine quit raises --- test.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test.py b/test.py index 2413e394e..6176ef542 100755 --- a/test.py +++ b/test.py @@ -3268,9 +3268,14 @@ def test_sf_analysis(self): @catchAndSkip(FileNotFoundError, "need stockfish") def test_sf_quit(self): - with chess.engine.SimpleEngine.popen_uci("stockfish", debug=True) as engine: + engine = chess.engine.SimpleEngine.popen_uci("stockfish", debug=True) + + with engine: engine.quit() + with self.assertRaises(chess.engine.EngineTerminatedError), engine: + engine.ping() + @catchAndSkip(FileNotFoundError, "need crafty") def test_crafty_play_to_mate(self): logging.disable(logging.WARNING) From 5602ed6e7f85671814cc8bb42285db65e7c7d0de Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 14 Jan 2019 16:29:24 +0100 Subject: [PATCH 0266/1451] Let SimpleAnalysisResult.__iter__ call __aiter__ --- chess/engine.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index 7882287c4..3a8d764a9 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -2276,6 +2276,8 @@ def next(self): return future.result() def __iter__(self): + with self.simple_engine._not_shut_down(): + self.simple_engine.protocol.loop.call_soon_threadsafe(self.inner.__aiter__) return self def __next__(self): From cbe7732288ffc10a98a6ddf03c6a2e307a6f09e8 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 14 Jan 2019 18:59:40 +0100 Subject: [PATCH 0267/1451] Test PGN header tag with paren (#353) --- data/pgn/stockfish-learning.pgn | 66 +++++++++++++++++++++++++++++++++ test.py | 6 +++ 2 files changed, 72 insertions(+) create mode 100644 data/pgn/stockfish-learning.pgn diff --git a/data/pgn/stockfish-learning.pgn b/data/pgn/stockfish-learning.pgn new file mode 100644 index 000000000..6504c8593 --- /dev/null +++ b/data/pgn/stockfish-learning.pgn @@ -0,0 +1,66 @@ +[Event "?"] +[Site "?"] +[Date "2019.01.14"] +[Round "1"] +[White "Stockfish-reference"] +[Black "Stockfish-learning"] +[Result "1-0"] +[ECO "B00"] +[Opening "St. George (Baker) defense"] +[GameDuration "00:40:54"] +[GameEndTime "2019-01-14T07:27:30.802 PST"] +[GameStartTime "2019-01-14T06:46:35.929 PST"] +[PlyCount "154"] +[Termination "adjudication"] +[TimeControl "900+5"] + +1. e4 {book} a6 {book} 2. c4 {book} e5 {0.00/31 48s} 3. Nf3 {+0.69/29 36s} +Nc6 {0.00/31 12s} 4. Nc3 {+0.56/28 14s} Nf6 {0.00/30 9.3s} 5. a3 {+0.50/28 29s} +d6 {+0.12/28 21s} 6. Be2 {+0.43/26 16s} Bg4 {+0.14/25 8.8s} 7. d3 {+0.43/28 34s} +g6 {0.00/28 14s} 8. O-O {+0.30/28 20s} Bg7 {+0.15/31 47s} 9. b4 {+0.24/27 13s} +O-O {0.00/26 14s} 10. Rb1 {+0.45/30 24s} Nd7 {-0.03/29 35s} +11. Bg5 {+0.68/25 6.8s} Bf6 {0.00/27 7.9s} 12. Be3 {+0.55/29 16s} +Bg7 {-0.05/30 44s} 13. Bg5 {+0.61/29 26s} Bf6 {0.00/32 8.7s} +14. Be3 {+0.51/29 11s} Bg7 {0.00/34 16s} 15. Qd2 {+0.50/30 22s} a5 {0.00/32 67s} +16. b5 {+0.45/29 7.9s} Bxf3 {0.00/32 6.0s} 17. Bxf3 {+0.51/32 9.4s} +Nd4 {0.00/34 8.9s} 18. Bd1 {+0.66/31 9.5s} Nc5 {-0.09/35 18s} +19. Bxd4 {+0.65/31 12s} exd4 {-0.32/34 37s} 20. Na4 {+0.83/32 29s} +Nd7 {-0.27/32 28s} 21. f4 {+0.78/32 27s} h5 {-0.35/32 21s} +22. Bf3 {+0.85/29 12s} Rb8 {-0.33/29 24s} 23. g3 {+0.81/30 30s} +b6 {-0.48/29 21s} 24. Bg2 {+0.88/29 21s} Nf6 {-0.58/33 46s} +25. Nb2 {+1.03/34 9.1s} Ng4 {-0.58/36 19s} 26. Nd1 {+1.03/34 11s} +Qe7 {-0.58/38 16s} 27. Bf3 {+1.03/37 13s} Nf6 {-0.58/38 7.6s} +28. Nf2 {+1.03/38 12s} Nd7 {-0.58/39 26s} 29. Bg2 {+1.03/38 17s} +Nc5 {-0.58/38 11s} 30. h4 {+1.03/38 15s} Rbe8 {-0.67/38 71s} +31. Kh2 {+1.03/39 15s} Qd8 {-0.67/34 18s} 32. Rbe1 {+1.03/35 12s} +Nd7 {-0.77/38 125s} 33. Qd1 {+1.03/36 16s} Nc5 {-0.87/34 30s} +34. Nh3 {+1.03/37 13s} Kh8 {-0.77/30 14s} 35. Qe2 {+1.03/37 25s} +Nd7 {-0.77/33 5.2s} 36. Ng5 {+1.13/35 44s} Bh6 {-0.77/33 7.8s} +37. Qf2 {+1.24/34 15s} Bg7 {-0.77/34 7.1s} 38. Rd1 {+1.42/31 14s} +Nc5 {-0.87/34 46s} 39. Kh1 {+1.33/32 24s} Kg8 {-0.77/32 9.1s} +40. Nf3 {+1.38/30 9.7s} Nb3 {-0.77/30 4.8s} 41. Bh3 {+1.68/28 14s} +Kh8 {-1.10/31 53s} 42. Rg1 {+1.86/32 46s} Nc5 {-1.01/27 5.0s} +43. g4 {+1.73/30 12s} Qf6 {-1.04/28 18s} 44. Ng5 {+1.78/31 21s} +hxg4 {-1.53/28 8.1s} 45. Rxg4 {+1.87/29 9.6s} Bh6 {-1.45/26 6.1s} +46. Qg3 {+1.86/29 15s} Rg8 {-1.50/29 15s} 47. Rg1 {+1.71/32 44s} +Rg7 {-1.32/29 4.7s} 48. h5 {+1.86/29 6.2s} Kg8 {-1.41/28 6.2s} +49. hxg6 {+1.46/32 27s} fxg6 {-1.25/28 7.8s} 50. Qh4 {+1.61/33 20s} +Bxg5 {-1.42/29 6.6s} 51. Rxg5 {+1.61/35 7.0s} Ne6 {-1.19/28 4.8s} +52. Bxe6+ {+1.61/38 10.0s} Rxe6 {-1.39/32 13s} 53. Kg2 {+1.61/39 15s} +Rh7 {-1.49/29 8.5s} 54. Qg4 {+1.61/41 9.0s} Kf8 {-2.00/31 19s} +55. Rh1 {+2.24/32 7.8s} Rxh1 {-2.01/28 2.5s} 56. Kxh1 {+2.36/35 12s} +Qh8+ {-1.93/29 5.9s} 57. Kg2 {+2.41/36 9.8s} Kf7 {-1.57/29 1.9s} +58. Qf3 {+2.46/37 14s} Rf6 {-2.27/33 13s} 59. e5 {+2.50/33 9.1s} +dxe5 {-2.25/30 2.5s} 60. Qd5+ {+2.87/33 18s} Kg7 {-2.43/30 4.3s} +61. Qd7+ {+2.92/33 7.1s} Rf7 {-2.55/31 5.7s} 62. Qe6 {+3.02/32 8.4s} +Qh6 {-2.48/28 3.7s} 63. Qxe5+ {+3.14/32 7.6s} Kh7 {-2.24/27 1.7s} +64. Qe4 {+3.13/35 13s} Qg7 {-2.39/30 8.2s} 65. Re5 {+3.45/32 6.9s} +Qf8 {-2.65/30 10s} 66. Re8 {+3.78/31 9.5s} Qc5 {-2.65/30 5.5s} +67. Kg3 {+3.99/31 7.7s} a4 {-2.92/31 5.2s} 68. Rd8 {+4.10/31 7.1s} +Re7 {-3.27/29 5.0s} 69. Qxd4 {+4.37/32 9.1s} Qxd4 {-3.28/27 2.5s} +70. Rxd4 {+4.41/29 7.3s} Kg7 {-3.62/31 7.5s} 71. Re4 {+5.72/31 7.3s} +Rd7 {-4.53/29 5.0s} 72. d4 {+6.17/33 8.0s} Rd8 {-5.41/32 5.0s} +73. Kg4 {+6.31/39 6.0s} Kf7 {-5.92/29 5.0s} 74. d5 {+7.67/39 36s} +Rh8 {-6.63/29 5.0s} 75. Re6 {+7.97/28 6.8s} Rh1 {-7.12/26 5.0s} +76. Rc6 {+8.52/27 8.6s} Rg1+ {-7.80/24 5.0s} 77. Kf3 {+8.86/27 6.4s} +Rf1+ {-8.11/24 5.0s, White wins by adjudication} 1-0 diff --git a/test.py b/test.py index 6176ef542..51f8eceef 100755 --- a/test.py +++ b/test.py @@ -2015,6 +2015,12 @@ def test_promotion_without_equals(self): last_node = game.end() self.assertEqual(last_node.move.uci(), "b2b1q") + def test_header_with_paren(self): + with open("data/pgn/stockfish-learning.pgn") as pgn: + game = chess.pgn.read_game(pgn) + self.assertEqual(game.headers["Opening"], "St. George (Baker) defense") + self.assertEqual(game.end().board(), chess.Board("8/2p2k2/1pR3p1/1P1P4/p1P2P2/P4K2/8/5r2 w - - 7 78")) + def test_chess960_without_fen(self): pgn = io.StringIO(textwrap.dedent("""\ [Variant "Chess960"] From e3445ca9e88b0731ae67d46c3a7be93b90e3b83d Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 14 Jan 2019 19:07:05 +0100 Subject: [PATCH 0268/1451] Explicitly mention that Python 3.7 is supported --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 581976928..5a698de52 100755 --- a/setup.py +++ b/setup.py @@ -97,6 +97,7 @@ def extra_dependencies(): "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3 :: Only", "Topic :: Games/Entertainment :: Board Games", "Topic :: Software Development :: Libraries :: Python Modules", From 474193f37f14f323410f8bee16b08d57bc140f48 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 14 Jan 2019 19:15:55 +0100 Subject: [PATCH 0269/1451] Let chess.pgn.read_game() ignore BOM (#353) --- chess/pgn.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chess/pgn.py b/chess/pgn.py index 2e2f700ab..5a46419e0 100644 --- a/chess/pgn.py +++ b/chess/pgn.py @@ -1077,7 +1077,7 @@ def read_game(handle, *, Visitor=GameCreator): managed_headers = None # Ignore leading empty lines and comments. - line = handle.readline() + line = handle.readline().lstrip("\ufeff") while line.isspace() or line.startswith("%") or line.startswith(";"): line = handle.readline() From 0ba4186327fac512c04465a102c01bec8ac259b2 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 16 Jan 2019 17:00:19 +0100 Subject: [PATCH 0270/1451] Expose SimpleEngine.shutdown_event --- chess/engine.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 3a8d764a9..368c61630 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -2114,7 +2114,7 @@ def __init__(self, transport, protocol, *, timeout=10.0): self._shutdown_lock = threading.Lock() self._shutdown = False - self._shutdown_event = asyncio.Event() + self.shutdown_event = asyncio.Event(loop=self.protocol.loop) def _timeout_for(self, limit): if self.timeout is None or limit is None or limit.time is None: @@ -2185,11 +2185,13 @@ def quit(self): return future.result() def close(self): - """Closes the transport and the background event loop.""" + """ + Closes the transport and the background event loop as soon as possible. + """ with self._shutdown_lock: if not self._shutdown: self._shutdown = True - self.protocol.loop.call_soon_threadsafe(lambda: (self.transport.close(), self._shutdown_event.set())) + self.protocol.loop.call_soon_threadsafe(lambda: (self.transport.close(), self.shutdown_event.set())) @classmethod def popen(cls, Protocol, command, *, timeout=10.0, debug=False, setpgrp=False, **popen_args): @@ -2200,7 +2202,7 @@ def background(future): future.set_result(simple_engine) yield from protocol.returncode simple_engine.close() - yield from simple_engine._shutdown_event.wait() + yield from simple_engine.shutdown_event.wait() return run_in_background(background, debug=debug) From edb20debe0efb6d1d74e84dc80a4b7a41bbc808b Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 16 Jan 2019 17:12:40 +0100 Subject: [PATCH 0271/1451] Provide SimpleEngine.returncode --- chess/engine.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 368c61630..ddfc5040d 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -2096,15 +2096,15 @@ class SimpleEngine: the same methods and attributes as :class:`~chess.engine.EngineProtocol`, with blocking functions instead of coroutines. - Methods will raise :class:`asyncio.TimeoutError` if an operation takes - *timeout* seconds longer than expected (unless *timeout* is ``None``). - - Automatically closes the transport when used as a context manager. - You may not concurrently modify objects passed to any of the methods. Other than that :class:`~chess.engine.SimpleEngine` is thread-safe. When sending a new command to the engine, any previous running command will be cancelled as soon as possible. + + Methods will raise :class:`asyncio.TimeoutError` if an operation takes + *timeout* seconds longer than expected (unless *timeout* is ``None``). + + Automatically closes the transport when used as a context manager. """ def __init__(self, transport, protocol, *, timeout=10.0): @@ -2116,6 +2116,8 @@ def __init__(self, transport, protocol, *, timeout=10.0): self._shutdown = False self.shutdown_event = asyncio.Event(loop=self.protocol.loop) + self.returncode = concurrent.futures.Future() + def _timeout_for(self, limit): if self.timeout is None or limit is None or limit.time is None: return None @@ -2200,7 +2202,8 @@ def background(future): transport, protocol = yield from asyncio.wait_for(Protocol.popen(command, setpgrp=setpgrp, **popen_args), timeout) simple_engine = cls(transport, protocol, timeout=timeout) future.set_result(simple_engine) - yield from protocol.returncode + returncode = yield from protocol.returncode + simple_engine.returncode.set_result(returncode) simple_engine.close() yield from simple_engine.shutdown_event.wait() From a44d5aca85d2042ff5e0a08a252fc7549ac6aa29 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 16 Jan 2019 17:31:59 +0100 Subject: [PATCH 0272/1451] Do not prematurely restore managed options --- chess/engine.py | 49 +++++++++++++++++++++++++++++-------------------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index ddfc5040d..64277af7f 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -68,9 +68,7 @@ class StopAsyncIteration(Exception): KORK = object() -MANAGED_UCI_OPTIONS = ["uci_chess960", "uci_variant", "uci_analysemode", "multipv", "ponder"] - -MANAGED_XBOARD_OPTIONS = ["MultiPV"] # Case sensitive +MANAGED_OPTIONS = ["uci_chess960", "uci_variant", "uci_analysemode", "multipv", "ponder"] class EventLoopPolicy(asyncio.DefaultEventLoopPolicy): @@ -234,11 +232,12 @@ def parse(self, value): else: raise EngineError("unknown option type: {}", self.type) - def is_managed_uci(self): - return self.name.lower() in MANAGED_UCI_OPTIONS - - def is_managed_xboard(self): - return self.name in MANAGED_XBOARD_OPTIONS + def is_managed(self): + """ + Some options are managed automatically: ``UCI_Chess960``, + ``UCI_Variant``, ``UCI_AnalyseMode``, ``MultiPV``, ``Ponder``. + """ + return self.name.lower() in MANAGED_OPTIONS class Limit: @@ -676,7 +675,13 @@ def ping(self): @abc.abstractmethod @asyncio.coroutine def configure(self, options): - """Configures global engine options.""" + """ + Configures global engine options. + + :param options: A dictionary of engine options, where the keys are + names of :py:attr:`~options`. Do not set options that are + managed automatically (:func:`chess.engine.Option.is_managed()`). + """ @abc.abstractmethod @asyncio.coroutine @@ -1029,7 +1034,7 @@ def _setoption(self, name, value): def _configure(self, options): for name, value in options.items(): - if name.lower() in MANAGED_UCI_OPTIONS: + if name.lower() in MANAGED_OPTIONS: raise EngineError("cannot set {} which is automatically managed".format(name)) else: self._setoption(name, value) @@ -1046,15 +1051,15 @@ def start(self, engine): def _position(self, board): # Select UCI_Variant and UCI_Chess960. uci_variant = type(board).uci_variant - if uci_variant != self._getoption("UCI_Variant", "chess"): - if "UCI_Variant" not in self.options: - raise EngineError("engine does not support UCI_Variant") + if "UCI_Variant" in self.options: self._setoption("UCI_Variant", uci_variant) + elif uci_variant != "chess": + raise EngineError("engine does not support UCI_Variant") - if board.chess960 != self._getoption("UCI_Chess960", False): - if "UCI_Chess960" not in self.options: - raise EngineError("engine does not support UCI_Chess960") + if "UCI_Chess960" in self.options: self._setoption("UCI_Chess960", board.chess960) + elif board.chess960: + raise EngineError("engine does not support UCI_Chess960") # Send starting position. builder = ["position"] @@ -1196,10 +1201,12 @@ def _bestmove(self, engine, arg): self.end(engine) def end(self, engine): + # Restore options. for name, value in previous_config.items(): - engine._setoption(name, value) + if name.lower() not in MANAGED_OPTIONS: + engine._setoption(name, value) for name, option in engine.options.items(): - if name not in ["UCI_AnalyseMode", "Ponder"] and name not in previous_config and option.default is not None: + if name.lower() not in MANAGED_OPTIONS and name not in previous_config and option.default is not None: engine._setoption(name, option.default) self.set_finished() @@ -1250,10 +1257,12 @@ def _info(self, engine, arg): self.analysis.post(_parse_uci_info(arg, engine.board, info)) def _bestmove(self, engine, arg): + # Restore options. for name, value in previous_config.items(): - engine._setoption(name, value) + if name.lower() not in MANAGED_OPTIONS: + engine._setoption(name, value) for name, option in engine.options.items(): - if name not in ["UCI_AnalyseMode", "Ponder", "MultiPV"] and name not in previous_config and option.default is not None: + if name.lower() not in MANAGED_OPTIONS and name not in previous_config and option.default is not None: engine._setoption(name, option.default) self.analysis.set_finished() From 4bd1de82a6cedb50d5691f5814936ebe38e41308 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 16 Jan 2019 22:18:18 +0100 Subject: [PATCH 0273/1451] Minor documentation tweaks --- chess/engine.py | 8 ++++---- docs/engine.rst | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 64277af7f..af5016566 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -342,8 +342,8 @@ class Score(abc.ABC): Evaluation of a position. The score can be :class:`~chess.engine.Cp` (centi-pawns), - :class:`~chess.engine.Mate` or ``MateGiven``. A positive value indicates - an advantage. + :class:`~chess.engine.Mate` or :py:data:`~chess.engine.MateGiven`. + A positive value indicates an advantage. There is a total order defined on centi-pawn and mate scores. @@ -1976,13 +1976,13 @@ def _parse_xboard_post(line, root_board, selector=INFO_ALL): class AnalysisResult: """ Handle to ongoing engine analysis. + Returned by :func:`chess.engine.EngineProtocol.analysis()`. Can be used to asynchronously iterate over information sent by the engine. Automatically stops the analysis when used as a context manager. - - Returned by :func:`chess.engine.EngineProtocol.analysis()`. """ + def __init__(self, stop=None): self._stop = stop self._queue = asyncio.Queue() diff --git a/docs/engine.rst b/docs/engine.rst index 260bff368..b33b1f7ed 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -119,7 +119,7 @@ Example: Let Stockfish play against itself, 100 milliseconds per move. ``depth``, ``seldepth``, ``time`` (in seconds), ``nodes``, ``nps``, ``tbhits``, ``multipv``. - Others: ``currmove``, ``currmovenumber``, ``hashfull`` + Others: ``currmove``, ``currmovenumber``, ``hashfull``, ``cpuload``, ``refutation``, ``currline``, ``ebf`` and ``string``. .. py:attribute:: draw_offered From 00c1fddb18d4e86092922fb4f0de6ad7e1cf145e Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 17 Jan 2019 16:40:27 +0100 Subject: [PATCH 0274/1451] Send ucinewgame at least once (likely fixes #355) --- chess/engine.py | 54 ++++++++++++++++++++++++++++++++++--------------- test.py | 2 ++ 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index af5016566..0c1d6894e 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -898,6 +898,7 @@ def __init__(self): self.id = {} self.board = chess.Board() self.game = None + self.first_game = True @asyncio.coroutine def _initialize(self): @@ -981,6 +982,7 @@ def _isready(self): def _ucinewgame(self): self.send_line("ucinewgame") + self.first_game = False def debug(self, on=True): """ @@ -1138,6 +1140,7 @@ class Command(BaseCommand): def start(self, engine): self.info = {} self.pondering = False + self.sent_isready = False if "UCI_AnalyseMode" in engine.options: engine._setoption("UCI_AnalyseMode", False) @@ -1148,21 +1151,29 @@ def start(self, engine): engine._configure(options) - if engine.game != game: + if engine.first_game or engine.game != game: + engine.game = game engine._ucinewgame() - engine.game = game - - engine._position(board) - engine._go(limit, root_moves=root_moves) + self.sent_isready = True + engine._isready() + else: + self._readyok(engine) def line_received(self, engine, line): if line.startswith("info "): self._info(engine, line.split(" ", 1)[1]) elif line.startswith("bestmove "): self._bestmove(engine, line.split(" ", 1)[1]) + elif line == "readyok" and self.sent_isready: + self._readyok(engine) else: LOGGER.warning("%s: Unexpected engine output: %s", engine, line) + def _readyok(self, engine): + self.sent_isready = False + engine._position(board) + engine._go(limit, root_moves=root_moves) + def _info(self, engine, arg): if not self.pondering: self.info.update(_parse_uci_info(arg, engine.board, info)) @@ -1223,6 +1234,7 @@ def analysis(self, board, limit=None, *, multipv=None, game=None, info=INFO_ALL, class Command(BaseCommand): def start(self, engine): self.analysis = AnalysisResult(stop=lambda: self.cancel(engine)) + self.sent_isready = False if "UCI_AnalyseMode" in engine.options: engine._setoption("UCI_AnalyseMode", True) @@ -1232,27 +1244,35 @@ def start(self, engine): engine._configure(options) - if engine.game != game: + if engine.first_game or engine.game != game: + engine.game = game engine._ucinewgame() - engine.game = game - - engine._position(board) - - if limit: - engine._go(limit, root_moves=root_moves) + self.sent_isready = True + engine._isready() else: - engine._go(Limit(), root_moves=root_moves, infinite=True) - - self.result.set_result(self.analysis) + self._readyok(engine) def line_received(self, engine, line): if line.startswith("info "): self._info(engine, line.split(" ", 1)[1]) elif line.startswith("bestmove "): self._bestmove(engine, line.split(" ", 1)[1]) + elif line == "readyok" and self.sent_isready: + self._readyok(engine) else: LOGGER.warning("%s: Unexpected engine output: %s", engine, line) + def _readyok(self, engine): + self.sent_isready = False + engine._position(board) + + if limit: + engine._go(limit, root_moves=root_moves) + else: + engine._go(Limit(), root_moves=root_moves, infinite=True) + + self.result.set_result(self.analysis) + def _info(self, engine, arg): self.analysis.post(_parse_uci_info(arg, engine.board, info)) @@ -1468,6 +1488,7 @@ def __init__(self): self.config = {} self.board = chess.Board() self.game = None + self.first_game = True @asyncio.coroutine def _initialize(self): @@ -1559,8 +1580,9 @@ def _new(self, board, game, options): # Setup start position. root = board.root() new_options = "random" in options or "computer" in options - new_game = self.game != game or new_options or root != self.board.root() + new_game = self.first_game or self.game != game or new_options or root != self.board.root() self.game = game + self.first_game = False if new_game: self.board = root self.send_line("new") diff --git a/test.py b/test.py index 51f8eceef..60782ae29 100755 --- a/test.py +++ b/test.py @@ -3364,6 +3364,8 @@ def main(): mock = chess.engine.MockTransport(protocol) # Pondering. + mock.expect("ucinewgame") + mock.expect("isready", ["readyok"]) mock.expect("position startpos") mock.expect("go movetime 123 searchmoves e2e4 d2d4", ["info string searching ...", "bestmove d2d4 ponder d7d5"]) mock.expect("position startpos moves d2d4 d7d5") From f629daccfd1e9b2a8afe38bbda801eab89f84dfb Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 17 Jan 2019 19:43:35 +0100 Subject: [PATCH 0275/1451] Document EngineProtocol.id --- docs/engine.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/engine.rst b/docs/engine.rst index b33b1f7ed..866a36fc2 100644 --- a/docs/engine.rst +++ b/docs/engine.rst @@ -376,6 +376,11 @@ Reference Future: Exit code of the process. + .. py:attribute:: id + + Dictionary of information about the engine. Common keys are ``name`` + and ``author``. + .. autoclass:: chess.engine.UciProtocol .. autoclass:: chess.engine.XBoardProtocol From 82800ef332876be848a2a97a3ba5f7d1769b6703 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 17 Jan 2019 19:54:02 +0100 Subject: [PATCH 0276/1451] Add DeprecationWarning that mentions chess.engine --- chess/uci.py | 11 +++++++++++ chess/xboard.py | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/chess/uci.py b/chess/uci.py index f617801e1..1a28e74e8 100644 --- a/chess/uci.py +++ b/chess/uci.py @@ -33,6 +33,17 @@ import collections import concurrent.futures import threading +import warnings +import textwrap + + +warnings.warn(textwrap.dedent("""\ + The chess.uci module is deprecated in favor of + chess.engine . + + Please consider updating and open an issue + if your use case + is not covered by the new API."""), DeprecationWarning, stacklevel=2) class Score(collections.namedtuple("Score", "cp mate")): diff --git a/chess/xboard.py b/chess/xboard.py index 132b5bb7f..0d7c51fcc 100644 --- a/chess/xboard.py +++ b/chess/xboard.py @@ -21,6 +21,8 @@ import concurrent.futures import shlex import threading +import warnings +import textwrap from chess._engine import EngineTerminatedException from chess._engine import EngineStateException @@ -34,6 +36,15 @@ import chess +warnings.warn(textwrap.dedent("""\ + The chess.xboard module is deprecated in favor of + chess.engine . + + Please consider updating and open an issue + if your use case + is not covered by the new API."""), DeprecationWarning, stacklevel=2) + + DUMMY_RESPONSES = [ENGINE_RESIGN, GAME_DRAW] = [-1, -2] RESULTS = [WHITE_WIN, BLACK_WIN, DRAW] = ["1-0", "0-1", "1/2-1/2"] From e919c6ab1846929635c4f90ef55863fcc0097e46 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 18 Jan 2019 16:26:46 +0100 Subject: [PATCH 0277/1451] Update error when installing on Python 2 --- setup.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/setup.py b/setup.py index 5a698de52..a339b097c 100755 --- a/setup.py +++ b/setup.py @@ -21,17 +21,18 @@ import platform import re import sys +import textwrap import setuptools if sys.version_info < (3, ): - raise ImportError( - """You are installing python-chess on Python 2. + raise ImportError(textwrap.dedent("""\ + You are trying to install python-chess on Python 2. -Python 2 support has been dropped. Consider upgrading to Python 3, or using -the 0.23.x branch, which will be maintained until the end of 2018. -""") + The last compatible branch was 0.23.x, which was supported until the + end of 2018. Consider upgrading to Python 3. + """)) import chess From f8268205bc910efa319a5efd1cdbc253da727602 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 18 Jan 2019 20:17:52 +0100 Subject: [PATCH 0278/1451] Remove deprecated GameModelCreator --- chess/pgn.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/chess/pgn.py b/chess/pgn.py index 5a46419e0..27331c962 100644 --- a/chess/pgn.py +++ b/chess/pgn.py @@ -1311,7 +1311,3 @@ def skip_game(handle): Skip a game. Returns ``True`` if a game was found and skipped. """ return read_game(handle, Visitor=SkipVisitor) - - -# TODO: Deprecated -GameModelCreator = GameCreator From bef022808d758ddfd30fb3d5eac7a683dc5814cb Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 18 Jan 2019 21:09:08 +0100 Subject: [PATCH 0279/1451] Prepare 0.25.0 --- CHANGELOG.rst | 21 +++++++++++++++++++++ chess/__init__.py | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0eae2e32b..b29fe55fc 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,27 @@ Changelog for python-chess ========================== +New in v0.25.0 +-------------- + +New features: + +* This release introduces a new **experimental API for chess engine + communication**, `chess.engine`, based on `asyncio`. It is intended to + eventually replace `chess.uci` and `chess.xboard`. + +Bugfixes: + +* Fixed race condition in LRU-cache of open Syzygy tables. The LRU-cache is + enabled by default (*max_fds*). +* Fix deprecation warning and unclosed file in setup.py. + Thanks Mickaël Schoentgen. + +Changes: + +* `chess.pgn.read_game()` now ignores BOM at the start of the stream. +* Removed deprecated items. + New in v0.24.2 -------------- diff --git a/chess/__init__.py b/chess/__init__.py index 75174c42d..106097eea 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -26,7 +26,7 @@ __email__ = "niklas.fiekas@backscattering.de" -__version__ = "0.24.2" +__version__ = "0.25.0" import collections import collections.abc From 89a5f5086d4835e3623f0157171d83b517cbf9a9 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 19 Jan 2019 16:09:52 +0100 Subject: [PATCH 0280/1451] Update tox.ini --- tox.ini | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index 6d917d6e2..29c7af117 100644 --- a/tox.ini +++ b/tox.ini @@ -1,12 +1,13 @@ [tox] -envlist = py34,py35,py36,pypy3 +envlist = py34,py35,py36,py37,pypy3 [testenv] passenv = LD_LIBRARY_PATH whitelist_externals = stockfish + crafty deps = - py34,py35,py36: spur + py34,py35,py36,py37: spur commands = python test.py --verbose python -m doctest README.rst --verbose @@ -16,5 +17,5 @@ ignore = E126 E131 # allow over indent and unaligned indent E241 # allow indenting arrays E302 E305 # allow grouping functions - W504 # allow operators at before eol + W504 # allow operators before eol max-line-length = 160 From 1f9266902c3812478fd27ba0dd91001eddd2baf0 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 19 Jan 2019 16:16:06 +0100 Subject: [PATCH 0281/1451] Fix or ignore some overlong lines --- chess/engine.py | 4 +++- chess/svg.py | 28 ++++++++++++++-------------- chess/uci.py | 5 ++++- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 0c1d6894e..0e84f0307 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -243,7 +243,9 @@ def is_managed(self): class Limit: """Search termination condition.""" - def __init__(self, *, time=None, depth=None, nodes=None, mate=None, white_clock=None, black_clock=None, white_inc=None, black_inc=None, remaining_moves=None): + def __init__(self, *, time=None, depth=None, nodes=None, mate=None, + white_clock=None, black_clock=None, white_inc=None, + black_inc=None, remaining_moves=None): self.time = time self.depth = depth self.nodes = nodes diff --git a/chess/svg.py b/chess/svg.py index bc84d6f58..9aec72b34 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -30,23 +30,23 @@ MARGIN = 20 PIECES = { - "b": """""", - "k": """""", - "n": """""", - "p": """""", - "q": """""", - "r": """""", - "B": """""", - "K": """""", - "N": """""", - "P": """""", - "Q": """""", - "R": """""" + "b": """""", # noqa: E501 + "k": """""", # noqa: E501 + "n": """""", # noqa: E501 + "p": """""", # noqa: E501 + "q": """""", # noqa: E501 + "r": """""", # noqa: E501 + "B": """""", # noqa: E501 + "K": """""", # noqa: E501 + "N": """""", # noqa: E501 + "P": """""", # noqa: E501 + "Q": """""", # noqa: E501 + "R": """""", # noqa: E501 } -XX = """""" +XX = """""" # noqa: E501 -CHECK_GRADIENT = """""" +CHECK_GRADIENT = """""" # noqa: E501 DEFAULT_COLORS = { "square light": "#ffce9e", diff --git a/chess/uci.py b/chess/uci.py index 1a28e74e8..386d97230 100644 --- a/chess/uci.py +++ b/chess/uci.py @@ -506,7 +506,10 @@ def handle_move_token(token, fn): # Ignore extra spaces. Those can not be directly discarded, # because they may occur in the string parameter. pass - elif token in ["depth", "seldepth", "time", "nodes", "pv", "multipv", "score", "currmove", "currmovenumber", "hashfull", "nps", "tbhits", "cpuload", "refutation", "currline", "ebf", "string"]: + elif token in ["depth", "seldepth", "time", "nodes", "pv", + "multipv", "score", "currmove", "currmovenumber", + "hashfull", "nps", "tbhits", "cpuload", + "refutation", "currline", "ebf", "string"]: end_of_parameter() current_parameter = token From 68d0f0d7d57a241ecdc68806680b41ff1025c7bd Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Tue, 22 Jan 2019 10:58:39 +0100 Subject: [PATCH 0282/1451] Ignoring W503 rather than W504 --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 29c7af117..422b0e6a0 100644 --- a/tox.ini +++ b/tox.ini @@ -17,5 +17,5 @@ ignore = E126 E131 # allow over indent and unaligned indent E241 # allow indenting arrays E302 E305 # allow grouping functions - W504 # allow operators before eol + W503 # allow operators after eol max-line-length = 160 From 1003bfc5059ebeca5865d4372221157c6a8dbda9 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 23 Jan 2019 23:17:43 +0100 Subject: [PATCH 0283/1451] Add basic appveyor.yml --- appveyor.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 appveyor.yml diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 000000000..58b44abc3 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,11 @@ +environment: + matrix: + - PYTHON: "C:\\Python34" + - PYTHON: "C:\\Python35" + - PYTHON: "C:\\Python36" + - PYTHON: "C:\\Python37" + +build: off + +test_script: + - "%PYTHON%\\python.exe test.py" From 64bd2a98af53ca11df1c1f215a29e54b8129d5e6 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 23 Jan 2019 23:28:28 +0100 Subject: [PATCH 0284/1451] Try to install Stockfish on Appveyor --- appveyor.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/appveyor.yml b/appveyor.yml index 58b44abc3..af7a76347 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -7,5 +7,12 @@ environment: build: off +install: + # Stockfish + - ps: Start-FileDownload "https://stockfishchess.org/files/stockfish-10-win.zip" + - 7z e stockfish-10-win.zip + - cp stockfish-10-win/Windows/stockfish_10_x64_popcnt.exe stockfish.exe + - "set PATH=%cd%;%PATH%" + test_script: - - "%PYTHON%\\python.exe test.py" + - "%PYTHON%\\python.exe test.py -vv" From aa05ac06790bac94b64262e2b23a31fc2161ba2b Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 23 Jan 2019 23:31:48 +0100 Subject: [PATCH 0285/1451] Unpack only exe files from stockfish-10-win.zip --- appveyor.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/appveyor.yml b/appveyor.yml index af7a76347..137e3304c 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -10,9 +10,9 @@ build: off install: # Stockfish - ps: Start-FileDownload "https://stockfishchess.org/files/stockfish-10-win.zip" - - 7z e stockfish-10-win.zip - - cp stockfish-10-win/Windows/stockfish_10_x64_popcnt.exe stockfish.exe - - "set PATH=%cd%;%PATH%" + - 7z e stockfish-10-win.zip stockfish-10-win/Windows/*.exe + - ren stockfish_10_x64_popcnt.exe stockfish.exe + - set PATH=%cd%;%PATH% test_script: - "%PYTHON%\\python.exe test.py -vv" From c5d6a6094090c73b9cd006843fc71e1bdf8e19f0 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 23 Jan 2019 23:39:51 +0100 Subject: [PATCH 0286/1451] Add Appveyor badge --- README.rst | 3 +++ setup.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/README.rst b/README.rst index 00eea8165..cdfaaab15 100644 --- a/README.rst +++ b/README.rst @@ -4,6 +4,9 @@ python-chess: a pure Python chess library .. image:: https://travis-ci.org/niklasf/python-chess.svg?branch=master :target: https://travis-ci.org/niklasf/python-chess +.. image:: https://ci.appveyor.com/api/projects/status/y9k3hdbm0f0nbum9/branch/master?svg=true + :target: https://ci.appveyor.com/project/niklasf/python-chess + .. image:: https://coveralls.io/repos/github/niklasf/python-chess/badge.svg?branch=master :target: https://coveralls.io/github/niklasf/python-chess?branch=master diff --git a/setup.py b/setup.py index a339b097c..6a21535ed 100755 --- a/setup.py +++ b/setup.py @@ -60,6 +60,11 @@ def read_description(): "//travis-ci.org/niklasf/python-chess.svg?branch=master", "//travis-ci.org/niklasf/python-chess.svg?branch=v{}".format(chess.__version__)) + # Show Appveyor build status of the concrete version. + description = description.replace( + "/y9k3hdbm0f0nbum9/branch/master", + "/y9k3hdbm0f0nbum9/branch/v{}".format(chess.__version__)) + # Remove doctest comments. description = re.sub(r"\s*# doctest:.*", "", description) From 5eaaaee9c9a9ce709cbc84b5191a7ef54b805a37 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 23 Jan 2019 23:45:55 +0100 Subject: [PATCH 0287/1451] Handle Windows line endings (fixes #357) --- chess/engine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index 0e84f0307..333d0db6c 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -532,7 +532,7 @@ def get_pipe_transport(self, fd): return self def write(self, data): - self.stdin_buffer.extend(data) + self.stdin_buffer.extend(data.replace(b"\r\n", b"\n")) while b"\n" in self.stdin_buffer: line, self.stdin_buffer = self.stdin_buffer.split(b"\n", 1) line = line.decode("utf-8") From f55559f67fcf3bdc01b6eb72ac25cb8732df4509 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 23 Jan 2019 23:50:48 +0100 Subject: [PATCH 0288/1451] Revert "Handle Windows line endings (fixes #357)" This reverts commit 5eaaaee9c9a9ce709cbc84b5191a7ef54b805a37. --- chess/engine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index 333d0db6c..0e84f0307 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -532,7 +532,7 @@ def get_pipe_transport(self, fd): return self def write(self, data): - self.stdin_buffer.extend(data.replace(b"\r\n", b"\n")) + self.stdin_buffer.extend(data) while b"\n" in self.stdin_buffer: line, self.stdin_buffer = self.stdin_buffer.split(b"\n", 1) line = line.decode("utf-8") From 45320bac81c7d714b043b4ff719b3ad4c8b71064 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Wed, 23 Jan 2019 23:57:44 +0100 Subject: [PATCH 0289/1451] Normalize line endings (#357) --- chess/engine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index 0e84f0307..530af0190 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -598,7 +598,7 @@ def send_line(self, line): stdin.write(b"\n") def pipe_data_received(self, fd, data): - self.buffer[fd].extend(data) + self.buffer[fd].extend(data.replace(b"\r\n", b"\n")) while b"\n" in self.buffer[fd]: line, self.buffer[fd] = self.buffer[fd].split(b"\n", 1) line = line.decode("utf-8") From 86ff1870e2a455a9a68922a5a65a116fbec21ebb Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Thu, 24 Jan 2019 00:08:43 +0100 Subject: [PATCH 0290/1451] Prepare 0.25.1 --- CHANGELOG.rst | 8 ++++++++ chess/__init__.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b29fe55fc..3d1766d7c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,14 @@ Changelog for python-chess ========================== +New in v0.25.1 +-------------- + +Bugfixes: + +* `chess.engine` did not correctly handle Windows-style line endings. + Thanks @Bstylestuff. + New in v0.25.0 -------------- diff --git a/chess/__init__.py b/chess/__init__.py index 106097eea..77408ce6c 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -26,7 +26,7 @@ __email__ = "niklas.fiekas@backscattering.de" -__version__ = "0.25.0" +__version__ = "0.25.1" import collections import collections.abc From 05fa4dc908fe8a0e3c53ff4bfb3bc46cd9672f25 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 25 Jan 2019 19:12:28 +0100 Subject: [PATCH 0291/1451] Add Bitboard type alias --- chess/__init__.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/chess/__init__.py b/chess/__init__.py index 77408ce6c..795955955 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -36,14 +36,14 @@ import itertools +Color = bool COLORS = [WHITE, BLACK] = [True, False] COLOR_NAMES = ["black", "white"] -Color = bool +PieceType = int PIECE_TYPES = [PAWN, KNIGHT, BISHOP, ROOK, QUEEN, KING] = range(1, 7) PIECE_SYMBOLS = [None, "p", "n", "b", "r", "q", "k"] PIECE_NAMES = [None, "pawn", "knight", "bishop", "rook", "queen", "king"] -PieceType = int UNICODE_PIECE_SYMBOLS = { "R": u"♖", "r": u"♜", @@ -106,6 +106,7 @@ class Status(_IntFlag): STATUS_RACE_MATERIAL = Status.RACE_MATERIAL +Square = int SQUARES = [ A1, B1, C1, D1, E1, F1, G1, H1, A2, B2, C2, D2, E2, F2, G2, H2, @@ -115,7 +116,6 @@ class Status(_IntFlag): A6, B6, C6, D6, E6, F6, G6, H6, A7, B7, C7, D7, E7, F7, G7, H7, A8, B8, C8, D8, E8, F8, G8, H8] = range(64) -Square = int SQUARE_NAMES = [f + r for r in RANK_NAMES for f in FILE_NAMES] @@ -148,6 +148,7 @@ def square_mirror(square): SQUARES_180 = [square_mirror(sq) for sq in SQUARES] +Bitboard = int BB_EMPTY = 0 BB_ALL = 0xffffffffffffffff @@ -287,7 +288,7 @@ def shift_down_right(b): def _sliding_attacks(square, occupied, deltas): - attacks = 0 + attacks = BB_EMPTY for delta in deltas: sq = square @@ -318,7 +319,7 @@ def _edges(square): def _carry_rippler(mask): # Carry-Rippler trick to iterate subsets of mask. - subset = 0 + subset = BB_EMPTY while True: yield subset subset = (subset - mask) & mask @@ -363,8 +364,8 @@ def _rays(): rays_row.append(BB_FILE_ATTACKS[a][0] | bb_a) between_row.append(BB_FILE_ATTACKS[a][BB_FILE_MASKS[a] & bb_b] & BB_FILE_ATTACKS[b][BB_FILE_MASKS[b] & bb_a]) else: - rays_row.append(0) - between_row.append(0) + rays_row.append(BB_EMPTY) + between_row.append(BB_EMPTY) rays.append(rays_row) between.append(between_row) return rays, between From 737edbe74b19525a17bfd0e734b1b215e0d44f72 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sun, 27 Jan 2019 13:07:58 +0100 Subject: [PATCH 0292/1451] Ignore .mypy_cache --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 80d4fd61a..bc88892b8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ venv/ .coveralls.yml nosetests.xml .tox +.mypy_cache dist/ build/ From 62526edcbf1a6ce40d7e75c1c4074bdf052fef18 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sun, 27 Jan 2019 16:36:33 +0100 Subject: [PATCH 0293/1451] Update bratko_kopec example --- examples/bratko_kopec/bratko_kopec.py | 137 ++++++++++++-------------- 1 file changed, 61 insertions(+), 76 deletions(-) diff --git a/examples/bratko_kopec/bratko_kopec.py b/examples/bratko_kopec/bratko_kopec.py index 0f006d752..2ba155c7d 100755 --- a/examples/bratko_kopec/bratko_kopec.py +++ b/examples/bratko_kopec/bratko_kopec.py @@ -3,79 +3,59 @@ """Run an EPD test suite with an UCI engine.""" -import chess -import chess.uci -import chess.variant +import asyncio import time import argparse import itertools import logging import sys +import chess +import chess.engine +import chess.variant + -def test_epd(engine, epd, VariantBoard, threads, movetime): - position = VariantBoard() - epd_info = position.set_epd(epd) - epd_string = "%s" % epd_info.get("id", position.fen()) +async def test_epd(engine, epd, VariantBoard, movetime): + board, epd_info = VariantBoard.from_epd(epd) + epd_string = epd_info.get("id", board.fen()) if "am" in epd_info: - epd_string = "%s (avoid %s)" % (epd_string, " and ".join(position.san(am) for am in epd_info["am"])) + epd_string = "{} (avoid {})".format(epd_string, " and ".join(board.san(am) for am in epd_info["am"])) if "bm" in epd_info: - epd_string = "%s (expect %s)" % (epd_string, " or ".join(position.san(bm) for bm in epd_info["bm"])) - - engine.ucinewgame() - engine.setoption({ - "UCI_Variant": VariantBoard.uci_variant, - "Threads": threads - }) - engine.position(position) + epd_string = "{} (expect {})".format(epd_string, " or ".join(board.san(bm) for bm in epd_info["bm"])) - enginemove, _ = engine.go(movetime=movetime) + limit = chess.engine.Limit(time=movetime) + result = await engine.play(board, limit, game=object()) - if "am" in epd_info and enginemove in epd_info["am"]: - print("%s: %s | +0" % (epd_string, position.san(enginemove))) + if "am" in epd_info and result.move in epd_info["am"]: + print("{}: {} | +0".format(epd_string, board.san(result.move))) return 0.0 - elif "bm" in epd_info and enginemove not in epd_info["bm"]: - print("%s: %s | +0" % (epd_string, position.san(enginemove))) + elif "bm" in epd_info and result.move not in epd_info["bm"]: + print("{}: {} | +0".format(epd_string, board.san(result.move))) return 0.0 else: - print("%s: %s | +1" % (epd_string, position.san(enginemove))) + print("{}: {} | +1".format(epd_string, board.san(result.move))) return 1.0 -def test_epd_with_fractional_scores(engine, epd, VariantBoard, threads, movetime): - info_handler = chess.uci.InfoHandler() - engine.info_handlers.append(info_handler) - - position = VariantBoard() - epd_info = position.set_epd(epd) - epd_string = "%s" % epd_info.get("id", position.fen()) +async def test_epd_with_fractional_scores(engine, epd, VariantBoard, movetime): + board, epd_info = VariantBoard.from_epd(epd) + epd_string = epd_info.get("id", board.fen()) if "am" in epd_info: - epd_string = "%s (avoid %s)" % (epd_string, " and ".join(position.san(am) for am in epd_info["am"])) + epd_string = "{} (avoid {})".format(epd_string, " and ".join(board.san(am) for am in epd_info["am"])) if "bm" in epd_info: - epd_string = "%s (expect %s)" % (epd_string, " or ".join(position.san(bm) for bm in epd_info["bm"])) - - engine.ucinewgame() - engine.setoption({ - "UCI_Variant": VariantBoard.uci_variant, - "Threads": threads - }) - engine.position(position) - - # Search in background - search = engine.go(infinite=True, async_callback=True) + epd_string = "{} (expect {})".format(epd_string, " or ".join(board.san(bm) for bm in epd_info["bm"])) + # Start analysis. score = 0.0 - - print("%s:" % epd_string, end=" ", flush=True) - - for step in range(0, 3): - time.sleep(movetime / 4000.0) - - # Assess the current principal variation. - with info_handler as info: - if 1 in info["pv"] and len(info["pv"][1]) >= 1: - move = info["pv"][1][0] - print("(%s)" % position.san(move), end=" ", flush=True) + print("{}:".format(epd_string), end=" ", flush=True) + with await engine.analysis(board, game=object()) as analysis: + for step in range(0, 4): + await asyncio.sleep(movetime / 4) + + # Assess the current principal variation. + if "pv" in analysis.info and len(analysis.info["pv"]) >= 1: + move = analysis.info["pv"][0] + print(board.san(move), end=" ", flush=True) if "am" in epd_info and move in epd_info["am"]: continue # fail elif "bm" in epd_info and move not in epd_info["bm"]: @@ -85,35 +65,28 @@ def test_epd_with_fractional_scores(engine, epd, VariantBoard, threads, movetime else: print("(no pv)", end=" ", flush=True) - # Assess the final best move by the engine. - time.sleep(movetime / 4000.0) - engine.stop() - enginemove, _ = search.result() - if "am" in epd_info and enginemove in epd_info["am"]: - pass # fail - elif "bm" in epd_info and enginemove not in epd_info["bm"]: - pass # fail - else: - score = 1.0 - - print("%s | +%g" % (position.san(enginemove), score)) - - engine.info_handlers.remove(info_handler) - return score + # Done. + print("| +{}".format(score)) + return score -if __name__ == "__main__": +async def main(): # Parse command line arguments. parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("-e", "--engine", required=True, + + engine_group = parser.add_mutually_exclusive_group(required=True) + engine_group.add_argument("-u", "--uci", help="The UCI engine under test.") + engine_group.add_argument("-x", "--xboard", + help="The XBoard engine under test.") + parser.add_argument("epd", nargs="+", type=argparse.FileType("r"), help="EPD test suite(s).") parser.add_argument("-v", "--variant", default="standard", help="Use a non-standard chess variant.") parser.add_argument("-t", "--threads", default=1, type=int, help="Threads for use by the UCI engine.") - parser.add_argument("-m", "--movetime", default=1000, type=int, + parser.add_argument("-m", "--movetime", default=1.0, type=float, help="Time to move in milliseconds.") parser.add_argument("-s", "--simple", dest="test_epd", action="store_const", default=test_epd_with_fractional_scores, @@ -121,6 +94,7 @@ def test_epd_with_fractional_scores(engine, epd, VariantBoard, threads, movetime help="Run in simple mode without fractional scores.") parser.add_argument("-d", "--debug", action="store_true", help="Show debug logs.") + args = parser.parse_args() # Configure logger. @@ -129,9 +103,15 @@ def test_epd_with_fractional_scores(engine, epd, VariantBoard, threads, movetime # Find variant. VariantBoard = chess.variant.find_variant(args.variant) - # Open engine. - engine = chess.uci.popen_engine(args.engine) - engine.uci() + # Open and configure engine. + if args.uci: + _, engine = await chess.engine.popen_uci(args.uci) + if args.threads > 1: + await engine.configure({"Threads": args.threads}) + else: + _, engine = await chess.engine.popen_xboard(args.xboard) + if args.threads > 1: + await engine.configure({"cores": args.threads}) # Run each test line. score = 0.0 @@ -145,10 +125,15 @@ def test_epd_with_fractional_scores(engine, epd, VariantBoard, threads, movetime continue # Run the actual test. - score += args.test_epd(engine, epd, VariantBoard, args.threads, args.movetime) + score += await args.test_epd(engine, epd, VariantBoard, args.movetime) count += 1 - engine.quit() + await engine.quit() print("-------------------------------") - print("%g / %d" % (score, count)) + print("{} / {}".format(score, count)) + + +if __name__ == "__main__": + asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy()) + asyncio.run(main()) From bdb38694551affc82589da0c87de11758997e133 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sun, 27 Jan 2019 16:41:39 +0100 Subject: [PATCH 0294/1451] Tweak example code --- examples/bratko_kopec/bratko_kopec.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/examples/bratko_kopec/bratko_kopec.py b/examples/bratko_kopec/bratko_kopec.py index 2ba155c7d..ee35ee863 100755 --- a/examples/bratko_kopec/bratko_kopec.py +++ b/examples/bratko_kopec/bratko_kopec.py @@ -48,7 +48,9 @@ async def test_epd_with_fractional_scores(engine, epd, VariantBoard, movetime): # Start analysis. score = 0.0 print("{}:".format(epd_string), end=" ", flush=True) - with await engine.analysis(board, game=object()) as analysis: + analysis = await engine.analysis(board, game=object()) + + with analysis: for step in range(0, 4): await asyncio.sleep(movetime / 4) @@ -65,9 +67,9 @@ async def test_epd_with_fractional_scores(engine, epd, VariantBoard, movetime): else: print("(no pv)", end=" ", flush=True) - # Done. - print("| +{}".format(score)) - return score + # Done. + print("| +{}".format(score)) + return score async def main(): From 7173daa462472e3a1e4e3f8dde317e31a6c204c5 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sun, 27 Jan 2019 16:54:48 +0100 Subject: [PATCH 0295/1451] Handle EngineError during line_received() --- chess/engine.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index 530af0190..067e57399 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -868,7 +868,10 @@ def _done(self): def _line_received(self, engine, line): assert self.state in [CommandState.Active, CommandState.Cancelling] - self.line_received(engine, line) + try: + self.line_received(engine, line) + except EngineError as err: + self._handle_exception(engine, err) def cancel(self, engine): pass From dccfc580fb0437278ae3136e21802a66643c4b56 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 28 Jan 2019 21:22:06 +0100 Subject: [PATCH 0296/1451] Retire old engine API --- .travis.yml | 7 +- chess/_engine.py | 308 ---------- chess/uci.py | 1168 ------------------------------------ chess/xboard.py | 1483 ---------------------------------------------- docs/engine.rst | 8 - docs/index.rst | 1 - docs/uci.rst | 170 ------ docs/variant.rst | 9 +- setup.py | 8 - test.py | 710 +--------------------- tox.ini | 2 - 11 files changed, 6 insertions(+), 3868 deletions(-) delete mode 100644 chess/_engine.py delete mode 100644 chess/uci.py delete mode 100644 chess/xboard.py delete mode 100644 docs/uci.rst diff --git a/.travis.yml b/.travis.yml index 2de99db58..d0d6dcef9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -49,7 +49,7 @@ install: - pip install --upgrade pip wheel - pip install --upgrade setuptools - pip install coverage coveralls - - pip install -e .[test] + - pip install -e . script: - # Unit tests - if [[ $PERFT -ne 1 ]]; then coverage erase; fi @@ -74,11 +74,6 @@ script: - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv CrazyhouseTestCase; fi - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv GiveawayTestCase; fi - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv EngineTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv UciEngineTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv CraftyTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv StockfishTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv SpurEngineTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv XboardEngineTestCase; fi - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append -m doctest README.rst --verbose; fi - echo Unit tests complete - if [[ $PERFT -ne 1 ]]; then coveralls || [[ $? -eq 139 ]]; fi diff --git a/chess/_engine.py b/chess/_engine.py deleted file mode 100644 index 3ebce28f5..000000000 --- a/chess/_engine.py +++ /dev/null @@ -1,308 +0,0 @@ -# -*- coding: utf-8 -*- -# -# This file is part of the python-chess library. -# Copyright (C) 2012-2019 Niklas Fiekas -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import collections -import collections.abc -import logging -import os -import platform -import queue -import signal -import subprocess -import threading - - -FUTURE_POLL_TIMEOUT = 0.1 if platform.system() == "Windows" else 60 - -LOGGER = logging.getLogger(__name__) - - -class EngineTerminatedException(Exception): - """The engine has been terminated.""" - pass - - -class EngineStateException(Exception): - """Unexpected engine state.""" - pass - - -class Option(collections.namedtuple("Option", "name type default min max var")): - """Information about an available option for an UCI engine.""" - - __slots__ = () - - -class MockProcess: - def __init__(self, engine): - self.engine = engine - self._expectations = collections.deque() - self._is_dead = threading.Event() - self._std_streams_closed = False - - self.engine.on_process_spawned(self) - - self._send_queue = queue.Queue() - self._send_thread = threading.Thread(target=self._send_thread_target) - self._send_thread.daemon = True - self._send_thread.start() - - def _send_thread_target(self): - while not self._is_dead.is_set(): - line = self._send_queue.get() - if line is not None: - self.engine.on_line_received(line) - self._send_queue.task_done() - - def expect(self, expectation, responses=()): - self._expectations.append((expectation, responses)) - - def assert_done(self): - assert not self._expectations, "pending expectations: {}".format(self._expectations) - - def assert_terminated(self): - self.assert_done() - assert self._is_dead.is_set() - - def is_alive(self): - return not self._is_dead.is_set() - - def terminate(self): - self._is_dead.set() - self._send_queue.put(None) - self.engine.on_terminated() - - def kill(self): - self._is_dead.set() - self._send_queue.put(None) - self.engine.on_terminated() - - def send_line(self, string): - assert self.is_alive() - - assert self._expectations, "unexpected: {}".format(string) - expectation, responses = self._expectations.popleft() - assert expectation == string, "expected: {}, got {}".format(expectation, string) - - for response in responses: - self._send_queue.put(response) - - def wait_for_return_code(self): - self._is_dead.wait() - return 0 - - def pid(self): - return None - - def __repr__(self): - return "".format(hex(id(self))) - - -class PopenProcess: - def __init__(self, engine, command, **kwargs): - self.engine = engine - - self._receiving_thread = threading.Thread(target=self._receiving_thread_target) - self._receiving_thread.daemon = True - self._stdin_lock = threading.Lock() - - self.engine.on_process_spawned(self) - - popen_args = { - "stdout": subprocess.PIPE, - "stdin": subprocess.PIPE, - "bufsize": 1, # Line buffering - "universal_newlines": True, - } - popen_args.update(kwargs) - self.process = subprocess.Popen(command, **popen_args) - - self._receiving_thread.start() - - def _receiving_thread_target(self): - for line in iter(self.process.stdout.readline, ""): - self.engine.on_line_received(line.rstrip()) - - # Close file descriptors. - self.process.stdout.close() - with self._stdin_lock: - self.process.stdin.close() - - # Ensure the process is terminated (not just the in/out streams). - if self.is_alive(): - self.terminate() - self.wait_for_return_code() - - self.engine.on_terminated() - - def is_alive(self): - return self.process.poll() is None - - def terminate(self): - self.process.terminate() - - def kill(self): - self.process.kill() - - def send_line(self, string): - with self._stdin_lock: - self.process.stdin.write(string + "\n") - self.process.stdin.flush() - - def wait_for_return_code(self): - self.process.wait() - return self.process.returncode - - def pid(self): - return self.process.pid - - def __repr__(self): - return "".format(hex(id(self)), self.pid()) - - -class SpurProcess: - def __init__(self, engine, shell, command): - self.engine = engine - self.shell = shell - - self._stdout_buffer = [] - - self._result = None - - self._waiting_thread = threading.Thread(target=self._waiting_thread_target) - self._waiting_thread.daemon = True - - self.engine.on_process_spawned(self) - self.process = self.shell.spawn(command, store_pid=True, allow_error=True, stdout=self) - self._waiting_thread.start() - - def write(self, byte): - # Internally called whenever a byte is received. - if byte == b"\r": - pass - elif byte == b"\n": - self.engine.on_line_received(b"".join(self._stdout_buffer).decode("utf-8")) - del self._stdout_buffer[:] - else: - self._stdout_buffer.append(byte) - - def _waiting_thread_target(self): - self._result = self.process.wait_for_result() - self.engine.on_terminated() - - def is_alive(self): - return self.process.is_running() - - def terminate(self): - self.process.send_signal(signal.SIGTERM) - - def kill(self): - self.process.send_signal(signal.SIGKILL) - - def send_line(self, string): - self.process.stdin_write(string.encode("utf-8")) - self.process.stdin_write(b"\n") - - def wait_for_return_code(self): - return self.process.wait_for_result().return_code - - def pid(self): - return self.process.pid - - def __repr__(self): - return "".format(hex(id(self)), self.pid()) - - -class OptionMap(collections.abc.MutableMapping): - def __init__(self, data=None, **kwargs): - self._store = dict() - if data is None: - data = {} - self.update(data, **kwargs) - - def __setitem__(self, key, value): - self._store[key.lower()] = (key, value) - - def __getitem__(self, key): - return self._store[key.lower()][1] - - def __delitem__(self, key): - del self._store[key.lower()] - - def __iter__(self): - return (casedkey for casedkey, mappedvalue in self._store.values()) - - def __len__(self): - return len(self._store) - - def __eq__(self, other): - for key, value in self.items(): - if key not in other or other[key] != value: - return False - - for key, value in other.items(): - if key not in self or self[key] != value: - return False - - return True - - def copy(self): - return type(self)(self._store.values()) - - def __copy__(self): - return self.copy() - - def __repr__(self): - return "{}({})".format(type(self).__name__, dict(self.items())) - - -def _popen_engine(command, engine_cls, setpgrp=False, **kwargs): - """ - Opens a local chess engine process. - - :param engine_cls: Engine class - :param setpgrp: Open the engine process in a new process group. This will - stop signals (such as keyboards interrupts) from propagating from the - parent process. Defaults to ``False``. - """ - engine = engine_cls() - - popen_args = {} - if setpgrp: - try: - # Windows. - popen_args["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP - except AttributeError: - # Unix. - popen_args["preexec_fn"] = os.setpgrp - popen_args.update(kwargs) - - PopenProcess(engine, command, **popen_args) - - return engine - - -def _spur_spawn_engine(shell, command, engine_cls): - """ - Spawns a remote engine using a `Spur`_ shell. - - .. _Spur: https://pypi.python.org/pypi/spur - """ - engine = engine_cls() - SpurProcess(engine, shell, command) - return engine diff --git a/chess/uci.py b/chess/uci.py deleted file mode 100644 index 386d97230..000000000 --- a/chess/uci.py +++ /dev/null @@ -1,1168 +0,0 @@ -# -*- coding: utf-8 -*- -# -# This file is part of the python-chess library. -# Copyright (C) 2012-2019 Niklas Fiekas -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import chess - -from chess._engine import EngineTerminatedException -from chess._engine import EngineStateException -from chess._engine import MockProcess -from chess._engine import PopenProcess -from chess._engine import SpurProcess -from chess._engine import Option -from chess._engine import OptionMap -from chess._engine import LOGGER -from chess._engine import FUTURE_POLL_TIMEOUT -from chess._engine import _popen_engine -from chess._engine import _spur_spawn_engine - -import collections -import concurrent.futures -import threading -import warnings -import textwrap - - -warnings.warn(textwrap.dedent("""\ - The chess.uci module is deprecated in favor of - chess.engine . - - Please consider updating and open an issue - if your use case - is not covered by the new API."""), DeprecationWarning, stacklevel=2) - - -class Score(collections.namedtuple("Score", "cp mate")): - """A *cp* (centipawns) or *mate* score sent by an UCI engine.""" - __slots__ = () - - -class BestMove(collections.namedtuple("BestMove", "bestmove ponder")): - """A *bestmove* and *ponder* move sent by an UCI engine.""" - __slots__ = () - - -class InfoHandler: - """ - Chess engines may send information about their calculations with the - *info* command. An :class:`~chess.uci.InfoHandler` instance can be used - to aggregate or react to this information. - - >>> import chess.uci - >>> - >>> engine = chess.uci.popen_engine("stockfish") - >>> - >>> # Register a standard info handler. - >>> info_handler = chess.uci.InfoHandler() - >>> engine.info_handlers.append(info_handler) - >>> - >>> # Start a search. - >>> engine.position(chess.Board()) - >>> engine.go(movetime=1000) - BestMove(bestmove=Move.from_uci('e2e4'), ponder=Move.from_uci('e7e6')) - >>> - >>> # Retrieve the score of the mainline (PV 1) after search is completed. - >>> # Note that the score is relative to the side to move. - >>> info_handler.info["score"][1] - Score(cp=34, mate=None) - - See :attr:`~chess.uci.InfoHandler.info` for a way to access this dictionary - in a thread-safe way during search. - - If you want to be notified whenever new information is available, - you would usually subclass the :class:`~chess.uci.InfoHandler` class: - - >>> class MyHandler(chess.uci.InfoHandler): - ... def post_info(self): - ... # Called whenever a complete info line has been processed. - ... print(self.info) - ... super().post_info() # Release the lock - """ - def __init__(self): - self.lock = threading.Lock() - - self.info = {"refutation": {}, "currline": {}, "pv": {}, "score": {}} - - def depth(self, x): - """Receives the search depth in plies.""" - self.info["depth"] = x - - def seldepth(self, x): - """Receives the selective search depth in plies.""" - self.info["seldepth"] = x - - def time(self, x): - """Receives a new time searched in milliseconds.""" - self.info["time"] = x - - def nodes(self, x): - """Receives the number of nodes searched.""" - self.info["nodes"] = x - - def pv(self, moves): - """ - Receives the principal variation as a list of moves. - - In *MultiPV* mode, this is related to the most recent *multipv* number - sent by the engine. - """ - self.info["pv"][self.info.get("multipv", 1)] = moves - - def multipv(self, num): - """ - Receives a new *multipv* number, starting at 1. - - If *multipv* occurs in an info line, this is guaranteed to be called - before *score* or *pv*. - """ - self.info["multipv"] = num - - def score(self, cp, mate, lowerbound, upperbound): - """ - Receives a new evaluation in *cp* (centipawns) or a *mate* score. - - *cp* may be ``None`` if no score in centipawns is available. - - *mate* may be ``None`` if no forced mate has been found. A negative - number means the engine thinks it will get mated. - - *lowerbound* and *upperbound* are usually ``False``. If ``True``, - the sent score is just a *lowerbound* or *upperbound*. - - In *MultiPV* mode, this is related to the most recent *multipv* number - sent by the engine. - """ - if not lowerbound and not upperbound: - self.info["score"][self.info.get("multipv", 1)] = Score(cp, mate) - - def currmove(self, move): - """ - Receives a move the engine is currently thinking about. - - The move comes directly from the engine, so the castling move - representation depends on the *UCI_Chess960* option of the engine. - """ - self.info["currmove"] = move - - def currmovenumber(self, x): - """Receives a new current move number.""" - self.info["currmovenumber"] = x - - def hashfull(self, x): - """ - Receives new information about the hash table. - - The hash table is *x* permill full. - """ - self.info["hashfull"] = x - - def nps(self, x): - """Receives a new nodes per second (nps) statistic.""" - self.info["nps"] = x - - def tbhits(self, x): - """Receives a new information about the number of tablebase hits.""" - self.info["tbhits"] = x - - def cpuload(self, x): - """Receives a new *cpuload* information in permill.""" - self.info["cpuload"] = x - - def string(self, string): - """Receives a string the engine wants to display.""" - self.info["string"] = string - - def refutation(self, move, refuted_by): - """ - Receives a new refutation of a move. - - *refuted_by* may be a list of moves representing the mainline of the - refutation or ``None`` if no refutation has been found. - - Engines should only send refutations if the *UCI_ShowRefutations* - option has been enabled. - """ - self.info["refutation"][move] = refuted_by - - def currline(self, cpunr, moves): - """ - Receives a new snapshot of a line that a specific CPU is calculating. - - *cpunr* is an integer representing a specific CPU and *moves* is a list - of moves. - """ - self.info["currline"][cpunr] = moves - - def ebf(self, ebf): - """Receives the effective branching factor.""" - self.info["ebf"] = ebf - - def pre_info(self, line): - """ - Receives new info lines before they are processed. - - When subclassing, remember to call this method on the parent class - to keep the locking intact. - """ - self.lock.acquire() - self.info.pop("multipv", None) - - def post_info(self): - """ - Processing of a new info line has been finished. - - When subclassing, remember to call this method on the parent class - to keep the locking intact. - """ - self.lock.release() - - def on_bestmove(self, bestmove, ponder): - """Receives a new *bestmove* and a new *ponder* move.""" - pass - - def on_go(self): - """ - Notified when a *go* command is beeing sent. - - Since information about the previous search is invalidated, the - dictionary with the current information will be cleared. - """ - with self.lock: - self.info.clear() - self.info["refutation"] = {} - self.info["currline"] = {} - self.info["pv"] = {} - self.info["score"] = {} - - def acquire(self, blocking=True): - return self.lock.acquire(blocking) - - def release(self): - return self.lock.release() - - def __enter__(self): - self.acquire() - return self.info - - def __exit__(self, exc_type, exc_value, traceback): - self.release() - - -class Engine: - def __init__(self, *, Executor=concurrent.futures.ThreadPoolExecutor): - self.idle = True - self.pondering = False - self.state_changed = threading.Condition() - self.semaphore = threading.Semaphore() - self.search_started = threading.Event() - - self.board = chess.Board() - self.uci_chess960 = None - self.uci_variant = None - - self.name = None - self.author = None - self.options = OptionMap() - self.uciok = threading.Event() - self.uciok_received = threading.Condition() - - self.readyok_received = threading.Condition() - - self.bestmove = None - self.ponder = None - self.bestmove_received = threading.Event() - - self.return_code = None - self.terminated = threading.Event() - - self.info_handlers = [] - - self.pool = Executor(max_workers=3) - self.process = None - - def on_process_spawned(self, process): - self.process = process - - def send_line(self, line): - LOGGER.debug("%s << %s", self.process, line) - return self.process.send_line(line) - - def on_line_received(self, buf): - LOGGER.debug("%s >> %s", self.process, buf) - - command_and_args = buf.split(None, 1) - if not command_and_args: - return - - if len(command_and_args) >= 1: - if command_and_args[0] == "uciok": - return self._uciok() - elif command_and_args[0] == "readyok": - return self._readyok() - - if len(command_and_args) >= 2: - if command_and_args[0] == "id": - return self._id(command_and_args[1]) - elif command_and_args[0] == "bestmove": - return self._bestmove(command_and_args[1]) - elif command_and_args[0] == "copyprotection": - return self._copyprotection(command_and_args[1]) - elif command_and_args[0] == "registration": - return self._registration(command_and_args[1]) - elif command_and_args[0] == "info": - return self._info(command_and_args[1]) - elif command_and_args[0] == "option": - return self._option(command_and_args[1]) - - def on_terminated(self): - self.return_code = self.process.wait_for_return_code() - self.pool.shutdown(wait=False) - self.terminated.set() - - # Wake up waiting commands. - self.bestmove_received.set() - with self.uciok_received: - self.uciok_received.notify_all() - with self.readyok_received: - self.readyok_received.notify_all() - with self.state_changed: - self.state_changed.notify_all() - - def _id(self, arg): - property_and_arg = arg.split(None, 1) - if property_and_arg[0] == "name": - if len(property_and_arg) >= 2: - self.name = property_and_arg[1] - else: - self.name = "" - return - elif property_and_arg[0] == "author": - if len(property_and_arg) >= 2: - self.author = property_and_arg[1] - else: - self.author = "" - return - - def _uciok(self): - # Set UCI_Chess960 and UCI_Variant default value. - if self.uci_chess960 is None and "UCI_Chess960" in self.options: - self.uci_chess960 = self.options["UCI_Chess960"].default - if self.uci_variant is None and "UCI_Variant" in self.options: - self.uci_variant = self.options["UCI_Variant"].default - - self.uciok.set() - - with self.uciok_received: - self.uciok_received.notify_all() - - def _readyok(self): - with self.readyok_received: - self.readyok_received.notify_all() - - def _bestmove(self, arg): - tokens = arg.split(None, 2) - - self.bestmove = None - if tokens[0] != "(none)": - try: - self.bestmove = self.board.parse_uci(tokens[0]) - except ValueError: - LOGGER.exception("exception parsing bestmove") - - self.ponder = None - if self.bestmove is not None and len(tokens) >= 3 and tokens[1] == "ponder" and tokens[2] != "(none)": - # The ponder move must be legal after the bestmove. Generally, we - # trust the engine on this. But we still have to convert - # non-UCI_Chess960 castling moves. - try: - self.ponder = chess.Move.from_uci(tokens[2]) - if self.ponder.from_square in [chess.E1, chess.E8] and self.ponder.to_square in [chess.C1, chess.C8, chess.G1, chess.G8]: - # Make a copy of the board to avoid race conditions. - board = self.board.copy(stack=False) - board.push(self.bestmove) - self.ponder = board.parse_uci(tokens[2]) - except ValueError: - LOGGER.exception("exception parsing bestmove ponder") - self.ponder = None - - self.bestmove_received.set() - - for info_handler in self.info_handlers: - info_handler.on_bestmove(self.bestmove, self.ponder) - - def _copyprotection(self, arg): - LOGGER.error("engine copyprotection not supported") - - def _registration(self, arg): - LOGGER.error("engine registration not supported") - - def _info(self, arg): - if not self.info_handlers: - return - - # Notify info handlers of start. - for info_handler in self.info_handlers: - info_handler.pre_info(arg) - - # Initialize parser state. - board = None - pv = None - score_kind = None - score_cp = None - score_mate = None - score_lowerbound = False - score_upperbound = False - refutation_move = None - refuted_by = [] - currline_cpunr = None - currline_moves = [] - string = [] - - def end_of_parameter(): - # Parameters with variable length can only be handled when the - # next parameter starts or at the end of the line. - - if pv is not None: - for info_handler in self.info_handlers: - info_handler.pv(pv) - - if score_cp is not None or score_mate is not None: - for info_handler in self.info_handlers: - info_handler.score(score_cp, score_mate, score_lowerbound, score_upperbound) - - if refutation_move is not None: - if refuted_by: - for info_handler in self.info_handlers: - info_handler.refutation(refutation_move, refuted_by) - else: - for info_handler in self.info_handlers: - info_handler.refutation(refutation_move, None) - - if currline_cpunr is not None: - for info_handler in self.info_handlers: - info_handler.currline(currline_cpunr, currline_moves) - - def handle_integer_token(token, fn): - try: - intval = int(token) - except ValueError: - LOGGER.exception("exception parsing integer token from info: %r", arg) - return - - for info_handler in self.info_handlers: - fn(info_handler, intval) - - def handle_float_token(token, fn): - try: - floatval = float(token) - except ValueError: - LOGGER.exception("exception parsing float token from info: %r", arg) - - for info_handler in self.info_handlers: - fn(info_handler, floatval) - - def handle_move_token(token, fn): - try: - move = chess.Move.from_uci(token) - except ValueError: - LOGGER.exception("exception parsing move token from info: %r", arg) - return - - for info_handler in self.info_handlers: - fn(info_handler, move) - - # Find multipv parameter first. - if "multipv" in arg: - current_parameter = None - for token in arg.split(" "): - if token == "string": - break - - if current_parameter == "multipv": - handle_integer_token(token, lambda handler, val: handler.multipv(val)) - - current_parameter = token - - # Parse all other parameters. - current_parameter = None - for token in arg.split(" "): - if current_parameter == "string": - string.append(token) - elif not token: - # Ignore extra spaces. Those can not be directly discarded, - # because they may occur in the string parameter. - pass - elif token in ["depth", "seldepth", "time", "nodes", "pv", - "multipv", "score", "currmove", "currmovenumber", - "hashfull", "nps", "tbhits", "cpuload", - "refutation", "currline", "ebf", "string"]: - end_of_parameter() - current_parameter = token - - pv = None - score_kind = None - score_mate = None - score_cp = None - score_lowerbound = False - score_upperbound = False - refutation_move = None - refuted_by = [] - currline_cpunr = None - currline_moves = [] - - if current_parameter == "pv": - pv = [] - - if current_parameter in ["refutation", "pv", "currline"]: - board = self.board.copy(stack=False) - elif current_parameter == "depth": - handle_integer_token(token, lambda handler, val: handler.depth(val)) - elif current_parameter == "seldepth": - handle_integer_token(token, lambda handler, val: handler.seldepth(val)) - elif current_parameter == "time": - handle_integer_token(token, lambda handler, val: handler.time(val)) - elif current_parameter == "nodes": - handle_integer_token(token, lambda handler, val: handler.nodes(val)) - elif current_parameter == "pv": - try: - pv.append(board.push_uci(token)) - except ValueError: - LOGGER.exception("exception parsing pv from info: %r, position at root: %s", arg, self.board.fen()) - elif current_parameter == "multipv": - # Ignore multipv. It was already parsed before anything else. - pass - elif current_parameter == "score": - if token in ["cp", "mate"]: - score_kind = token - elif token == "lowerbound": - score_lowerbound = True - elif token == "upperbound": - score_upperbound = True - elif score_kind == "cp": - try: - score_cp = int(token) - except ValueError: - LOGGER.exception("exception parsing score cp value from info: %r", arg) - elif score_kind == "mate": - try: - score_mate = int(token) - except ValueError: - LOGGER.exception("exception parsing score mate value from info: %r", arg) - elif current_parameter == "currmove": - handle_move_token(token, lambda handler, val: handler.currmove(val)) - elif current_parameter == "currmovenumber": - handle_integer_token(token, lambda handler, val: handler.currmovenumber(val)) - elif current_parameter == "hashfull": - handle_integer_token(token, lambda handler, val: handler.hashfull(val)) - elif current_parameter == "nps": - handle_integer_token(token, lambda handler, val: handler.nps(val)) - elif current_parameter == "tbhits": - handle_integer_token(token, lambda handler, val: handler.tbhits(val)) - elif current_parameter == "cpuload": - handle_integer_token(token, lambda handler, val: handler.cpuload(val)) - elif current_parameter == "refutation": - try: - if refutation_move is None: - refutation_move = board.push_uci(token) - else: - refuted_by.append(board.push_uci(token)) - except ValueError: - LOGGER.exception("exception parsing refutation from info: %r, position at root: %s", arg, self.board.fen()) - elif current_parameter == "currline": - try: - if currline_cpunr is None: - currline_cpunr = int(token) - else: - currline_moves.append(board.push_uci(token)) - except ValueError: - LOGGER.exception("exception parsing currline from info: %r, position at root: %s", arg, self.board.fen()) - elif current_parameter == "ebf": - handle_float_token(token, lambda handler, val: handler.ebf(val)) - - end_of_parameter() - - if string: - for info_handler in self.info_handlers: - info_handler.string(" ".join(string)) - - # Notify info handlers of end. - for info_handler in self.info_handlers: - info_handler.post_info() - - def _option(self, arg): - current_parameter = None - - name = [] - type = [] - default = [] - min = None - max = None - current_var = None - var = [] - - for token in arg.split(" "): - if token == "name" and not name: - current_parameter = "name" - elif token == "type" and not type: - current_parameter = "type" - elif token == "default" and not default: - current_parameter = "default" - elif token == "min" and min is None: - current_parameter = "min" - elif token == "max" and max is None: - current_parameter = "max" - elif token == "var": - current_parameter = "var" - if current_var is not None: - var.append(" ".join(current_var)) - current_var = [] - elif current_parameter == "name": - name.append(token) - elif current_parameter == "type": - type.append(token) - elif current_parameter == "default": - default.append(token) - elif current_parameter == "var": - current_var.append(token) - elif current_parameter == "min": - try: - min = int(token) - except ValueError: - LOGGER.exception("exception parsing option min") - elif current_parameter == "max": - try: - max = int(token) - except ValueError: - LOGGER.exception("exception parsing option max") - - if current_var is not None: - var.append(" ".join(current_var)) - - type = " ".join(type) - - default = " ".join(default) - if type == "check": - if default == "true": - default = True - elif default == "false": - default = False - else: - default = None - elif type == "spin": - try: - default = int(default) - except ValueError: - LOGGER.exception("exception parsing option spin default") - default = None - - option = Option(" ".join(name), type, default, min, max, var) - self.options[option.name] = option - - def _queue_command(self, command, async_callback): - try: - future = self.pool.submit(command) - except RuntimeError: - raise EngineTerminatedException() - - if async_callback is True: - return future - elif async_callback: - future.add_done_callback(async_callback) - return future - else: - # Avoid calling future.result() without a timeout. In Python 2 - # such a call cannot be interrupted. - while True: - try: - return future.result(timeout=FUTURE_POLL_TIMEOUT) - except concurrent.futures.TimeoutError: - pass - - def uci(self, *, async_callback=None): - """ - Tells the engine to use the UCI interface. - - This is mandatory before any other command. A conforming engine will - send its name, authors and available options. - - :return: Nothing - """ - def command(): - with self.semaphore: - with self.uciok_received: - self.send_line("uci") - self.uciok_received.wait() - - if self.terminated.is_set(): - raise EngineTerminatedException() - - return self._queue_command(command, async_callback) - - def debug(self, on, *, async_callback=None): - """ - Switch the debug mode on or off. - - In debug mode, the engine should send additional information to the - GUI to help with the debugging. Usually, this mode is off by default. - - :param on: bool - - :return: Nothing - """ - def command(): - with self.semaphore: - if on: - self.send_line("debug on") - else: - self.send_line("debug off") - - return self._queue_command(command, async_callback) - - def isready(self, *, async_callback=None): - """ - Command used to synchronize with the engine. - - The engine will respond as soon as it has handled all other queued - commands. - - :return: Nothing - """ - def command(): - with self.semaphore: - with self.readyok_received: - self.send_line("isready") - self.readyok_received.wait() - - if self.terminated.is_set(): - raise EngineTerminatedException() - - return self._queue_command(command, async_callback) - - def _setoption(self, options): - option_lines = [] - - for name, value in options.items(): - if name.lower() == "uci_chess960": - self.uci_chess960 = value - if name.lower() == "uci_variant": - self.uci_variant = value.lower() - - builder = ["setoption name", name, "value"] - if value is True: - builder.append("true") - elif value is False: - builder.append("false") - elif value is None: - builder.append("none") - else: - builder.append(str(value)) - - option_lines.append(" ".join(builder)) - - return option_lines - - def setoption(self, options, *, async_callback=None): - """ - Set values for the engine's available options. - - :param options: A dictionary with option names as keys. - - :return: Nothing - """ - option_lines = self._setoption(options) - - def command(): - with self.semaphore: - with self.readyok_received: - for option_line in option_lines: - self.send_line(option_line) - - self.send_line("isready") - self.readyok_received.wait() - - if self.terminated.is_set(): - raise EngineTerminatedException() - - return self._queue_command(command, async_callback) - - def ucinewgame(self, *, async_callback=None): - """ - Tell the engine that the next search will be from a different game. - - This can be a new game the engine should play or if the engine should - analyse a position from a different game. Using this command is - recommended, but not required. - - :return: Nothing - """ - # Warn if this is called while the engine is still calculating. - with self.state_changed: - if not self.idle: - LOGGER.warning("ucinewgame while engine is busy") - - def command(): - with self.semaphore: - with self.readyok_received: - self.send_line("ucinewgame") - - self.send_line("isready") - self.readyok_received.wait() - - if self.terminated.is_set(): - raise EngineTerminatedException() - - return self._queue_command(command, async_callback) - - def position(self, board, *, async_callback=None): - """ - Set up a given position. - - Rather than sending just the final FEN, the initial FEN and all moves - leading up to the position will be sent. This will allow the engine - to use the move history (for example to detect repetitions). - - If the position is from a new game, it is recommended to use the - *ucinewgame* command before the *position* command. - - :param board: A *chess.Board*. - - :return: Nothing - - :raises: :exc:`~chess.uci.EngineStateException` if the engine is still - calculating. - """ - # Raise if this is called while the engine is still calculating. - with self.state_changed: - if not self.idle: - raise EngineStateException("position command while engine is busy") - - # Set UCI_Variant and UCI_Chess960. - options = {} - - uci_variant = type(board).uci_variant - if uci_variant != (self.uci_variant or "chess"): - if self.uci_variant is None: - LOGGER.warning("engine may not support UCI_Variant or has not been initialized with 'uci' command") - options["UCI_Variant"] = type(board).uci_variant - - if bool(self.uci_chess960) != board.chess960: - if self.uci_chess960 is None: - LOGGER.warning("engine may not support UCI_Chess960 or has not been initialized with 'uci' command") - options["UCI_Chess960"] = board.chess960 - - option_lines = self._setoption(options) - - # Send starting position. - builder = ["position"] - root = board.root() - fen = root.fen() - if uci_variant == "chess" and fen == chess.STARTING_FEN: - builder.append("startpos") - else: - builder.append("fen") - builder.append(root.shredder_fen() if self.uci_chess960 else fen) - - # Send moves. - if board.move_stack: - builder.append("moves") - builder.extend(move.uci() for move in board.move_stack) - - self.board = board.copy(stack=False) - - def command(): - with self.semaphore: - for option_line in option_lines: - self.send_line(option_line) - - self.send_line(" ".join(builder)) - - if self.terminated.is_set(): - raise EngineTerminatedException() - - return self._queue_command(command, async_callback) - - def go(self, *, searchmoves=None, ponder=False, wtime=None, btime=None, winc=None, binc=None, movestogo=None, depth=None, nodes=None, mate=None, movetime=None, infinite=False, async_callback=None): - """ - Start calculating on the current position. - - All parameters are optional, but there should be at least one of - *depth*, *nodes*, *mate*, *infinite* or some time control settings, - so that the engine knows how long to calculate. - - Note that when using *infinite* or *ponder*, the engine will not stop - until it is told to. - - :param searchmoves: Restrict search to moves in this list. - :param ponder: Bool to enable pondering mode. The engine will not stop - pondering in the background until a *stop* command is received. - :param wtime: Integer of milliseconds White has left on the clock. - :param btime: Integer of milliseconds Black has left on the clock. - :param winc: Integer of white Fisher increment. - :param binc: Integer of black Fisher increment. - :param movestogo: Number of moves to the next time control. If this is - not set, but wtime or btime are, then it is sudden death. - :param depth: Search *depth* ply only. - :param nodes: Search so many *nodes* only. - :param mate: Search for a mate in *mate* moves. - :param movetime: Integer. Search exactly *movetime* milliseconds. - :param infinite: Search in the background until a *stop* command is - received. - - :return: A tuple of two elements. The first is the best move according - to the engine. The second is the ponder move. This is the reply - as sent by the engine. Either of the elements may be ``None``. - - :raises: :exc:`~chess.uci.EngineStateException` if the engine is - already calculating. - """ - with self.state_changed: - if not self.idle: - raise EngineStateException("go command while engine is already busy") - - self.idle = False - self.search_started.clear() - self.bestmove_received.clear() - self.pondering = ponder - self.state_changed.notify_all() - - for info_handler in self.info_handlers: - info_handler.on_go() - - builder = ["go"] - - if ponder: - builder.append("ponder") - - if wtime is not None: - builder.append("wtime") - builder.append(str(int(wtime))) - - if btime is not None: - builder.append("btime") - builder.append(str(int(btime))) - - if winc is not None: - builder.append("winc") - builder.append(str(int(winc))) - - if binc is not None: - builder.append("binc") - builder.append(str(int(binc))) - - if movestogo is not None and movestogo > 0: - builder.append("movestogo") - builder.append(str(int(movestogo))) - - if depth is not None: - builder.append("depth") - builder.append(str(int(depth))) - - if nodes is not None: - builder.append("nodes") - builder.append(str(int(nodes))) - - if mate is not None: - builder.append("mate") - builder.append(str(int(mate))) - - if movetime is not None: - builder.append("movetime") - builder.append(str(int(movetime))) - - if infinite: - builder.append("infinite") - - if searchmoves: - builder.append("searchmoves") - for move in searchmoves: - builder.append(self.board.uci(move)) - - def command(): - with self.semaphore: - self.send_line(" ".join(builder)) - self.search_started.set() - - self.bestmove_received.wait() - - with self.state_changed: - self.idle = True - self.state_changed.notify_all() - - if self.terminated.is_set(): - raise EngineTerminatedException() - - return BestMove(self.bestmove, self.ponder) - - return self._queue_command(command, async_callback) - - def stop(self, *, async_callback=None): - """ - Stop calculating as soon as possible. - - :return: Nothing. - """ - # Only send stop when the engine is actually searching. - def command(): - with self.semaphore: - with self.state_changed: - if not self.idle: - self.search_started.wait() - - backoff = 0.5 - while not self.bestmove_received.is_set() and not self.terminated.is_set(): - if self.idle: - break - else: - self.send_line("stop") - self.bestmove_received.wait(backoff) - backoff *= 2 - - self.idle = True - self.state_changed.notify_all() - - if self.terminated.is_set(): - raise EngineTerminatedException() - - return self._queue_command(command, async_callback) - - def ponderhit(self, *, async_callback=None): - """ - May be sent if the expected ponder move has been played. - - The engine should continue searching, but should switch from pondering - to normal search. - - :return: Nothing. - - :raises: :exc:`~chess.uci.EngineStateException` if the engine is not - currently searching in ponder mode. - """ - with self.state_changed: - if self.idle: - raise EngineStateException("ponderhit but not searching") - if not self.pondering: - raise EngineStateException("ponderhit but not pondering") - - self.pondering = False - self.state_changed.notify_all() - - def command(): - self.search_started.wait() - with self.semaphore: - self.send_line("ponderhit") - - return self._queue_command(command, async_callback) - - def quit(self, *, async_callback=None): - """ - Quit the engine as soon as possible. - - :return: The return code of the engine process. - """ - def command(): - with self.semaphore: - self.send_line("quit") - - self.terminated.wait() - return self.return_code - - return self._queue_command(command, async_callback) - - def _queue_termination(self, async_callback): - def wait(): - self.terminated.wait() - return self.return_code - - try: - return self._queue_command(wait, async_callback) - except EngineTerminatedException: - assert self.terminated.is_set() - - future = concurrent.futures.Future() - future.set_result(self.return_code) - if async_callback is True: - return future - elif async_callback: - future.add_done_callback(async_callback) - else: - return future.result() - - def terminate(self, *, async_callback=None): - """ - Terminate the engine. - - This is not an UCI command. It instead tries to terminate the engine - on operating system level, like sending SIGTERM on Unix - systems. If possible, first try the *quit* command. - - :return: The return code of the engine process (or a Future). - """ - self.process.terminate() - return self._queue_termination(async_callback) - - def kill(self, *, async_callback=None): - """ - Kill the engine. - - Forcefully kill the engine process, like by sending SIGKILL. - - :return: The return code of the engine process (or a Future). - """ - self.process.kill() - return self._queue_termination(async_callback) - - def is_alive(self): - """Poll the engine process to check if it is alive.""" - return self.process.is_alive() - - -def popen_engine(command, *, engine_cls=Engine, setpgrp=False, **kwargs): - """ - Opens a local chess engine process. - - No initialization commands are sent, so do not forget to send the - mandatory *uci* command. - - >>> engine = chess.uci.popen_engine("/usr/bin/stockfish") - >>> engine.uci() - >>> engine.name - 'Stockfish 8 64 POPCNT' - >>> engine.author - 'T. Romstad, M. Costalba, J. Kiiski, G. Linscott' - - :param command: - :param engine_cls: - :param setpgrp: Open the engine process in a new process group. This will - stop signals (such as keyboard interrupts) from propagating from the - parent process. Defaults to ``False``. - """ - return _popen_engine(command, engine_cls, setpgrp, **kwargs) - - -def spur_spawn_engine(shell, command, *, engine_cls=Engine): - """ - Spawns a remote engine using a `Spur`_ shell. - - >>> import spur - >>> - >>> shell = spur.SshShell(hostname="localhost", username="username", password="pw") - >>> engine = chess.uci.spur_spawn_engine(shell, ["/usr/bin/stockfish"]) - >>> engine.uci() - - .. _Spur: https://pypi.python.org/pypi/spur - """ - return _spur_spawn_engine(shell, command, engine_cls) diff --git a/chess/xboard.py b/chess/xboard.py deleted file mode 100644 index 0d7c51fcc..000000000 --- a/chess/xboard.py +++ /dev/null @@ -1,1483 +0,0 @@ -# -*- coding: utf-8 -*- -# -# This file is part of the python-chess library. -# Copyright (C) 2017-2019 Manik Charan -# Copyright (C) 2017-2019 Niklas Fiekas -# Copyright (C) 2017 Cash Costello -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import concurrent.futures -import shlex -import threading -import warnings -import textwrap - -from chess._engine import EngineTerminatedException -from chess._engine import EngineStateException -from chess._engine import Option -from chess._engine import OptionMap -from chess._engine import LOGGER -from chess._engine import FUTURE_POLL_TIMEOUT -from chess._engine import _popen_engine -from chess._engine import _spur_spawn_engine - -import chess - - -warnings.warn(textwrap.dedent("""\ - The chess.xboard module is deprecated in favor of - chess.engine . - - Please consider updating and open an issue - if your use case - is not covered by the new API."""), DeprecationWarning, stacklevel=2) - - -DUMMY_RESPONSES = [ENGINE_RESIGN, GAME_DRAW] = [-1, -2] -RESULTS = [WHITE_WIN, BLACK_WIN, DRAW] = ["1-0", "0-1", "1/2-1/2"] - - -def try_move(board, move): - try: - move = board.push_uci(move) - except ValueError: - try: - move = board.push_san(move) - except ValueError: - LOGGER.exception("exception parsing pv") - return None - return move - - -class DrawHandler: - """ - Chess engines may send a draw offer after playing its move and may receive - one during an offer during its calculations. A draw handler can be used to - send, or react to, this information. - - >>> # Register a standard draw handler. - >>> draw_handler = chess.xboard.DrawHandler() - >>> engine.draw_handler = draw_handler - - >>> # Start a search. - >>> engine.setboard(board) - >>> engine.st(1) - >>> engine.go() - e2e4 - offer draw - >>> - >>> # Do some relevant work. - >>> # Check if a draw offer is pending at any given time. - >>> draw_handler.pending_offer - True - - See :attr:`~chess.xboard.DrawHandler.pending_offer` for a way to access - this flag in a thread-safe way during search. - - If you want to be notified whenever new information is available, - you would usually subclass the :class:`~chess.xboard.DrawHandler` class: - - >>> class MyHandler(chess.xboard.DrawHandler): - ... def offer_draw(self): - ... # Called whenever offer draw has been processed. - ... super().offer_draw() - ... print(self.pending_offer) - """ - def __init__(self): - self.lock = threading.Lock() - self.draw_offered = threading.Condition(self.lock) - self.pending_offer = False - - def pre_offer(self): - """ - Processes the newly received draw offer. - - When subclassing, remember to call this method of the parent class in - order to keep the locking intact. - """ - self.lock.acquire() - - def post_offer(self): - """ - Finishes processing of the newly received draw offer. - - When subclassing, remember to call this method of the parent class in - order to keep the locking intact. - """ - self.lock.release() - - def offer_draw(self): - """Offers a draw.""" - with self.lock: - self.pending_offer = True - self.draw_offered.notify_all() - - def clear_offer(self): - """Declines the draw offer.""" - with self.lock: - self.pending_offer = False - - def acquire(self, blocking=True): - return self.lock.acquire(blocking) - - def release(self): - return self.lock.release() - - def __enter__(self): - self.acquire() - return self.pending_offer - - def __exit__(self, exc_type, exc_value, traceback): - self.release() - - -class PostHandler: - """ - Chess engines may send information about their calculations if enabled - via the *post* command. Post handlers can be used to aggregate or react - to this information. - - >>> # Register a standard post handler. - >>> post_handler = chess.xboard.PostHandler() - >>> engine.post_handlers.append(post_handler) - - >>> # Start a search. - >>> engine.setboard(board) - >>> engine.st(1) - >>> engine.go() - e2e4 - >>> - >>> # Retrieve the score of the mainline (PV1) after search is completed. - >>> # Note that the score is relative to the side to move. - >>> post_handler.post["score"] - 34 - - See :attr:`~chess.xboard.PostHandler.post` for a way to access this dictionary - in a thread-safe way during search. - - If you want to be notified whenever new information is available, - you would usually subclass the :class:`~chess.xboard.PostHandler` class: - - >>> class MyHandler(chess.xboard.PostHandler): - ... def post_info(self): - ... # Called whenever a complete post line has been processed. - ... super().post_info() - ... print(self.post) - """ - def __init__(self): - self.lock = threading.Lock() - - self.post = {"pv": {}} - - def depth(self, depth): - """Receives the search depth in plies.""" - self.post["depth"] = depth - - def score(self, score): - """Receives the score in centipawns.""" - self.post["score"] = score - - def time(self, time): - """Receives the new time searched in centiseconds.""" - self.post["time"] = time - - def nodes(self, nodes): - """Receives the number of nodes searched.""" - self.post["nodes"] = nodes - - def pv(self, moves): - """Receives the principal variation as a list of moves.""" - self.post["pv"] = moves - - def pre_info(self): - """ - Receives new info lines before they are processed. - - When subclassing, remember to call this method of the parent class in - order to keep the locking intact. - """ - self.lock.acquire() - - def post_info(self): - """ - Processing of a new info line has been finished. - - When subclassing, remember to call this method of the parent class in - order to keep the locking intact. - """ - self.lock.release() - - def on_move(self, move): - """Receives a new move.""" - pass - - def on_go(self): - """Notified when a *go* command is beeing sent.""" - with self.lock: - self.post.clear() - self.post["pv"] = {} - - def acquire(self, blocking=True): - return self.lock.acquire(blocking) - - def release(self): - return self.lock.release() - - def __enter__(self): - self.acquire() - return self.post - - def __exit__(self, exc_type, exc_value, traceback): - self.release() - - -class FeatureMap: - def __init__(self): - # Populated with defaults to begin with. - self._features = { - "ping": 0, # TODO: Remove dependency of the xboard module on ping - "setboard": 0, - "playother": 0, - "san": 0, - "usermove": 0, - "time": 1, - "draw": 1, - "sigint": 1, - "sigterm": 1, - "reuse": 1, - "analyze": 1, - "myname": None, - "variants": None, - "colors": 1, - "ics": 0, - "name": None, - "pause": 0, - "nps": 1, - "debug": 0, - "memory": 0, - "smp": 0, - "egt": [], - "option": OptionMap(), - "done": None - } - - def set_feature(self, key, value): - if key == "egt": - for egt_type in value.split(","): - self._features["egt"].append(egt_type) - else: - try: - value = int(value) - except ValueError: - pass - - try: - self._features[key] = value - except KeyError: - LOGGER.exception("exception looking up feature") - - def get_option(self, key): - try: - return self._features["option"][key] - except KeyError: - LOGGER.exception("exception looking up option") - - def set_option(self, key, value): - try: - self._features["option"][key] = value - except KeyError: - LOGGER.exception("exception looking up option") - - def get(self, key): - try: - return self._features[key] - except KeyError: - LOGGER.exception("exception looking up feature") - - def supports(self, key): - return self.get(key) == 1 - - -class Engine: - def __init__(self, Executor=concurrent.futures.ThreadPoolExecutor): - self.idle = True - self.state_changed = threading.Condition() - self.semaphore = threading.Semaphore() - self.search_started = threading.Event() - - self.board = chess.Board() - - self.name = None - self.author = None - self.supported_variants = [] - self.features = FeatureMap() - self.pong = threading.Event() - self.ping_num = 123 - self.pong_received = threading.Condition() - self.auto_force = False - self.in_force = False - self.end_result = None - - self.move = None - self.move_received = threading.Event() - - self.ponder_on = None - self.ponder_move = None - - self.return_code = None - self.terminated = threading.Event() - - self.post_handlers = [] - self.draw_handler = None - self.engine_offered_draw = False - - self.pool = Executor(max_workers=3) - self.process = None - - def on_process_spawned(self, process): - self.process = process - - def send_line(self, line): - LOGGER.debug("%s << %s", self.process, line) - return self.process.send_line(line) - - def on_line_received(self, buf): - LOGGER.debug("%s >> %s", self.process, buf) - - if buf.startswith("feature"): - return self._feature(buf[8:]) - elif buf.startswith("Illegal"): - split_buf = buf.split() - illegal_move = split_buf[-1] - exception_msg = "Engine received an illegal move: {}".format(illegal_move) - if len(split_buf) == 4: - reason = split_buf[2][1:-2] - exception_msg = " ".join([exception_msg, reason]) - raise EngineStateException(exception_msg) - elif buf.startswith("Error"): - err_msg = buf.split()[1][1:-2] - raise EngineStateException("Engine produced an error: {}".format(err_msg)) - elif buf.startswith("#"): - return - - command_and_args = buf.split() - if not command_and_args: - return - - if len(command_and_args) == 1: - if command_and_args[0] == "resign": - return self._resign() - elif len(command_and_args) == 2: - if command_and_args[0] == "pong": - return self._pong(command_and_args[1]) - elif command_and_args[0] == "move": - return self._move(command_and_args[1]) - elif command_and_args[0] == "offer" and command_and_args[1] == "draw": - return self._offer_draw() - elif command_and_args[0] == "Hint:": - return self._hint(command_and_args[1]) - elif len(command_and_args) >= 5: - return self._post(buf) - - def on_terminated(self): - self.return_code = self.process.wait_for_return_code() - self.pool.shutdown(wait=False) - self.terminated.set() - - # Wake up waiting commands. - self.move_received.set() - with self.pong_received: - self.pong_received.notify_all() - with self.state_changed: - self.state_changed.notify_all() - - def _resign(self): - # TODO: Logic is a bit hacky, needs clearer code. - self.result(RESULTS[int(self.idle) ^ int(self.board.turn)]) - self.move = ENGINE_RESIGN - self.move_received.set() - - def _offer_draw(self): - if self.draw_handler: - if self.draw_handler.pending_offer and not self.engine_offered_draw: - self.result(DRAW) - self.move = GAME_DRAW - self.move_received.set() - else: - self.engine_offered_draw = True - self.draw_handler.offer_draw() - - def _feature(self, features): - """ - Does not conform to the CECP spec regarding `done` and instead reads all - the features atomically. - """ - def _option(feature): - params = feature.split() - name = params[0] - type = params[1][1:] - default = None - min = None - max = None - var = [] - if type == "combo": - choices = params[2:] - for choice in choices: - if choice == "///": - continue - elif choice[0] == "*": - default = choice[1:] - var.append(choice[1:]) - else: - var.append(choice) - elif type == "check": - default = int(params[2]) - elif type in ("string", "file", "path"): - if len(params) > 2: - default = params[2] - else: - default = "" - elif type == "spin": - default = int(params[2]) - min = int(params[3]) - max = int(params[4]) - option = Option(name, type, default, min, max, var) - self.features.set_option(option.name, option) - return - - features = shlex.split(features) - feature_map = [feature.split("=") for feature in features] - for (key, value) in feature_map: - if key == "variants": - self.supported_variants = value.split(",") - elif key == "option": - _option(value) - else: - self.features.set_feature(key, value) - - def _pong(self, pong_arg): - try: - pong_num = int(pong_arg) - except ValueError: - LOGGER.exception("exception parsing pong") - - if self.ping_num == pong_num: - self.pong.set() - with self.pong_received: - self.pong_received.notify_all() - - def _move(self, arg): - self.move = None - try: - self.move = self.board.parse_uci(arg) - except ValueError: - try: - self.move = self.board.parse_san(arg) - except ValueError: - LOGGER.exception("exception parsing move") - - self.move_received.set() - if self.draw_handler: - self.draw_handler.clear_offer() - self.engine_offered_draw = False - for post_handler in self.post_handlers: - post_handler.on_move(self.move) - - def _hint(self, arg): - # If we have finished search and received a best move, - # the Hint tells us the ponder move for supported engines - if self.move_received.is_set(): - self.ponder_move = arg - - def _post(self, arg): - if not self.post_handlers: - return - - # Notify post handlers of start. - for post_handler in self.post_handlers: - post_handler.pre_info() - - def handle_integer_token(token, fn): - try: - intval = int(token) - except ValueError: - LOGGER.exception("exception parsing integer token") - return - - for post_handler in self.post_handlers: - fn(post_handler, intval) - - pv = [] - board = self.board.copy(stack=False) - - # Ponder may be handled in one (or both) of two ways according to the - # spec. Either through a 'Hint: ' or through '5. ... () pv'. - # It is unclear whether the 'Hint: ' variation is persistent - # until changed or whether it must be given before each ponder post. - - # Assumption: The hint ponder overrides the pv ponder. - # They should be the same in a normal scenario. - - making_pv_ponder = False # For the '()' variation - hint_ponder_played = False # For the 'Hint: ' variation - if self.ponder_move: - try_move(board, self.ponder_move) - hint_ponder_played = True - - tokens = arg.split() - # Order: