Spaces:
Running
Running
File size: 54,839 Bytes
f56e4d7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 | """The main module that controls lichess-bot."""
import argparse
import chess
import chess.pgn
from chess.variant import find_variant
from lib import engine_wrapper, model, lichess, matchmaking
import json
import logging
import logging.handlers
import multiprocessing
import signal
import time
import datetime
import backoff
import os
import io
import copy
import math
import sys
import yaml
import traceback
import itertools
import glob
import platform
import importlib.metadata
import contextlib
from lib.blocklist import OnlineBlocklist
from lib.config import load_config, Configuration, log_config
from lib.conversation import Conversation, ChatLine
from lib.timer import Timer, seconds, msec, hours, to_seconds
from lib.lichess import stop
from lib.lichess_types import (UserProfileType, EventType, GameType, GameEventType, CONTROL_QUEUE_TYPE,
CORRESPONDENCE_QUEUE_TYPE, LOGGING_QUEUE_TYPE, PGN_QUEUE_TYPE)
from requests.exceptions import (ChunkedEncodingError, ConnectionError as RequestsConnectionError, HTTPError, ReadTimeout,
RequestException)
from rich.logging import RichHandler
from collections import defaultdict
from collections.abc import Iterator, MutableSequence
from http.client import RemoteDisconnected
from queue import Empty
from multiprocessing.pool import Pool
from collections import Counter
from typing import TypedDict, cast, TypeAlias
from types import FrameType
MULTIPROCESSING_LIST_TYPE: TypeAlias = MutableSequence[model.Challenge]
POOL_TYPE: TypeAlias = Pool
class PlayGameArgsType(TypedDict, total=False):
"""Type hint for `play_game_args`."""
li: lichess.Lichess
control_queue: CONTROL_QUEUE_TYPE
user_profile: UserProfileType
config: Configuration
challenge_queue: MULTIPROCESSING_LIST_TYPE
correspondence_queue: CORRESPONDENCE_QUEUE_TYPE
logging_queue: LOGGING_QUEUE_TYPE
pgn_queue: PGN_QUEUE_TYPE
game_id: str
class VersioningType(TypedDict):
"""Type hint for the versioning information from lib/versioning.yml."""
lichess_bot_version: str
minimum_python_version: str
deprecated_python_version: str
deprecation_date: datetime.date
logger = logging.getLogger(__name__)
with open(os.path.join(os.path.dirname(__file__), "versioning.yml")) as version_file:
versioning_info: VersioningType = yaml.safe_load(version_file)
__version__ = versioning_info["lichess_bot_version"]
def should_restart() -> bool:
"""Decide whether to restart lichess-bot when exiting main program."""
return stop.restart
def disable_restart() -> None:
"""Disable restarting lichess-bot when errors occur. Used during testing."""
stop.restart = False
def signal_handler(signal: int, frame: FrameType | None) -> None: # noqa: ARG001
"""Terminate lichess-bot."""
if not stop.terminated:
logger.debug("Received SIGINT. Terminating client.")
stop.terminated = True
else:
logger.debug("Received second SIGINT. Quitting now.")
stop.force_quit = True
signal.signal(signal.SIGINT, signal_handler)
def upgrade_account(li: lichess.Lichess) -> bool:
"""Upgrade the account to a BOT account."""
try:
li.upgrade_to_bot_account()
except HTTPError:
logger.exception("Failed to upgrade to Bot Account.")
return False
logger.info("Successfully upgraded to Bot Account!")
return True
def watch_control_stream(control_queue: CONTROL_QUEUE_TYPE, li: lichess.Lichess) -> None:
"""Put the events in a queue."""
while not stop.terminated:
try:
with li.get_event_stream() as response:
lines = response.iter_lines()
for line in lines:
if line:
event = json.loads(line.decode("utf-8"))
control_queue.put_nowait(event)
else:
control_queue.put_nowait({"type": "ping"})
except Exception:
logger.warning(f"Control stream error, reconnecting:\n{traceback.format_exc()}")
time.sleep(1)
control_queue.put_nowait({"type": "terminated"})
def do_correspondence_ping(control_queue: CONTROL_QUEUE_TYPE, period: datetime.timedelta) -> None:
"""
Tell the engine to check the correspondence games.
:param control_queue: Queue to put events in.
:param period: How many seconds to wait before sending a correspondence ping.
"""
while not stop.terminated:
time.sleep(to_seconds(period))
control_queue.put_nowait({"type": "correspondence_ping"})
def write_pgn_records(pgn_queue: PGN_QUEUE_TYPE, config: Configuration, username: str) -> None:
"""Write PGN records to files as games finish."""
while True:
mark_task_done = False
try:
event = pgn_queue.get()
mark_task_done = True
if event:
save_pgn_record(event, config, username)
except InterruptedError:
pass
except Exception:
logger.exception("Could not write PGN to file")
if mark_task_done:
pgn_queue.task_done()
def logging_configurer(level: int, filename: str | None, disable_auto_logs: bool) -> None:
"""
Configure the logger.
:param level: The logging level. Either `logging.INFO` or `logging.DEBUG`.
:param filename: The filename to write the logs to. If it is `None` then the logs aren't written to a file.
:param disable_auto_logs: Whether to disable automatic logging.
"""
console_handler = RichHandler()
console_formatter = logging.Formatter("%(message)s")
console_handler.setFormatter(console_formatter)
console_handler.setLevel(level)
all_handlers: list[logging.Handler] = [console_handler]
if filename:
file_handler = logging.FileHandler(filename, delay=True, encoding="utf-8")
FORMAT = "%(asctime)s %(name)s (%(filename)s:%(lineno)d) %(levelname)s %(message)s"
file_formatter = logging.Formatter(FORMAT)
file_handler.setFormatter(file_formatter)
file_handler.setLevel(level)
all_handlers.append(file_handler)
if not disable_auto_logs:
os.makedirs(auto_log_directory, exist_ok=True)
# Set up automatic logging.
auto_log_filename = os.path.join(auto_log_directory, "lichess-bot.log")
auto_file_handler = logging.handlers.TimedRotatingFileHandler(auto_log_filename,
delay=True,
encoding="utf-8",
when="midnight",
backupCount=7)
auto_file_handler.setLevel(logging.DEBUG)
FORMAT = "%(asctime)s %(name)s (%(filename)s:%(lineno)d) %(levelname)s %(message)s"
file_formatter = logging.Formatter(FORMAT)
auto_file_handler.setFormatter(file_formatter)
all_handlers.append(auto_file_handler)
logging.basicConfig(level=logging.DEBUG,
handlers=all_handlers,
force=True)
def logging_listener_proc(queue: LOGGING_QUEUE_TYPE, level: int, log_filename: str | None,
disable_auto_logging: bool) -> None:
"""
Handle events from the logging queue.
This allows the logs from inside a thread to be printed.
They are added to the queue, so they are printed outside the thread.
"""
logging_configurer(level, log_filename, disable_auto_logging)
logger = logging.getLogger()
while True:
task: logging.LogRecord | None = None
try:
task = queue.get(block=False)
except Empty:
time.sleep(0.1)
except InterruptedError:
pass
except Exception: # noqa: S110
pass
if task is None:
continue
logger.handle(task)
queue.task_done()
def thread_logging_configurer(queue: LOGGING_QUEUE_TYPE) -> None:
"""Configure the game logger."""
h = logging.handlers.QueueHandler(queue)
root = logging.getLogger()
root.handlers.clear()
root.addHandler(h)
root.setLevel(logging.DEBUG)
def start(li: lichess.Lichess, user_profile: UserProfileType, config: Configuration, logging_level: int,
log_filename: str | None, disable_auto_logging: bool, one_game: bool = False) -> None:
"""
Start lichess-bot.
:param li: Provides communication with lichess.org.
:param user_profile: Information on our bot.
:param config: The config that the bot will use.
:param logging_level: The logging level. Either `logging.INFO` or `logging.DEBUG`.
:param log_filename: The filename to write the logs to. If it is `None` then the logs aren't written to a file.
:param disable_auto_logging: Whether to disable automatic logging.
:param one_game: Whether the bot should play only one game. Only used in `test_bot/test_bot.py` to test lichess-bot.
"""
logger.info(f"You're now connected to {config.url} and awaiting challenges.")
manager = multiprocessing.Manager()
challenge_queue: MULTIPROCESSING_LIST_TYPE = manager.list()
control_queue: CONTROL_QUEUE_TYPE = manager.Queue()
control_stream = multiprocessing.Process(target=watch_control_stream, args=(control_queue, li))
control_stream.start()
correspondence_pinger = multiprocessing.Process(target=do_correspondence_ping,
args=(control_queue,
seconds(config.correspondence.checkin_period)))
correspondence_pinger.start()
correspondence_queue: CORRESPONDENCE_QUEUE_TYPE = manager.Queue()
logging_queue = manager.Queue()
logging_listener = multiprocessing.Process(target=logging_listener_proc,
args=(logging_queue,
logging_level,
log_filename,
disable_auto_logging))
logging_listener.start()
pgn_queue = manager.Queue()
pgn_listener = multiprocessing.Process(target=write_pgn_records,
args=(pgn_queue,
config,
user_profile["username"]))
pgn_listener.start()
thread_logging_configurer(logging_queue)
try:
lichess_bot_main(li,
user_profile,
config,
challenge_queue,
control_queue,
correspondence_queue,
logging_queue,
pgn_queue,
one_game)
finally:
control_stream.terminate()
control_stream.join()
correspondence_pinger.terminate()
correspondence_pinger.join()
time.sleep(1.0) # Allow final messages in logging_queue to be handled.
logging_configurer(logging_level, log_filename, disable_auto_logging)
logging_listener.terminate()
logging_listener.join()
pgn_listener.terminate()
pgn_listener.join()
def log_proc_count(change: str, active_games: set[str]) -> None:
"""
Log the number of active games and their IDs.
:param change: Either "Freed", "Used", or "Queued".
:param active_games: A set containing the IDs of the active games.
"""
symbol = "+++" if change == "Freed" else "---"
logger.info(f"{symbol} Process {change}. Count: {len(active_games)}. IDs: {active_games or None}")
def lichess_bot_main(li: lichess.Lichess,
user_profile: UserProfileType,
config: Configuration,
challenge_queue: MULTIPROCESSING_LIST_TYPE,
control_queue: CONTROL_QUEUE_TYPE,
correspondence_queue: CORRESPONDENCE_QUEUE_TYPE,
logging_queue: LOGGING_QUEUE_TYPE,
pgn_queue: PGN_QUEUE_TYPE,
one_game: bool) -> None:
"""
Handle all the games and challenges.
:param li: Provides communication with lichess.org.
:param user_profile: Information on our bot.
:param config: The config that the bot will use.
:param challenge_queue: The queue containing the challenges.
:param control_queue: The queue containing all the events.
:param correspondence_queue: The queue containing the correspondence games.
:param logging_queue: The logging queue. Used by `logging_listener_proc`.
:param pgn_queue: The queue containing the PGN games.
:param one_game: Whether the bot should play only one game. Only used in `test_bot/test_bot.py` to test lichess-bot.
"""
max_games = config.challenge.concurrency
one_game_completed = False
all_games = li.get_ongoing_games() or []
prune_takeback_records(all_games)
startup_correspondence_games = [game["gameId"]
for game in all_games
if game["speed"] == "correspondence"]
active_games = {game["gameId"]
for game in all_games
if game["gameId"] not in startup_correspondence_games}
low_time_games: list[GameType] = []
last_check_online_time = Timer(hours(1))
matchmaker = matchmaking.Matchmaking(li, config, user_profile)
matchmaker.show_earliest_challenge_time()
play_game_args = PlayGameArgsType(li=li, control_queue=control_queue, user_profile=user_profile,
config=config, challenge_queue=challenge_queue,
correspondence_queue=correspondence_queue, logging_queue=logging_queue,
pgn_queue=pgn_queue)
recent_bot_challenges: defaultdict[str, list[Timer]] = defaultdict(list)
online_block_list = OnlineBlocklist(config.challenge.online_block_list)
if config.quit_after_all_games_finish:
logger.info("When quitting, lichess-bot will first wait for all running games to finish.")
logger.info("Press Ctrl-C twice to quit immediately.")
with multiprocessing.pool.Pool(max_games + 1) as pool:
while not (stop.terminated or (one_game and one_game_completed) or stop.restart):
event = next_event(control_queue)
if not event:
continue
if event["type"] == "terminated":
stop.restart = True
control_queue.task_done()
break
if event["type"] == "local_game_done":
active_games.discard(event["game"]["id"])
matchmaker.game_done()
log_proc_count("Freed", active_games)
one_game_completed = True
elif event["type"] == "challenge":
handle_challenge(event,
li,
challenge_queue,
config.challenge,
user_profile,
recent_bot_challenges,
online_block_list)
elif event["type"] == "challengeDeclined":
matchmaker.declined_challenge(event)
elif event["type"] == "challengeCanceled":
active_games.discard(event["challenge"]["id"])
log_proc_count("Freed", active_games)
elif event["type"] == "gameStart":
matchmaker.accepted_challenge(event)
start_game(event,
pool,
play_game_args,
config,
startup_correspondence_games,
correspondence_queue,
active_games,
low_time_games)
start_low_time_games(low_time_games, active_games, max_games, pool, play_game_args)
check_in_on_correspondence_games(pool,
event,
correspondence_queue,
challenge_queue,
play_game_args,
active_games,
max_games)
accept_challenges(li, challenge_queue, active_games, max_games)
matchmaker.challenge(active_games, challenge_queue, max_games)
check_online_status(li, user_profile, last_check_online_time)
control_queue.task_done()
close_pool(pool, active_games, config)
def close_pool(pool: POOL_TYPE, active_games: set[str], config: Configuration) -> None:
"""Shut down pool after possibly waiting on games to finish depending on the configuration."""
if config.quit_after_all_games_finish:
if active_games:
logger.info("Waiting for games to finish before quitting.")
pool.close()
pool.join()
def next_event(control_queue: CONTROL_QUEUE_TYPE) -> EventType:
"""Get the next event from the control queue."""
try:
event = control_queue.get()
if event is None:
return {}
except InterruptedError:
return {}
if "type" not in event:
logger.warning("Unable to handle response from lichess.org:")
logger.warning(event)
control_queue.task_done()
return {}
if event.get("type") != "ping":
logger.debug(f"Event: {event}")
return event
correspondence_games_to_start = 0
def check_in_on_correspondence_games(pool: POOL_TYPE,
event: EventType,
correspondence_queue: CORRESPONDENCE_QUEUE_TYPE,
challenge_queue: MULTIPROCESSING_LIST_TYPE,
play_game_args: PlayGameArgsType,
active_games: set[str],
max_games: int) -> None:
"""Start correspondence games."""
global correspondence_games_to_start
if event["type"] == "correspondence_ping":
correspondence_games_to_start = correspondence_queue.qsize()
elif event["type"] != "local_game_done":
return
if challenge_queue:
return
while len(active_games) < max_games and correspondence_games_to_start > 0:
game_id = correspondence_queue.get_nowait()
correspondence_games_to_start -= 1
correspondence_queue.task_done()
start_game_thread(active_games, game_id, play_game_args, pool)
def start_low_time_games(low_time_games: list[GameType], active_games: set[str], max_games: int,
pool: POOL_TYPE, play_game_args: PlayGameArgsType) -> None:
"""Start the games based on how much time we have left."""
low_time_games.sort(key=lambda g: g.get("secondsLeft", math.inf))
while low_time_games and len(active_games) < max_games:
game_id = low_time_games.pop(0)["id"]
start_game_thread(active_games, game_id, play_game_args, pool)
def accept_challenges(li: lichess.Lichess, challenge_queue: MULTIPROCESSING_LIST_TYPE, active_games: set[str],
max_games: int) -> None:
"""Accept a challenge."""
while len(active_games) < max_games and challenge_queue:
chlng = challenge_queue.pop(0)
if chlng.from_self:
continue
try:
logger.info(f"Accept {chlng}")
li.accept_challenge(chlng.id)
active_games.add(chlng.id)
log_proc_count("Queued", active_games)
except (HTTPError, ReadTimeout) as exception:
if isinstance(exception, HTTPError) and exception.response is not None and exception.response.status_code == 404:
logger.info(f"Skip missing {chlng}")
def check_online_status(li: lichess.Lichess, user_profile: UserProfileType, last_check_online_time: Timer) -> None:
"""Check if lichess.org thinks the bot is online or not. If it isn't, we restart it."""
if last_check_online_time.is_expired():
try:
if not li.is_online(user_profile["id"]):
logger.info("Will restart lichess-bot")
stop.restart = True
last_check_online_time.reset()
except (HTTPError, ReadTimeout):
pass
def sort_challenges(challenge_queue: MULTIPROCESSING_LIST_TYPE, challenge_config: Configuration) -> None:
"""
Sort the challenges.
They can be sorted either by rating (the best challenger is accepted first),
or by time (the first challenger is accepted first). The bot can also
prioritize playing against humans or bots.
"""
challenge_list = list(challenge_queue)
if challenge_config.sort_by == "best":
challenge_list.sort(key=lambda challenger: challenger.score(), reverse=True)
if challenge_config.preference != "none":
challenge_list.sort(key=lambda challenger: challenger.challenger.is_bot, reverse=challenge_config.preference == "bot")
challenge_queue[:] = challenge_list
def game_is_active(li: lichess.Lichess, game_id: str) -> bool:
"""Determine if a game is still being played."""
active_games = li.get_ongoing_games()
if active_games is None:
return True
return game_id in (ongoing_game["gameId"] for ongoing_game in active_games)
def start_game_thread(active_games: set[str], game_id: str, play_game_args: PlayGameArgsType, pool: POOL_TYPE) -> None:
"""Start a game thread."""
active_games.add(game_id)
log_proc_count("Used", active_games)
play_game_args["game_id"] = game_id
def game_error_handler(error: BaseException) -> None:
logger.exception("Game ended due to error:", exc_info=error)
control_queue = play_game_args["control_queue"]
pgn_queue = play_game_args["pgn_queue"]
li = play_game_args["li"]
control_queue.put_nowait({"type": "local_game_done", "game": {"id": game_id}})
pgn_queue.put_nowait({"game": {"id": game_id,
"pgn": li.get_game_pgn(game_id),
"complete": not game_is_active(li, game_id)}})
pool.apply_async(play_game,
kwds=play_game_args,
error_callback=game_error_handler)
def start_game(event: EventType,
pool: POOL_TYPE,
play_game_args: PlayGameArgsType,
config: Configuration,
startup_correspondence_games: list[str],
correspondence_queue: CORRESPONDENCE_QUEUE_TYPE,
active_games: set[str],
low_time_games: list[GameType]) -> None:
"""
Start a game.
:param event: The gameStart event.
:param pool: The thread pool that the game is added to, so they can be run asynchronously.
:param play_game_args: The args passed to `play_game`.
:param config: The config the bot will use.
:param startup_correspondence_games: A list of correspondence games that have to be started.
:param correspondence_queue: The queue that correspondence games are added to, to be started.
:param active_games: A set of all the games that aren't correspondence games.
:param low_time_games: A list of games, in which we don't have much time remaining.
"""
game_id = event["game"]["id"]
if game_id in startup_correspondence_games:
if enough_time_to_queue(event, config):
logger.info(f"--- Enqueue {config.url + game_id}")
correspondence_queue.put_nowait(game_id)
else:
logger.info(f"--- Will start {config.url + game_id} as soon as possible")
low_time_games.append(event["game"])
startup_correspondence_games.remove(game_id)
else:
start_game_thread(active_games, game_id, play_game_args, pool)
def enough_time_to_queue(event: EventType, config: Configuration) -> bool:
"""Check whether the correspondence must be started now or if it can wait."""
corr_cfg = config.correspondence
minimum_time = (corr_cfg.checkin_period + corr_cfg.move_time) * 10
game = event["game"]
return not game["isMyTurn"] or game.get("secondsLeft", math.inf) > minimum_time
def handle_challenge(event: EventType, li: lichess.Lichess, challenge_queue: MULTIPROCESSING_LIST_TYPE,
challenge_config: Configuration, user_profile: UserProfileType,
recent_bot_challenges: defaultdict[str, list[Timer]], online_block_list: OnlineBlocklist) -> None:
"""Handle incoming challenges. It either accepts, declines, or queues them to accept later."""
chlng = model.Challenge(event["challenge"], user_profile)
if chlng.from_self:
return
active_games = li.get_ongoing_games() or []
opponent_engagements = Counter(game["opponent"]["username"] for game in active_games)
opponent_engagements.update(challenge.challenger.name for challenge in challenge_queue)
online_block_list.refresh()
is_supported, decline_reason = chlng.is_supported(challenge_config,
recent_bot_challenges,
opponent_engagements,
online_block_list,
user_profile)
if is_supported:
challenge_queue.append(chlng)
sort_challenges(challenge_queue, challenge_config)
time_window = challenge_config.recent_bot_challenge_age
if time_window is not None:
recent_bot_challenges[chlng.challenger.name].append(Timer(seconds(time_window)))
else:
li.decline_challenge(chlng.id, reason=decline_reason)
@backoff.on_exception(backoff.expo, BaseException, max_time=600, giveup=lichess.is_final, # type: ignore[arg-type]
on_backoff=lichess.backoff_handler)
def play_game(li: lichess.Lichess,
game_id: str,
control_queue: CONTROL_QUEUE_TYPE,
user_profile: UserProfileType,
config: Configuration,
challenge_queue: MULTIPROCESSING_LIST_TYPE,
correspondence_queue: CORRESPONDENCE_QUEUE_TYPE,
logging_queue: LOGGING_QUEUE_TYPE,
pgn_queue: PGN_QUEUE_TYPE) -> None:
"""
Play a game.
:param li: Provides communication with lichess.org.
:param game_id: The id of the game.
:param control_queue: The control queue that contains events (adds `local_game_done` to the queue).
:param user_profile: Information on our bot.
:param config: The config that the bot will use.
:param challenge_queue: The queue containing the challenges.
:param correspondence_queue: The queue containing the correspondence games.
:param logging_queue: The logging queue. Used by `logging_listener_proc`.
:param pgn_queue: The queue containing the PGN games.
"""
thread_logging_configurer(logging_queue)
logger = logging.getLogger(__name__)
with li.get_game_stream(game_id) as response:
lines = response.iter_lines()
# Initial response of stream will be the full game info. Store it.
initial_state = json.loads(next(lines).decode("utf-8"))
logger.debug(f"Initial state: {initial_state}")
abort_time = seconds(config.abort_time)
game = model.Game(initial_state, user_profile["username"], li.baseUrl, abort_time)
with engine_wrapper.create_engine(config, game) as engine:
engine.get_opponent_info(game)
logger.debug(f"The engine for game {game_id} has pid={engine.get_pid()}")
conversation = Conversation(game, engine, li, __version__, challenge_queue)
logger.info(f"+++ {game}")
is_correspondence = game.speed == "correspondence"
correspondence_cfg = config.correspondence
correspondence_move_time = seconds(correspondence_cfg.move_time)
correspondence_disconnect_time = seconds(correspondence_cfg.disconnect_time)
engine_cfg = config.engine
ponder_cfg = correspondence_cfg if is_correspondence else engine_cfg
can_ponder = ponder_cfg.uci_ponder or ponder_cfg.ponder
move_overhead = msec(config.move_overhead)
delay = msec(config.rate_limiting_delay)
takebacks_accepted = read_takeback_record(game)
max_takebacks_accepted = config.max_takebacks_accepted
keyword_map: defaultdict[str, str] = defaultdict(str, me=game.me.name, opponent=game.opponent.name)
hello = get_greeting("hello", config.greeting, keyword_map)
goodbye = get_greeting("goodbye", config.greeting, keyword_map)
hello_spectators = get_greeting("hello_spectators", config.greeting, keyword_map)
goodbye_spectators = get_greeting("goodbye_spectators", config.greeting, keyword_map)
disconnect_time = correspondence_disconnect_time if not game.state.get("moves") else seconds(0)
prior_game = None
board = chess.Board()
game_stream = itertools.chain([json.dumps(game.state).encode("utf-8")], lines)
quit_after_all_games_finish = config.quit_after_all_games_finish
stay_in_game = True
while stay_in_game and (not stop.terminated or quit_after_all_games_finish) and not stop.force_quit:
move_attempted = False
try:
upd = next_update(game_stream)
u_type = upd["type"] if upd else "ping"
if u_type == "chatLine":
conversation.react(ChatLine(upd))
elif u_type == "gameState":
game.state = upd
board = setup_board(game)
takeback_field = game.state.get("btakeback") if game.is_white else game.state.get("wtakeback")
if not is_game_over(game) and is_engine_move(game, prior_game, board):
disconnect_time = correspondence_disconnect_time
say_hello(conversation, hello, hello_spectators, board)
setup_timer = Timer()
print_move_number(board)
move_attempted = True
engine.play_move(board,
game,
li,
setup_timer,
move_overhead,
can_ponder,
is_correspondence,
correspondence_move_time,
engine_cfg,
fake_think_time(config, board, game))
time.sleep(to_seconds(delay))
elif is_game_over(game):
tell_user_game_result(game, board)
engine.send_game_result(game, board)
conversation.send_message("player", goodbye)
conversation.send_message("spectator", goodbye_spectators)
elif (takeback_field
and not bot_to_move(game, board)
and li.accept_takeback(game.id, takebacks_accepted < max_takebacks_accepted)):
takebacks_accepted += 1
record_takeback(game, takebacks_accepted)
engine.discard_last_move_commentary()
wbtime = upd[engine_wrapper.wbtime(board)]
wbinc = upd[engine_wrapper.wbinc(board)]
terminate_time = msec(wbtime) + msec(wbinc) + seconds(60)
game.ping(abort_time, terminate_time, disconnect_time)
prior_game = copy.deepcopy(game)
elif u_type == "ping" and should_exit_game(board, game, prior_game, li, is_correspondence):
stay_in_game = False
except (HTTPError, ReadTimeout, RemoteDisconnected, ChunkedEncodingError, RequestsConnectionError,
StopIteration) as e:
stopped = isinstance(e, StopIteration)
stay_in_game = not stopped and (move_attempted or game_is_active(li, game.id))
pgn_record = try_get_pgn_game_record(li, config, game, board, engine)
final_queue_entries(control_queue, correspondence_queue, game, is_correspondence, pgn_record, pgn_queue)
delete_takeback_record(game)
def read_takeback_record(game: model.Game) -> int:
"""Read the number of move takeback requests accepted in a game."""
try:
with open(takeback_record_file_name(game.id)) as takeback_file:
return int(takeback_file.read())
except Exception:
return 0
def record_takeback(game: model.Game, accepted_count: int) -> None:
"""Record the number of move takeback requests accepted in a game."""
with open(takeback_record_file_name(game.id), "w") as takeback_file:
takeback_file.write(str(accepted_count))
def delete_takeback_record(game: model.Game) -> None:
"""Delete the takeback record from a game if it has finished."""
if is_game_over(game):
with contextlib.suppress(Exception):
os.remove(takeback_record_file_name(game.id))
def prune_takeback_records(all_games: list[GameType]) -> None:
"""Delete takeback records from games that have ended."""
active_game_ids = {game["gameId"] for game in all_games}
takeback_file_template = takeback_record_file_name("*")
prefix, suffix = takeback_file_template.split("*")
for takeback_file_name in glob.glob(takeback_file_template):
game_id = takeback_file_name.removeprefix(prefix).removesuffix(suffix)
if game_id not in active_game_ids:
with contextlib.suppress(Exception):
os.remove(takeback_file_name)
def takeback_record_file_name(game_id: str) -> str:
"""Get the file name for recording the number of move takebacks accepted."""
return os.path.join(auto_log_directory, f"takeback-count-{game_id}.txt")
def get_greeting(greeting: str, greeting_cfg: Configuration, keyword_map: defaultdict[str, str]) -> str:
"""Get the greeting to send to the chat."""
greeting_text: str = greeting_cfg.lookup(greeting)
return greeting_text.format_map(keyword_map)
def say_hello(conversation: Conversation, hello: str, hello_spectators: str, board: chess.Board) -> None:
"""Send the greetings to the chat rooms."""
if len(board.move_stack) < 2:
conversation.send_message("player", hello)
conversation.send_message("spectator", hello_spectators)
def fake_think_time(config: Configuration, board: chess.Board, game: model.Game) -> datetime.timedelta:
"""Calculate how much time we should wait for fake_think_time."""
sleep = seconds(0.0)
if config.fake_think_time and len(board.move_stack) > 9:
remaining = max(seconds(0), game.my_remaining_time() - msec(config.move_overhead))
delay = remaining * 0.025
accel = 0.99 ** (len(board.move_stack) - 10)
sleep = delay * accel
return sleep
def print_move_number(board: chess.Board) -> None:
"""Log the move number."""
logger.info("")
logger.info(f"move: {len(board.move_stack) // 2 + 1}")
def next_update(lines: Iterator[bytes]) -> GameEventType:
"""Get the next game state."""
binary_chunk = next(lines)
upd = cast(GameEventType, json.loads(binary_chunk.decode("utf-8"))) if binary_chunk else {}
if upd:
logger.debug(f"Game state: {upd}")
return upd
def setup_board(game: model.Game) -> chess.Board:
"""Set up the board."""
if game.variant_name.lower() == "chess960":
board = chess.Board(game.initial_fen, chess960=True)
elif game.variant_name == "From Position":
fen = cast(str, game.initial_fen)
board = chess.Board(fen, chess960=model.is_chess_960(fen))
else:
VariantBoard = find_variant(game.variant_name)
board = VariantBoard()
for move in game.state["moves"].split():
try:
board.push_uci(move)
except ValueError:
logger.exception(f"Ignoring illegal move {move} on board {board.fen()}")
return board
def is_engine_move(game: model.Game, prior_game: model.Game | None, board: chess.Board) -> bool:
"""Check whether it is the engine's turn."""
return game_changed(game, prior_game) and bot_to_move(game, board)
def bot_to_move(game: model.Game, board: chess.Board) -> bool:
"""
Determine whether it is the bot's move on the given board.
This only determines if the board state shows the bot is on move. It does not check if the board state has changed.
Messages from lichess can contain repeat board states if another game aspect has changed (draw offer, takeback offer,
etc.). Use is_engine_move() to determine if the engine should play a move.
"""
return game.is_white == (board.turn == chess.WHITE)
def is_game_over(game: model.Game) -> bool:
"""Check whether the game is over."""
status = game.state["status"]
return status != "started"
def should_exit_game(board: chess.Board, game: model.Game, prior_game: model.Game | None, li: lichess.Lichess,
is_correspondence: bool) -> bool:
"""Whether we should exit a game."""
if (is_correspondence
and not is_engine_move(game, prior_game, board)
and game.should_disconnect_now()):
return True
if game.should_abort_now():
logger.info(f"Aborting {game.url()} by lack of activity")
li.abort(game.id)
return True
if game.should_terminate_now():
logger.info(f"Terminating {game.url()} by lack of activity")
if game.is_abortable():
li.abort(game.id)
return True
return False
def final_queue_entries(control_queue: CONTROL_QUEUE_TYPE, correspondence_queue: CORRESPONDENCE_QUEUE_TYPE,
game: model.Game, is_correspondence: bool, pgn_record: str, pgn_queue: PGN_QUEUE_TYPE) -> None:
"""
Log the game that ended or we disconnected from, and sends a `local_game_done` for the game.
If this is an unfinished correspondence game, put it in a queue to resume later.
"""
if is_correspondence and not is_game_over(game):
logger.info(f"--- Disconnecting from {game.url()}")
correspondence_queue.put_nowait(game.id)
else:
logger.info(f"--- {game.url()} Game over")
control_queue.put_nowait({"type": "local_game_done", "game": {"id": game.id}})
pgn_queue.put_nowait({"game": {"id": game.id,
"pgn": pgn_record,
"complete": is_game_over(game)}})
def game_changed(current_game: model.Game, prior_game: model.Game | None) -> bool:
"""Check whether the current game state is different from the previous game state."""
if prior_game is None:
return True
current_game_moves_str = current_game.state["moves"]
prior_game_moves_str = prior_game.state["moves"]
return current_game_moves_str != prior_game_moves_str
def tell_user_game_result(game: model.Game, board: chess.Board) -> None:
"""Log the game result."""
winner = game.state.get("winner")
termination = game.state.get("status")
winning_name = game.white.name if winner == "white" else game.black.name
losing_name = game.white.name if winner == "black" else game.black.name
if winner is not None:
logger.info(f"{winning_name} won!")
elif termination in [model.Termination.DRAW, model.Termination.TIMEOUT]:
logger.info("Game ended in a draw.")
else:
logger.info("Game adjourned.")
simple_endings: dict[str, str] = {model.Termination.MATE: "Game won by checkmate.",
model.Termination.RESIGN: f"{losing_name} resigned.",
model.Termination.ABORT: "Game aborted."}
if termination in simple_endings:
logger.info(simple_endings[termination])
elif termination == model.Termination.DRAW:
draw_results = [(board.is_fifty_moves(), "Game drawn by 50-move rule."),
(board.is_repetition(), "Game drawn by threefold repetition."),
(board.is_insufficient_material(), "Game drawn from insufficient material."),
(board.is_stalemate(), "Game drawn by stalemate."),
(True, "Game drawn by agreement.")]
messages = [draw_message for is_result, draw_message in draw_results if is_result]
logger.info(messages[0])
elif termination == model.Termination.TIMEOUT:
if winner:
logger.info(f"{losing_name} forfeited on time.")
else:
timeout_name = game.white.name if game.state.get("wtime") == 0 else game.black.name
other_name = game.white.name if timeout_name == game.black.name else game.black.name
logger.info(f"{timeout_name} ran out of time, but {other_name} did not have enough material to mate.")
elif termination:
logger.info(f"Game ended by {termination}")
def try_get_pgn_game_record(li: lichess.Lichess, config: Configuration, game: model.Game, board: chess.Board,
engine: engine_wrapper.EngineWrapper) -> str:
"""
Call `print_pgn_game_record` to write the game to a PGN file and handle errors raised by it.
:param li: Provides communication with lichess.org.
:param config: The config that the bot will use.
:param game: Contains information about the game (e.g. the players' names).
:param board: The board. Contains the moves.
:param engine: The engine. Contains information about the moves (e.g. eval, PV, depth).
"""
try:
return pgn_game_record(li, config, game, board, engine)
except Exception:
logger.exception("Error writing game record:")
return ""
def pgn_game_record(li: lichess.Lichess, config: Configuration, game: model.Game, board: chess.Board,
engine: engine_wrapper.EngineWrapper) -> str:
"""
Return the text of the game's PGN.
:param li: Provides communication with lichess.org.
:param config: The config that the bot will use.
:param game: Contains information about the game (e.g. the players' names).
:param board: The board. Contains the moves.
:param engine: The engine. Contains information about the moves (e.g. eval, PV, depth).
"""
if not config.pgn_directory:
return ""
lichess_game_record = chess.pgn.read_game(io.StringIO(li.get_game_pgn(game.id))) or chess.pgn.Game()
try:
# Recall previously written PGN file to retain engine evaluations.
previous_game_path = get_game_file_path(config,
game.id,
game.white.name,
game.black.name,
game.me.name,
is_game_over(game),
force_single=True)
with open(previous_game_path) as game_data:
game_record = chess.pgn.read_game(game_data) or lichess_game_record
game_record.headers.update(lichess_game_record.headers)
except FileNotFoundError:
game_record = lichess_game_record
fill_missing_pgn_headers(game_record, game)
current_node: chess.pgn.Game | chess.pgn.ChildNode = game_record.game()
lichess_node: chess.pgn.Game | chess.pgn.ChildNode = lichess_game_record.game()
for index, move in enumerate(board.move_stack):
next_node = current_node.next()
if next_node is None or next_node.move != move:
current_node = current_node.add_main_variation(move)
else:
current_node = next_node
next_lichess_node = lichess_node.next()
if next_lichess_node:
lichess_node = next_lichess_node
current_node.set_clock(lichess_node.clock())
if current_node.comment != lichess_node.comment:
current_node.comment = f"{current_node.comment} {lichess_node.comment}".strip()
commentary = engine.comment_for_board_index(index)
pv_node = current_node.parent.add_line(commentary["pv"]) if "pv" in commentary else current_node
pv_node.set_eval(commentary.get("score"), commentary.get("depth"))
pgn_writer = chess.pgn.StringExporter()
return game_record.accept(pgn_writer)
def get_game_file_path(config: Configuration,
game_id: str,
white_name: str,
black_name: str,
user_name: str,
game_is_over: bool,
*, force_single: bool = False) -> str:
"""Return the path of the file where the game record will be written."""
def create_valid_path(s: str) -> str:
illegal = '<>:"/\\|?*'
return os.path.join(config.pgn_directory, "".join(c for c in s if c not in illegal))
if config.pgn_file_grouping == "game" or not game_is_over or force_single:
return create_valid_path(f"{white_name} vs {black_name} - {game_id}.pgn")
elif config.pgn_file_grouping == "opponent":
opponent_name = white_name if user_name == black_name else black_name
return create_valid_path(f"{user_name} games vs. {opponent_name}.pgn")
else: # config.pgn_file_grouping == "all"
return create_valid_path(f"{user_name} games.pgn")
def fill_missing_pgn_headers(game_record: chess.pgn.Game, game: model.Game) -> None:
"""
Fill in any missing headers in the PGN record provided by lichess.org with information from `game`.
:param game_record: A `chess.pgn.Game` object containing information about the game lichess.org's PGN file.
:param game: Contains information about the game (e.g. the players' names), which is used to get the local headers.
"""
local_headers = get_headers(game)
for header, game_value in local_headers.items():
record_value = game_record.headers.get(header)
if not record_value or record_value.startswith("?") or (header == "Result" and record_value == "*"):
game_record.headers[header] = str(game_value)
def get_headers(game: model.Game) -> dict[str, str | int]:
"""
Create local headers to be written in the PGN file.
:param game: Contains information about the game (e.g. the players' names).
:return: The headers in a dict.
"""
headers: dict[str, str | int] = {}
headers["Event"] = game.pgn_event()
headers["Site"] = game.short_url()
headers["Date"] = game.game_start.strftime("%Y.%m.%d")
headers["White"] = game.white.name or str(game.white)
headers["Black"] = game.black.name or str(game.black)
headers["Result"] = game.result()
if game.black.rating:
headers["BlackElo"] = game.black.rating
if game.black.title:
headers["BlackTitle"] = game.black.title
if game.perf_name != "correspondence":
headers["TimeControl"] = game.time_control()
headers["UTCDate"] = headers["Date"]
headers["UTCTime"] = game.game_start.strftime("%H:%M:%S")
headers["Variant"] = game.variant_name
if game.initial_fen and game.initial_fen != "startpos":
headers["Setup"] = 1
headers["FEN"] = game.initial_fen
if game.white.rating:
headers["WhiteElo"] = game.white.rating
if game.white.title:
headers["WhiteTitle"] = game.white.title
return headers
def save_pgn_record(event: EventType, config: Configuration, user_name: str) -> None:
"""
Write the game PGN record to a file.
:param event: A local_game_done event from the control queue.
:param config: The user's bot configuration.
:param user_name: The bot's name.
"""
pgn = event["game"]["pgn"]
pgn_headers = chess.pgn.read_headers(io.StringIO(pgn))
if not config.pgn_directory or pgn_headers is None:
return
game_id = event["game"]["id"]
white_name = pgn_headers["White"]
black_name = pgn_headers["Black"]
game_is_over = event["game"]["complete"]
os.makedirs(config.pgn_directory, exist_ok=True)
game_path = get_game_file_path(config, game_id, white_name, black_name, user_name, game_is_over)
single_game_path = get_game_file_path(config, game_id, white_name, black_name, user_name, game_is_over, force_single=True)
write_mode = "w" if game_path == single_game_path else "a"
logger.debug(f"Writing PGN game record to: {game_path}")
with open(game_path, write_mode) as game_file:
game_file.write(pgn + "\n\n")
if os.path.exists(single_game_path) and game_path != single_game_path:
os.remove(single_game_path)
def intro() -> str:
"""Return the intro string."""
return fr"""
. _/|
. // o\
. || ._) lichess-bot {__version__} on {platform.system()} {platform.release()}
. //__\
. )___( Play on Lichess with a bot
"""
auto_log_directory = "lichess_bot_auto_logs"
def log_python_and_libraries() -> None:
"""Log the installed libraries and the python version."""
logger.debug(f"Python version: {'.'.join(map(str, sys.version_info))}")
text = "Installed libraries:\n"
distributions = importlib.metadata.distributions()
for distribution in distributions:
text += f"{distribution.metadata['Name']}=={distribution.version}\n"
text += "\n"
logger.debug(text)
def start_lichess_bot() -> None:
"""Parse arguments passed to lichess-bot.py and starts lichess-bot."""
parser = argparse.ArgumentParser(description="Play on Lichess with a bot")
parser.add_argument("-u", action="store_true", help="Upgrade your account to a bot account.")
parser.add_argument("-v", action="store_true", help="Make output more verbose. Include all communication with lichess.")
parser.add_argument("--config", help="Specify a configuration file (defaults to ./config.yml).")
parser.add_argument("-l", "--logfile", help="Record all console output to a log file.", default=None)
parser.add_argument("--disable_auto_logging", action="store_true", help="Disable automatic logging.")
args = parser.parse_args()
logging_level = logging.DEBUG if args.v else logging.INFO
logging_configurer(logging_level, args.logfile, args.disable_auto_logging)
logger.info(intro(), extra={"highlighter": None})
CONFIG = load_config(args.config or "./config.yml")
if not args.disable_auto_logging:
with open(os.path.join(auto_log_directory, "config.log"), "w") as config_log:
log_config(CONFIG.config, config_log.write)
logger.info("Checking engine configuration ...")
with engine_wrapper.create_engine(CONFIG):
pass
logger.info("Engine configuration OK")
max_retries = CONFIG.engine.online_moves.max_retries
check_python_version()
log_python_and_libraries()
li = lichess.Lichess(CONFIG.token, CONFIG.url, __version__, logging_level, max_retries)
user_profile = li.get_profile()
username = user_profile["username"]
is_bot = user_profile.get("title") == "BOT"
logger.info(f"Welcome {username}!")
if args.u and not is_bot:
is_bot = upgrade_account(li)
if is_bot:
start(li, user_profile, CONFIG, logging_level, args.logfile, args.disable_auto_logging)
else:
logger.error(f"{username} is not a bot account. Please upgrade it to a bot account!")
logging.shutdown()
def check_python_version() -> None:
"""Raise a warning or an exception if the version isn't supported or is deprecated."""
def version_numeric(version_str: str) -> list[int]:
return [int(n) for n in version_str.split(".")]
python_deprecated_version = version_numeric(versioning_info["deprecated_python_version"])
python_good_version = version_numeric(versioning_info["minimum_python_version"])
version_change_date = versioning_info["deprecation_date"]
this_python_version = list(sys.version_info[0:2])
def version_str(version: list[int]) -> str:
return f"Python {'.'.join(str(n) for n in version)}"
upgrade_request = (f"You are currently running {version_str(this_python_version)}. "
f"Please upgrade to {version_str(python_good_version)} or newer")
out_of_date_error = RuntimeError("A newer version of Python is required "
f"to run this version of lichess-bot. {upgrade_request}.")
out_of_date_warning = ("A newer version of Python will be required "
f"on {version_change_date} to run lichess-bot. {upgrade_request} before then.")
this_lichess_bot_version = version_numeric(__version__)
lichess_bot_breaking_version = list(version_change_date.timetuple()[0:3])
if this_python_version < python_deprecated_version:
raise out_of_date_error
if this_python_version == python_deprecated_version:
if this_lichess_bot_version < lichess_bot_breaking_version:
logger.warning(out_of_date_warning)
else:
raise out_of_date_error
def start_program() -> None:
"""Start lichess-bot and restart when needed."""
multiprocessing.set_start_method("spawn")
try:
while should_restart():
disable_restart()
# Restart on network errors.
try:
start_lichess_bot()
except RequestException:
stop.restart = True
logger.exception("Restarting lichess-bot due to a network error:")
if stop.terminated or stop.force_quit:
logger.info("Termination requested - stopping restart cycle")
break
time.sleep(10 if should_restart() else 0)
except Exception:
logger.exception("Quitting lichess-bot due to an error:")
|