"""Imitate `lichess.py`. Used in tests.""" from __future__ import annotations import time import chess.engine import json import logging import traceback import datetime from queue import Queue from requests.models import Response from collections.abc import Generator from lib.lichess import Lichess as OriginalLichess from lib.timer import to_msec from lib.lichess_types import (UserProfileType, ChallengeType, REQUESTS_PAYLOAD_TYPE, GameType, OnlineType, PublicDataType, BackoffDetails) # ruff: noqa: ARG002 logger = logging.getLogger(__name__) def backoff_handler(details: BackoffDetails) -> None: """Log exceptions inside functions with the backoff decorator.""" logger.debug("Backing off {wait:0.1f} seconds after {tries} tries " "calling function {target} with args {args} and kwargs {kwargs}".format(**details)) logger.debug(f"Exception: {traceback.format_exc()}") def is_final(error: Exception) -> bool: """Mock error handler for tests when a function has a backup decorator.""" logger.debug(error) return False class GameStream(Response): """Imitate lichess.org's GameStream. Used in tests.""" def __init__(self, board_queue: Queue[chess.Board], clock_queue: Queue[tuple[datetime.timedelta, datetime.timedelta, datetime.timedelta]]) -> None: """ Capture the interprocess queues that will feed the gameStream with game information. :param board_queue: Updated board positions from the lichess_org_simulator() function. :param clock_queue: Updated game clock timings (white time, black time, and increment) from the lichess_org_simulator() function. """ self.board_queue = board_queue self.clock_queue = clock_queue def iter_lines(self, chunk_size: int | None = 512, decode_unicode: bool = False, delimiter: str | bytes | None = None) -> Generator[bytes, None, None]: """Send the game events to lichess-bot.""" yield json.dumps( {"id": "zzzzzzzz", "variant": {"key": "standard", "name": "Standard", "short": "Std"}, "clock": {"initial": 60000, "increment": 2000}, "speed": "bullet", "perf": {"name": "Bullet"}, "rated": True, "createdAt": 1600000000000, "white": {"id": "bo", "name": "bo", "title": "BOT", "rating": 3000}, "black": {"id": "b", "name": "b", "title": "BOT", "rating": 3000, "provisional": True}, "initialFen": "startpos", "type": "gameFull", "state": {"type": "gameState", "moves": "", "wtime": 10000, "btime": 10000, "winc": 100, "binc": 100, "status": "started"}}).encode("utf-8") while True: board = self.board_queue.get() self.board_queue.task_done() wtime, btime, increment = self.clock_queue.get() self.clock_queue.task_done() new_game_state = {"type": "gameState", "moves": " ".join(move.uci() for move in board.move_stack), "wtime": int(to_msec(wtime)), "btime": int(to_msec(btime)), "winc": int(to_msec(increment)), "binc": int(to_msec(increment))} if board.is_game_over(): new_game_state["status"] = "outoftime" new_game_state["winner"] = "black" yield json.dumps(new_game_state).encode("utf-8") break if board.move_stack: new_game_state["status"] = "started" yield json.dumps(new_game_state).encode("utf-8") def __enter__(self) -> GameStream: # noqa: PYI034 """Enter game stream context.""" return self def __exit__(self, *args: object) -> None: """Exit game stream context.""" class EventStream(Response): """Imitate lichess.org's EventStream. Used in tests.""" def __init__(self, sent_game: bool = False) -> None: """ Start the event stream for the lichess_bot_main() loop. :param sent_game: If we have already sent the `gameStart` event, so we don't send it again. """ self.sent_game = sent_game def iter_lines(self, chunk_size: int | None = 512, decode_unicode: bool = False, delimiter: str | bytes | None = None) -> Generator[bytes, None, None]: """Send the events to lichess-bot.""" if self.sent_game: yield b"" time.sleep(1) else: yield json.dumps( {"type": "gameStart", "game": {"id": "zzzzzzzz", "source": "friend", "compat": {"bot": True, "board": True}}}).encode("utf-8") def __enter__(self) -> EventStream: # noqa: PYI034 """Enter context block.""" return self def __exit__(self, *args: object) -> None: """Exit context block.""" # Docs: https://lichess.org/api. class Lichess(OriginalLichess): """Imitate communication with lichess.org.""" def __init__(self, move_queue: Queue[chess.Move | None], board_queue: Queue[chess.Board], clock_queue: Queue[tuple[datetime.timedelta, datetime.timedelta, datetime.timedelta]]) -> None: """ Capture the interprocess queues to distribute them to the eventStream and gameStream instances. :param move_queue: An interprocess queue to send moves chosen by the bot under test to the mock lichess function. :param board_queue: An interprocess queue to send board positions to the mock game stream. :param clock_queue: An interprocess queue to send game clock information to the mock game stream. """ self.baseUrl = "testing" self.move_queue = move_queue self.board_queue = board_queue self.clock_queue = clock_queue self.sent_game = False self.started_game_stream = False def upgrade_to_bot_account(self) -> None: """Isn't used in tests.""" def make_move(self, game_id: str, move: chess.engine.PlayResult) -> None: """Send a move to the opponent engine thread.""" self.move_queue.put(move.move) def accept_takeback(self, game_id: str, accept: bool) -> bool: """Isn't used in tests.""" return False def chat(self, game_id: str, room: str, text: str) -> None: """Isn't used in tests.""" def abort(self, game_id: str) -> None: """Isn't used in tests.""" def get_event_stream(self) -> EventStream: """Send the `EventStream`.""" events = EventStream(self.sent_game) self.sent_game = True return events def get_game_stream(self, game_id: str) -> GameStream: """Send the `GameStream`.""" if self.started_game_stream: self.move_queue.put(None) self.started_game_stream = True return GameStream(self.board_queue, self.clock_queue) def accept_challenge(self, challenge_id: str) -> None: """Isn't used in tests.""" def decline_challenge(self, challenge_id: str, reason: str = "generic") -> None: """Isn't used in tests.""" def get_profile(self) -> UserProfileType: """Return a simple profile for the bot that lichess-bot uses when testing.""" return {"id": "b", "username": "b", "online": True, "title": "BOT", "url": "https://lichess.org/@/b", "followable": True, "following": False, "blocking": False, "followsYou": False, "perfs": {}} def get_ongoing_games(self) -> list[GameType]: """Return that the bot isn't playing a game.""" return [] def resign(self, game_id: str) -> None: """Isn't used in tests.""" def get_game_pgn(self, game_id: str) -> str: """Return a simple PGN.""" return """ [Event "Test game"] [Site "pytest"] [Date "2022.03.11"] [Round "1"] [White "bo"] [Black "b"] [Result "0-1"] * """ def get_online_bots(self) -> list[UserProfileType]: """Return that the only bot online is us.""" return [{"username": "b", "online": True}] def challenge(self, username: str, payload: REQUESTS_PAYLOAD_TYPE) -> ChallengeType: """Isn't used in tests.""" return {} def cancel(self, challenge_id: str) -> None: """Isn't used in tests.""" def online_book_get(self, path: str, params: dict[str, str | int] | None = None, *, stream: bool = False, authenticated: bool = False) -> OnlineType: """Isn't used in tests.""" return {} def is_online(self, user_id: str) -> bool: """Return that a bot is online.""" return True def get_public_data(self, user_name: str) -> PublicDataType: """Isn't used in tests.""" return {}