diff --git a/chess_cli/__init__.py b/chess_cli/__init__.py index fc7e3ef..d79b416 100644 --- a/chess_cli/__init__.py +++ b/chess_cli/__init__.py @@ -1,5 +1,6 @@ # Add the dlls directory to path: import os + basepath = os.path.dirname(os.path.abspath(__file__)) dllspath = os.path.join(basepath, "..", "dlls") -os.environ['PATH'] = dllspath + os.pathsep + os.environ['PATH'] +os.environ["PATH"] = dllspath + os.pathsep + os.environ["PATH"] diff --git a/chess_cli/analysis.py b/chess_cli/analysis.py index 80252db..7bd5604 100644 --- a/chess_cli/analysis.py +++ b/chess_cli/analysis.py @@ -1,4 +1,3 @@ -import asyncio from collections import defaultdict from collections.abc import Mapping from contextlib import suppress @@ -9,8 +8,8 @@ import chess.engine import chess.pgn -from .base import CommandFailure, InitArgs -from .engine import ENGINE_TIMEOUT, Engine +from .base import InitArgs +from .engine import Engine @dataclass @@ -68,36 +67,34 @@ async def start_analysis( ) -> None: if engine in self._running_analyses: return - try: - async with asyncio.timeout(ENGINE_TIMEOUT): - analysis: AnalysisInfo = AnalysisInfo( - result=await self.loaded_engines[engine].engine.analysis( - self.game_node.board(), limit=limit, multipv=number_of_moves, game="this" - ), - engine=engine, - board=self.game_node.board(), - san=( - self.game_node.san() - if isinstance(self.game_node, chess.pgn.ChildNode) - else None - ), - ) - except TimeoutError: - raise chess.engine.EngineError(f"Timeout") + async with self.engine_timeout(engine, long=True): + analysis: AnalysisInfo = AnalysisInfo( + result=await self.loaded_engines[engine].engine.analysis( + self.game_node.board(), limit=limit, multipv=number_of_moves, game="this" + ), + engine=engine, + board=self.game_node.board(), + san=( + self.game_node.san() + if isinstance(self.game_node, chess.pgn.ChildNode) + else None + ), + ) self._analyses.add(analysis) self._running_analyses[engine] = analysis self._analysis_by_node[self.game_node][engine] = analysis - def stop_analysis(self, engine: str) -> None: - self._running_analyses[engine].result.stop() - del self._running_analyses[engine] + def stop_analysis(self, engine: str, remove_auto: bool = True) -> None: with suppress(KeyError): - self._auto_analysis_engines.remove(engine) + self._running_analyses[engine].result.stop() + del self._running_analyses[engine] + if remove_auto: + with suppress(KeyError): + self._auto_analysis_engines.remove(engine) @override async def close_engine(self, name: str) -> None: - if name in self.running_analyses: - self.stop_analysis(name) + self.stop_analysis(name) await super().close_engine(name) async def update_auto_analysis(self) -> None: @@ -106,12 +103,8 @@ async def update_auto_analysis(self) -> None: engine in self._running_analyses and self._running_analyses[engine].board != self.game_node.board() ): - self.stop_analysis(engine) - try: - await self.start_analysis(engine, self._auto_analysis_number_of_moves) - except chess.engine.EngineError as e: - self._auto_analysis_engines.remove(engine) - raise CommandFailure(f"Engine {engine} terminated unexpectedly: {e}") + self.stop_analysis(engine, remove_auto=False) + await self.start_analysis(engine, self._auto_analysis_number_of_moves) async def start_auto_analysis(self, engine: str, number_of_moves: int) -> None: """Start auto analysis on the current position.""" diff --git a/chess_cli/analysis_cmds.py b/chess_cli/analysis_cmds.py index d805200..43e0e3e 100644 --- a/chess_cli/analysis_cmds.py +++ b/chess_cli/analysis_cmds.py @@ -104,10 +104,10 @@ async def do_analysis(self, args) -> None: async def analysis_start(self, args) -> None: engine: str = self.get_selected_engine() if engine in self.analysis_by_node[self.game_node]: - answer: bool = yes_no_dialog( + answer: bool = await yes_no_dialog( title=f"Error: There's allready an analysis made by {engine} at this move.", text="Do you want to remove it and restart the analysis?", - ).run() + ).run_async() if answer: await self.exec_cmd("analysis rm") else: diff --git a/chess_cli/engine.py b/chess_cli/engine.py index fa661cf..9fe8cb4 100644 --- a/chess_cli/engine.py +++ b/chess_cli/engine.py @@ -5,17 +5,19 @@ import queue import shutil from collections import deque -from collections.abc import Mapping, Sequence -from contextlib import suppress +from collections.abc import AsyncGenerator, Mapping, Sequence +from contextlib import asynccontextmanager, suppress from dataclasses import dataclass from typing import assert_never, override import chess.engine +from prompt_toolkit.patch_stdout import StdoutProxy from pydantic import BaseModel from .base import Base, CommandFailure, InitArgs -ENGINE_TIMEOUT: int = 120 # Timeout for engine related operations. +ENGINE_TIMEOUT: int = 10 # Timeout for engine related operations. +ENGINE_LONG_TIMEOUT: int = 120 # Timeout when opening engine. class EngineProtocol(enum.StrEnum): @@ -70,9 +72,9 @@ def __init__(self, args: InitArgs) -> None: ## Setup logging: self._engines_saved_log = deque() self._engines_log_queue = queue.SimpleQueue() - log_handler = logging.handlers.QueueHandler(self._engines_log_queue) + log_handler = logging.StreamHandler(StdoutProxy()) log_handler.setLevel(logging.WARNING) - log_handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) + log_handler.setFormatter(logging.Formatter("%(message)s")) chess.engine.LOGGER.addHandler(log_handler) # Close engines when REPL is quit. @@ -116,14 +118,30 @@ async def close_engine(self, name: str) -> None: """Stop and quit an engine.""" engine: LoadedEngine = self._loaded_engines.pop(name) self.engine_confs[engine.config_name].loaded_as.remove(name) - async with asyncio.timeout(ENGINE_TIMEOUT): - await engine.engine.quit() - if self.selected_engine == engine: + if self.selected_engine == name: try: self.select_engine(next(iter(self.loaded_engines))) except StopIteration: self._selected_engine = None - self._selected_engine = None + async with self.engine_timeout(name, close=False, context="close_engine()"): + await engine.engine.quit() + + @asynccontextmanager + async def engine_timeout( + self, engine: str, long: bool = False, close: bool = True, context: str | None = None + ) -> AsyncGenerator[None, None]: + timeout = ENGINE_TIMEOUT if not long else ENGINE_LONG_TIMEOUT + try: + async with asyncio.timeout(timeout): + yield + except (TimeoutError, chess.engine.EngineError, chess.engine.EngineTerminatedError) as e: + if close: + await self.close_engine(engine) + raise CommandFailure( + f"Engine {engine} crashed" + + (" in {context}" if context is not None else "") + + f": {e}" + ) from e def get_engines_log(self) -> Sequence[str]: """Get log messages from all engines.""" @@ -272,7 +290,7 @@ async def set_engine_option( ) else: raise AssertionError(f"Unsupported option type: {option.type}") - async with asyncio.timeout(ENGINE_TIMEOUT): + async with self.engine_timeout(engine): await self.loaded_engines[engine].engine.configure({option.name: value}) async def load_engine(self, config_name: str, name: str) -> None: @@ -283,7 +301,7 @@ async def load_engine(self, config_name: str, name: str) -> None: engine_conf: EngineConf = self.engine_confs[config_name] engine: chess.engine.Protocol try: - async with asyncio.timeout(ENGINE_TIMEOUT): + async with self.engine_timeout(name, long=True, close=False): match engine_conf.protocol: case EngineProtocol.UCI: _, engine = await chess.engine.popen_uci(engine_conf.path) @@ -291,19 +309,12 @@ async def load_engine(self, config_name: str, name: str) -> None: _, engine = await chess.engine.popen_xboard(engine_conf.path) case x: assert_never(x) - except chess.engine.EngineError as e: - self.poutput( - f"Engine Terminated Error: The engine {engine_conf.path} didn't behaved as it" - " should. Either it is broken, or this program containes a bug. It might also be" - " that you've specified wrong path to the engine executable." - ) - raise e except FileNotFoundError as e: - self.poutput(f"Error: Couldn't find the engine executable {engine_conf.path}: {e}") - raise e + raise CommandFailure( + f"Error: Couldn't find the engine executable {engine_conf.path}: {e}" + ) from e except OSError as e: - self.poutput(f"Error: While loading engine executable {engine_conf.path}: {e}") - raise e + raise CommandFailure(f"While loading engine executable {engine_conf.path}: {e}") from e self._loaded_engines[name] = LoadedEngine(config_name, engine) engine_conf.fullname = engine.id.get("name") engine_conf.loaded_as.add(name) diff --git a/chess_cli/engine_cmds.py b/chess_cli/engine_cmds.py index c998ac1..1131fae 100644 --- a/chess_cli/engine_cmds.py +++ b/chess_cli/engine_cmds.py @@ -420,8 +420,6 @@ def get_engine_opt_name(self, engine: str, name: str) -> str: Raises CommandFailure if not found. """ options: Mapping[str, chess.engine.Option] = self.loaded_engines[engine].engine.options - if name in options: - return name try: return next(name for name in options if name.lower() == name.lower()) except StopIteration: diff --git a/chess_cli/game_cmds.py b/chess_cli/game_cmds.py index 0f9c87f..4c4de2d 100644 --- a/chess_cli/game_cmds.py +++ b/chess_cli/game_cmds.py @@ -6,10 +6,10 @@ import chess import chess.pgn +from .base import CommandFailure from .game_utils import GameUtils from .repl import argparse_command, command from .utils import MoveNumber -from .base import CommandFailure class GameCmds(GameUtils): @@ -399,7 +399,7 @@ def do_setup(self, args) -> None: try: board = chess.Board(args.fen) except ValueError as e: - raise CommandFailure(f"Bad FEN: {e}") + raise CommandFailure(f"Bad FEN: {e}") from None elif args.empty: board = chess.Board.empty() elif args.start: diff --git a/chess_cli/record.py b/chess_cli/record.py index 730ef09..5d6fcd1 100644 --- a/chess_cli/record.py +++ b/chess_cli/record.py @@ -8,7 +8,7 @@ import time import traceback from asyncio import subprocess -from collections.abc import Iterable, Mapping +from collections.abc import Iterable from contextlib import suppress from dataclasses import dataclass from pathlib import Path, PurePath diff --git a/chess_cli/repl.py b/chess_cli/repl.py index dfc66d7..055b494 100644 --- a/chess_cli/repl.py +++ b/chess_cli/repl.py @@ -260,6 +260,8 @@ async def cmd_loop(self) -> None: self.perror(f"Error: {e}") except _CommandException as ex: traceback.print_exception(ex.inner_exc) + except asyncio.exceptions.CancelledError: + self.perror("CancelledException thrown!") def command[T: ReplBase]( diff --git a/scripts/query_dll_dependencies.py b/scripts/query_dll_dependencies.py index d7e0ac4..e993773 100644 --- a/scripts/query_dll_dependencies.py +++ b/scripts/query_dll_dependencies.py @@ -1,11 +1,14 @@ #!/usr/bin/python3 -"""This is a short script to parse the output of dependencies.exe from -. The purpose is to get all dependent (non-system) DLLs for a DLL-file. I used it to query the dependencies for libcairo-2.dll: +"""A short script to parse the output of dependencies.exe from +. + +The purpose is to get all dependent (non-system) DLLs for a DLL-file. I used it +to query the dependencies for libcairo-2.dll: - Download and install GTK+ from . - Download and unpack dependencies from . - Open a terminal and navigate to the lib directory in the GTK installation folder. - Run dependencies.exe with an appropriate depth and pipe the JSON output to this script: - Dependencies.exe libcairo-2.dll -chain -json -depth 4 | python query_dll_dependencies.py + Dependencies.exe libcairo-2.dll -chain -json -depth 4 | python query_dll_dependencies.py. """ import json @@ -32,5 +35,6 @@ def main() -> None: for dll in dlls: print(dll) + if __name__ == "__main__": main()