Skip to content

Commit 8507caa

Browse files
committed
Return BestMove from AnalysisResult.wait() (fixes niklasf#433)
1 parent ab09811 commit 8507caa

4 files changed

Lines changed: 75 additions & 34 deletions

File tree

CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Bugfixes:
2828

2929
Features:
3030

31+
* `chess.engine.AnalysisResult.wait()` now returns `chess.engine.BestMove`.
3132
* Added `empty_square` parameter for `chess.Board.unicode()` with better
3233
aligned default (⭘).
3334

chess/engine.py

Lines changed: 56 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1281,27 +1281,13 @@ def _bestmove(self, engine: UciProtocol, arg: str) -> None:
12811281
if self.pondering:
12821282
self.pondering = False
12831283
elif not self.result.cancelled():
1284-
tokens = arg.split(None, 2)
1284+
best = _parse_uci_bestmove(engine.board, arg)
1285+
self.result.set_result(PlayResult(best.move, best.ponder, self.info))
12851286

1286-
bestmove = None
1287-
if tokens[0] != "(none)":
1288-
try:
1289-
bestmove = engine.board.parse_uci(tokens[0])
1290-
except ValueError as err:
1291-
raise EngineError(err)
1292-
1293-
pondermove = None
1294-
if bestmove is not None and len(tokens) >= 3 and tokens[1] == "ponder" and tokens[2] != "(none)":
1295-
engine.board.push(bestmove)
1296-
try:
1297-
pondermove = engine.board.push_uci(tokens[2])
1298-
except ValueError:
1299-
LOGGER.exception("engine sent invalid ponder move")
1300-
1301-
self.result.set_result(PlayResult(bestmove, pondermove, self.info))
1302-
1303-
if ponder and pondermove:
1287+
if ponder and best.move and best.ponder:
13041288
self.pondering = True
1289+
engine.board.push(best.move)
1290+
engine.board.push(best.ponder)
13051291
engine._position(engine.board)
13061292
engine._go(limit, ponder=True)
13071293

@@ -1369,8 +1355,9 @@ def _info(self, engine: UciProtocol, arg: str) -> None:
13691355
def _bestmove(self, engine: UciProtocol, arg: str) -> None:
13701356
if not self.result.done():
13711357
raise EngineError("was not searching, but engine sent bestmove")
1358+
best = _parse_uci_bestmove(engine.board, arg)
13721359
self.set_finished()
1373-
self.analysis.set_finished()
1360+
self.analysis.set_finished(best)
13741361

13751362
def cancel(self, engine: UciProtocol) -> None:
13761363
engine.send_line("stop")
@@ -1479,6 +1466,27 @@ def _parse_uci_info(arg: str, root_board: chess.Board, selector: Info = INFO_ALL
14791466

14801467
return info
14811468

1469+
def _parse_uci_bestmove(board: chess.Board, args: str) -> "BestMove":
1470+
tokens = args.split(None, 2)
1471+
1472+
move = None
1473+
ponder = None
1474+
if tokens[0] != "(none)":
1475+
try:
1476+
move = board.push_uci(tokens[0])
1477+
except ValueError as err:
1478+
raise EngineError(err)
1479+
1480+
try:
1481+
if len(tokens) >= 3 and tokens[1] == "ponder" and tokens[2] != "(none)":
1482+
ponder = board.parse_uci(tokens[2])
1483+
except ValueError:
1484+
LOGGER.exception("engine sent invalid ponder move")
1485+
finally:
1486+
board.pop()
1487+
1488+
return BestMove(move, ponder)
1489+
14821490

14831491
class UciOptionMap(MutableMapping[str, T]):
14841492
"""Dictionary with case-insensitive keys."""
@@ -1868,6 +1876,7 @@ async def analysis(self, board: chess.Board, limit: Optional[Limit] = None, *, m
18681876
class XBoardAnalysisCommand(BaseCommand[XBoardProtocol, AnalysisResult]):
18691877
def start(self, engine: XBoardProtocol) -> None:
18701878
self.stopped = False
1879+
self.best_move: Optional[chess.Move] = None
18711880
self.analysis = AnalysisResult(stop=lambda: self.cancel(engine))
18721881
self.final_pong: Optional[str] = None
18731882

@@ -1908,6 +1917,10 @@ def _post(self, engine: XBoardProtocol, line: str) -> None:
19081917
post_info = _parse_xboard_post(line, engine.board, info | INFO_BASIC)
19091918
self.analysis.post(post_info)
19101919

1920+
pv = post_info.get("pv")
1921+
if pv:
1922+
self.best_move = pv[0]
1923+
19111924
if limit is not None:
19121925
if limit.time is not None and typing.cast(float, post_info.get("time", 0)) >= limit.time:
19131926
self.cancel(engine)
@@ -1924,7 +1937,7 @@ def end(self, engine: XBoardProtocol) -> None:
19241937
self.time_limit_handle.cancel()
19251938

19261939
self.set_finished()
1927-
self.analysis.set_finished()
1940+
self.analysis.set_finished(BestMove(self.best_move, None))
19281941

19291942
def cancel(self, engine: XBoardProtocol) -> None:
19301943
if self.stopped:
@@ -2045,7 +2058,7 @@ def _parse_xboard_post(line: str, root_board: chess.Board, selector: Info = INFO
20452058
pv_tokens.insert(0, token)
20462059
break
20472060

2048-
if len(integer_tokens) < 4 or not selector:
2061+
if len(integer_tokens) < 4:
20492062
return info
20502063

20512064
# Required integer tokens.
@@ -2079,9 +2092,6 @@ def _parse_xboard_post(line: str, root_board: chess.Board, selector: Info = INFO
20792092
info["tbhits"] = integer_tokens.pop(0)
20802093

20812094
# Principal variation.
2082-
if not (selector & INFO_PV):
2083-
return info
2084-
20852095
pv = []
20862096
board = root_board.copy(stack=False)
20872097
for token in pv_tokens:
@@ -2092,11 +2102,26 @@ def _parse_xboard_post(line: str, root_board: chess.Board, selector: Info = INFO
20922102
pv.append(board.push_xboard(token))
20932103
except ValueError:
20942104
break
2105+
2106+
if not (selector & INFO_PV):
2107+
break
20952108
info["pv"] = pv
20962109

20972110
return info
20982111

20992112

2113+
class BestMove:
2114+
"""Returned by :func:`chess.engine.AnalysisResult.wait()`."""
2115+
2116+
def __init__(self, move: Optional[chess.Move], ponder: Optional[chess.Move]):
2117+
self.move = move
2118+
self.ponder = ponder
2119+
2120+
def __repr__(self) -> str:
2121+
return "<{} at {:#x} (move={}, ponder={}>".format(
2122+
type(self).__name__, id(self), self.move, self.ponder)
2123+
2124+
21002125
class AnalysisResult:
21012126
"""
21022127
Handle to ongoing engine analysis.
@@ -2112,7 +2137,7 @@ def __init__(self, stop: Optional[Callable[[], None]] = None):
21122137
self._queue: asyncio.Queue[InfoDict] = asyncio.Queue()
21132138
self._posted_kork = False
21142139
self._seen_kork = False
2115-
self._finished: asyncio.Future[None] = asyncio.Future()
2140+
self._finished: asyncio.Future[BestMove] = asyncio.Future()
21162141
self.multipv: List[InfoDict] = [{}]
21172142

21182143
def post(self, info: InfoDict) -> None:
@@ -2132,9 +2157,9 @@ def _kork(self):
21322157
self._posted_kork = True
21332158
self._queue.put_nowait({})
21342159

2135-
def set_finished(self) -> None:
2160+
def set_finished(self, best: BestMove) -> None:
21362161
if not self._finished.done():
2137-
self._finished.set_result(None)
2162+
self._finished.set_result(best)
21382163
self._kork()
21392164

21402165
def set_exception(self, exc: Exception) -> None:
@@ -2151,9 +2176,9 @@ def stop(self) -> None:
21512176
self._stop()
21522177
self._stop = None
21532178

2154-
async def wait(self) -> None:
2179+
async def wait(self) -> BestMove:
21552180
"""Waits until the analysis is complete (or stopped)."""
2156-
await self._finished
2181+
return await self._finished
21572182

21582183
async def get(self) -> InfoDict:
21592184
"""
@@ -2464,7 +2489,7 @@ def stop(self) -> None:
24642489
with self.simple_engine._not_shut_down():
24652490
self.simple_engine.protocol.loop.call_soon_threadsafe(self.inner.stop)
24662491

2467-
def wait(self) -> None:
2492+
def wait(self) -> BestMove:
24682493
with self.simple_engine._not_shut_down():
24692494
future = asyncio.run_coroutine_threadsafe(self.inner.wait(), self.simple_engine.protocol.loop)
24702495
return future.result()

docs/engine.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,17 @@ Example: Stream information from the engine and stop on an arbitrary condition.
254254
A list of dictionaries with aggregated information sent by the engine.
255255
One item for each root move.
256256

257+
.. autoclass:: chess.engine.BestMove
258+
:members:
259+
260+
.. py:attribute:: move
261+
262+
The best move accordig to the engine, or ``None``.
263+
264+
.. py:attribute:: ponder
265+
266+
The response that the engine expects after *move*, or ``None``.
267+
257268
Options
258269
-------
259270

test.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2902,7 +2902,9 @@ async def main():
29022902
mock.expect("stop", ["bestmove e2e4"])
29032903
result = await protocol.analysis(chess.Board())
29042904
result.stop()
2905-
await result.wait()
2905+
best = await result.wait()
2906+
self.assertEqual(best.move, chess.Move.from_uci("e2e4"))
2907+
self.assertTrue(best.ponder is None)
29062908
mock.assert_done()
29072909

29082910
# Explicitly disable.
@@ -2913,10 +2915,12 @@ async def main():
29132915
# Analyse again.
29142916
mock.expect("position startpos")
29152917
mock.expect("go infinite")
2916-
mock.expect("stop", ["bestmove e2e4"])
2918+
mock.expect("stop", ["bestmove e2e4 ponder e7e5"])
29172919
result = await protocol.analysis(chess.Board())
29182920
result.stop()
2919-
await result.wait()
2921+
best = await result.wait()
2922+
self.assertEqual(best.move, chess.Move.from_uci("e2e4"))
2923+
self.assertEqual(best.ponder, chess.Move.from_uci("e7e5"))
29202924
mock.assert_done()
29212925

29222926
asyncio.set_event_loop_policy(chess.engine.EventLoopPolicy())

0 commit comments

Comments
 (0)