From 6eaacb4fe348496e2f6a8ec1d8d6dfe28ed88a18 Mon Sep 17 00:00:00 2001 From: Mark Harrison Date: Wed, 17 Jan 2024 22:40:23 -0800 Subject: [PATCH] File organization (#885) * Move files to subfolders * Make lib/ into a package * Update lib file locations * Update github actions * Fix links * Ignore all __pycache__ directories --- .github/workflows/mypy.yml | 2 +- .github/workflows/python-build.yml | 2 +- .github/workflows/python-test.yml | 2 +- .github/workflows/update_version.py | 4 +- .gitignore | 3 +- CODE_OF_CONDUCT.md => docs/CODE_OF_CONDUCT.md | 128 +-- CONTRIBUTING.md => docs/CONTRIBUTING.md | 114 +-- LICENSE => docs/LICENSE | 0 lib/__init__.py | 1 + config.py => lib/config.py | 0 conversation.py => lib/conversation.py | 8 +- engine_wrapper.py => lib/engine_wrapper.py | 10 +- lichess.py => lib/lichess.py | 2 +- matchmaking.py => lib/matchmaking.py | 746 +++++++++--------- model.py => lib/model.py | 4 +- strategies.py => lib/strategies.py | 2 +- timer.py => lib/timer.py | 0 versioning.yml => lib/versioning.yml | 0 lichess-bot.py | 13 +- test_bot/conftest.py | 4 +- test_bot/lichess.py | 2 +- .../test-requirements.txt | 0 test_bot/test_bot.py | 15 +- 23 files changed, 529 insertions(+), 533 deletions(-) rename CODE_OF_CONDUCT.md => docs/CODE_OF_CONDUCT.md (97%) rename CONTRIBUTING.md => docs/CONTRIBUTING.md (76%) rename LICENSE => docs/LICENSE (100%) create mode 100644 lib/__init__.py rename config.py => lib/config.py (100%) rename conversation.py => lib/conversation.py (96%) rename engine_wrapper.py => lib/engine_wrapper.py (99%) rename lichess.py => lib/lichess.py (99%) rename matchmaking.py => lib/matchmaking.py (97%) rename model.py => lib/model.py (99%) rename strategies.py => lib/strategies.py (98%) rename timer.py => lib/timer.py (100%) rename versioning.yml => lib/versioning.yml (100%) rename test-requirements.txt => test_bot/test-requirements.txt (100%) diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml index 0b13571ee..d2d428768 100644 --- a/.github/workflows/mypy.yml +++ b/.github/workflows/mypy.yml @@ -26,7 +26,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt - pip install -r test-requirements.txt + pip install -r test_bot/test-requirements.txt - name: Run mypy run: | mypy --strict . diff --git a/.github/workflows/python-build.yml b/.github/workflows/python-build.yml index a5c97e2fd..48c6b69b7 100644 --- a/.github/workflows/python-build.yml +++ b/.github/workflows/python-build.yml @@ -27,7 +27,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt - pip install -r test-requirements.txt + pip install -r test_bot/test-requirements.txt - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 0a6298085..cd6927119 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -28,7 +28,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt - pip install -r test-requirements.txt + pip install -r test_bot/test-requirements.txt - name: Restore engines id: cache-temp-restore uses: actions/cache/restore@v3 diff --git a/.github/workflows/update_version.py b/.github/workflows/update_version.py index 23e64e368..5639d5354 100644 --- a/.github/workflows/update_version.py +++ b/.github/workflows/update_version.py @@ -3,7 +3,7 @@ import datetime import os -with open("versioning.yml") as version_file: +with open("lib/versioning.yml") as version_file: versioning_info = yaml.safe_load(version_file) current_version = versioning_info["lichess_bot_version"] @@ -19,7 +19,7 @@ versioning_info["lichess_bot_version"] = new_version -with open("versioning.yml", "w") as version_file: +with open("lib/versioning.yml", "w") as version_file: yaml.dump(versioning_info, version_file, sort_keys=False) with open(os.environ['GITHUB_OUTPUT'], 'a') as fh: diff --git a/.gitignore b/.gitignore index 1b7c7e5cd..1c0bc9d11 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ config.yml -__pycache__ -test_bot/__pycache__ +**/__pycache__ /engines/* !/engines/README.md diff --git a/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md similarity index 97% rename from CODE_OF_CONDUCT.md rename to docs/CODE_OF_CONDUCT.md index bacbb5498..d5b86a2b2 100644 --- a/CODE_OF_CONDUCT.md +++ b/docs/CODE_OF_CONDUCT.md @@ -1,64 +1,64 @@ -# Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity -and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the - overall community - -Examples of unacceptable behavior include: - -* The use of sexualized language or imagery, and sexual attention or - advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email - address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Our Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement. -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html - -For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq +# Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html + +For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq diff --git a/CONTRIBUTING.md b/docs/CONTRIBUTING.md similarity index 76% rename from CONTRIBUTING.md rename to docs/CONTRIBUTING.md index 1c526d47f..6a93ea9f8 100644 --- a/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,57 +1,57 @@ -# Contributing to lichess-bot - -We welcome your contributions! There are multiple ways to contribute. - -## Table of Contents - -1. [Code of Conduct](#code-of-conduct) -2. [How to Contribute](#how-to-contribute) -3. [Reporting Bugs](#reporting-bugs) -4. [Requesting Features](#requesting-features) -5. [Submitting Pull Requests](#submitting-pull-requests) -6. [Testing](#testing) -7. [Documentation](#documentation) - -## Code of Conduct - -Please review our [Code of Conduct](/CODE_OF_CONDUCT.md) before participating in our community. We want all contributors to feel welcome and to foster an open and inclusive environment. - -## How to Contribute - -We welcome contributions in the form of bug reports, feature requests, code changes, and documentation improvements. Here's how you can contribute: - -- Fork the repository to your GitHub account. -- Create a new branch for your feature or bug fix. -- Make your changes and commit them with a clear and concise commit message. -- Push your changes to your branch. -- Submit a pull request to the main repository. - -Please follow our [Pull Request Template](.github/pull_request_template.md) when submitting a pull request. - -## Reporting Bugs - -If you find a bug, please open an issue with a detailed description of the problem. Include information about your environment and steps to reproduce the issue. -When filing a bug remember that the better written the bug is, the more likely it is to be fixed. -Please follow our [Bug Report Template](.github/ISSUE_TEMPLATE/bug_report.md) when submitting a pull request. - -## Requesting Features - -We encourage you to open an issue to propose new features or improvements. Please provide as much detail as possible about your suggestion. -Please follow our [Feature Request Template](.github/ISSUE_TEMPLATE/feature_request.md) when submitting a pull request. - -## Submitting Pull Requests - -When submitting a pull request, please ensure the following: - -- You have added or updated relevant documentation. -- Tests (if applicable) have been added or updated. -- Your branch is up-to-date with the main repository. -- The pull request title and description are clear and concise. - -## Testing - -Ensure that your changes pass all existing tests and consider adding new tests if applicable. - -## Documentation - -Improvements to the documentation are always welcome. If you find areas that need clarification or additional information, please submit a pull request. +# Contributing to lichess-bot + +We welcome your contributions! There are multiple ways to contribute. + +## Table of Contents + +1. [Code of Conduct](#code-of-conduct) +2. [How to Contribute](#how-to-contribute) +3. [Reporting Bugs](#reporting-bugs) +4. [Requesting Features](#requesting-features) +5. [Submitting Pull Requests](#submitting-pull-requests) +6. [Testing](#testing) +7. [Documentation](#documentation) + +## Code of Conduct + +Please review our [Code of Conduct](/docs/CODE_OF_CONDUCT.md) before participating in our community. We want all contributors to feel welcome and to foster an open and inclusive environment. + +## How to Contribute + +We welcome contributions in the form of bug reports, feature requests, code changes, and documentation improvements. Here's how you can contribute: + +- Fork the repository to your GitHub account. +- Create a new branch for your feature or bug fix. +- Make your changes and commit them with a clear and concise commit message. +- Push your changes to your branch. +- Submit a pull request to the main repository. + +Please follow our [Pull Request Template](/.github/pull_request_template.md) when submitting a pull request. + +## Reporting Bugs + +If you find a bug, please open an issue with a detailed description of the problem. Include information about your environment and steps to reproduce the issue. +When filing a bug remember that the better written the bug is, the more likely it is to be fixed. +Please follow our [Bug Report Template](/.github/ISSUE_TEMPLATE/bug_report.md) when submitting a pull request. + +## Requesting Features + +We encourage you to open an issue to propose new features or improvements. Please provide as much detail as possible about your suggestion. +Please follow our [Feature Request Template](/.github/ISSUE_TEMPLATE/feature_request.md) when submitting a pull request. + +## Submitting Pull Requests + +When submitting a pull request, please ensure the following: + +- You have added or updated relevant documentation. +- Tests (if applicable) have been added or updated. +- Your branch is up-to-date with the main repository. +- The pull request title and description are clear and concise. + +## Testing + +Ensure that your changes pass all existing tests and consider adding new tests if applicable. + +## Documentation + +Improvements to the documentation are always welcome. If you find areas that need clarification or additional information, please submit a pull request. diff --git a/LICENSE b/docs/LICENSE similarity index 100% rename from LICENSE rename to docs/LICENSE diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 000000000..b39f30434 --- /dev/null +++ b/lib/__init__.py @@ -0,0 +1 @@ +"""This lib folder contains the library code necessary for running lichess-bot.""" diff --git a/config.py b/lib/config.py similarity index 100% rename from config.py rename to lib/config.py diff --git a/conversation.py b/lib/conversation.py similarity index 96% rename from conversation.py rename to lib/conversation.py index dcd29d92f..79450f8fa 100644 --- a/conversation.py +++ b/lib/conversation.py @@ -1,11 +1,11 @@ """Allows lichess-bot to send messages to the chat.""" from __future__ import annotations import logging -import model -from engine_wrapper import EngineWrapper -from lichess import Lichess +from lib import model +from lib.engine_wrapper import EngineWrapper +from lib.lichess import Lichess from collections.abc import Sequence -from timer import seconds +from lib.timer import seconds MULTIPROCESSING_LIST_TYPE = Sequence[model.Challenge] logger = logging.getLogger(__name__) diff --git a/engine_wrapper.py b/lib/engine_wrapper.py similarity index 99% rename from engine_wrapper.py rename to lib/engine_wrapper.py index 144218776..f8d98d1d8 100644 --- a/engine_wrapper.py +++ b/lib/engine_wrapper.py @@ -15,11 +15,9 @@ from collections import Counter from collections.abc import Generator, Callable from contextlib import contextmanager -import config -import model -import lichess -from config import Configuration -from timer import Timer, msec, seconds, msec_str, sec_str, to_seconds +from lib import config, model, lichess +from lib.config import Configuration +from lib.timer import Timer, msec, seconds, msec_str, sec_str, to_seconds from typing import Any, Optional, Union, Literal OPTIONS_TYPE = dict[str, Any] MOVE_INFO_TYPE = dict[str, Any] @@ -587,7 +585,7 @@ def getHomemadeEngine(name: str) -> type[MinimalEngine]: :param name: The name of the homemade engine. :return: The engine with this name. """ - import strategies + from lib import strategies engine: type[MinimalEngine] = getattr(strategies, name) return engine diff --git a/lichess.py b/lib/lichess.py similarity index 99% rename from lichess.py rename to lib/lichess.py index 217d4f690..ff2dde7b0 100644 --- a/lichess.py +++ b/lib/lichess.py @@ -9,7 +9,7 @@ import traceback from collections import defaultdict import datetime -from timer import Timer, seconds, sec_str +from lib.timer import Timer, seconds, sec_str from typing import Optional, Union, Any import chess.engine JSON_REPLY_TYPE = dict[str, Any] diff --git a/matchmaking.py b/lib/matchmaking.py similarity index 97% rename from matchmaking.py rename to lib/matchmaking.py index 725889ed9..c5666598b 100644 --- a/matchmaking.py +++ b/lib/matchmaking.py @@ -1,373 +1,373 @@ -"""Challenge other bots.""" -import random -import logging -import model -from timer import Timer, seconds, minutes, days -from collections import defaultdict -from collections.abc import Sequence -import lichess -import datetime -from config import Configuration, FilterType -from typing import Any, Optional -USER_PROFILE_TYPE = dict[str, Any] -EVENT_TYPE = dict[str, Any] -MULTIPROCESSING_LIST_TYPE = Sequence[model.Challenge] -DAILY_TIMERS_TYPE = list[Timer] - -logger = logging.getLogger(__name__) - -daily_challenges_file_name = "daily_challenge_times.txt" -timestamp_format = "%Y-%m-%d %H:%M:%S\n" - - -def read_daily_challenges() -> DAILY_TIMERS_TYPE: - """Read the challenges we have created in the past 24 hours from a text file.""" - timers: DAILY_TIMERS_TYPE = [] - try: - with open(daily_challenges_file_name) as file: - for line in file: - timers.append(Timer(days(1), datetime.datetime.strptime(line, timestamp_format))) - except FileNotFoundError: - pass - - return [timer for timer in timers if not timer.is_expired()] - - -def write_daily_challenges(daily_challenges: DAILY_TIMERS_TYPE) -> None: - """Write the challenges we have created in the past 24 hours to a text file.""" - with open(daily_challenges_file_name, "w") as file: - for timer in daily_challenges: - file.write(timer.starting_timestamp(timestamp_format)) - - -class Matchmaking: - """Challenge other bots.""" - - def __init__(self, li: lichess.Lichess, config: Configuration, user_profile: USER_PROFILE_TYPE) -> None: - """Initialize values needed for matchmaking.""" - self.li = li - self.variants = list(filter(lambda variant: variant != "fromPosition", config.challenge.variants)) - self.matchmaking_cfg = config.matchmaking - self.user_profile = user_profile - self.last_challenge_created_delay = Timer(seconds(25)) # Challenges expire after 20 seconds. - self.last_game_ended_delay = Timer(minutes(self.matchmaking_cfg.challenge_timeout)) - self.last_user_profile_update_time = Timer(minutes(5)) - self.min_wait_time = seconds(60) # Wait before new challenge to avoid api rate limits. - self.challenge_id: str = "" - self.daily_challenges: DAILY_TIMERS_TYPE = read_daily_challenges() - - # (opponent name, game aspect) --> other bot is likely to accept challenge - # game aspect is the one the challenged bot objects to and is one of: - # - game speed (bullet, blitz, etc.) - # - variant (standard, horde, etc.) - # - casual/rated - # - empty string (if no other reason is given or self.filter_type is COARSE) - self.challenge_type_acceptable: defaultdict[tuple[str, str], bool] = defaultdict(lambda: True) - self.challenge_filter = self.matchmaking_cfg.challenge_filter - - for name in self.matchmaking_cfg.block_list: - self.add_to_block_list(name) - - def should_create_challenge(self) -> bool: - """Whether we should create a challenge.""" - matchmaking_enabled = self.matchmaking_cfg.allow_matchmaking - time_has_passed = self.last_game_ended_delay.is_expired() - challenge_expired = self.last_challenge_created_delay.is_expired() and self.challenge_id - min_wait_time_passed = self.last_challenge_created_delay.time_since_reset() > self.min_wait_time - if challenge_expired: - self.li.cancel(self.challenge_id) - logger.info(f"Challenge id {self.challenge_id} cancelled.") - self.challenge_id = "" - self.show_earliest_challenge_time() - return bool(matchmaking_enabled and (time_has_passed or challenge_expired) and min_wait_time_passed) - - def create_challenge(self, username: str, base_time: int, increment: int, days: int, variant: str, - mode: str) -> str: - """Create a challenge.""" - params = {"rated": mode == "rated", "variant": variant} - - if days: - params["days"] = days - elif base_time or increment: - params["clock.limit"] = base_time - params["clock.increment"] = increment - else: - logger.error("At least one of challenge_days, challenge_initial_time, or challenge_increment " - "must be greater than zero in the matchmaking section of your config file.") - return "" - - try: - self.update_daily_challenge_record() - self.last_challenge_created_delay.reset() - response = self.li.challenge(username, params) - challenge_id: str = response.get("challenge", {}).get("id", "") - if not challenge_id: - logger.error(response) - self.add_to_block_list(username) - self.show_earliest_challenge_time() - return challenge_id - except Exception as e: - logger.warning("Could not create challenge") - logger.debug(e, exc_info=e) - self.show_earliest_challenge_time() - return "" - - def update_daily_challenge_record(self) -> None: - """ - Record timestamp of latest challenge and update minimum wait time. - - As the number of challenges in a day increase, the minimum wait time between challenges increases. - 0 - 49 challenges --> 1 minute - 50 - 99 challenges --> 2 minutes - 100 - 149 challenges --> 3 minutes - etc. - """ - self.daily_challenges = [timer for timer in self.daily_challenges if not timer.is_expired()] - self.daily_challenges.append(Timer(days(1))) - self.min_wait_time = seconds(60) * ((len(self.daily_challenges) // 50) + 1) - write_daily_challenges(self.daily_challenges) - - def perf(self) -> dict[str, dict[str, Any]]: - """Get the bot's rating in every variant. Bullet, blitz, rapid etc. are considered different variants.""" - user_perf: dict[str, dict[str, Any]] = self.user_profile["perfs"] - return user_perf - - def username(self) -> str: - """Our username.""" - username: str = self.user_profile["username"] - return username - - def update_user_profile(self) -> None: - """Update our user profile data, to get our latest rating.""" - if self.last_user_profile_update_time.is_expired(): - self.last_user_profile_update_time.reset() - try: - self.user_profile = self.li.get_profile() - except Exception: - pass - - def get_weights(self, online_bots: list[USER_PROFILE_TYPE], rating_preference: str, min_rating: int, max_rating: int, - game_type: str) -> list[int]: - """Get the weight for each bot. A higher weights means the bot is more likely to get challenged.""" - def rating(bot: USER_PROFILE_TYPE) -> int: - return int(bot.get("perfs", {}).get(game_type, {}).get("rating", 0)) - - if rating_preference == "high": - # A bot with max_rating rating will be twice as likely to get picked than a bot with min_rating rating. - reduce_ratings_by = min(min_rating - (max_rating - min_rating), min_rating - 1) - weights = [rating(bot) - reduce_ratings_by for bot in online_bots] - elif rating_preference == "low": - # A bot with min_rating rating will be twice as likely to get picked than a bot with max_rating rating. - reduce_ratings_by = max(max_rating - (min_rating - max_rating), max_rating + 1) - weights = [reduce_ratings_by - rating(bot) for bot in online_bots] - else: - weights = [1] * len(online_bots) - return weights - - def choose_opponent(self) -> tuple[Optional[str], int, int, int, str, str]: - """Choose an opponent.""" - override_choice = random.choice(self.matchmaking_cfg.overrides.keys() + [None]) - logger.info(f"Using the {override_choice or 'default'} matchmaking configuration.") - override = {} if override_choice is None else self.matchmaking_cfg.overrides.lookup(override_choice) - match_config = self.matchmaking_cfg | override - - variant = self.get_random_config_value(match_config, "challenge_variant", self.variants) - mode = self.get_random_config_value(match_config, "challenge_mode", ["casual", "rated"]) - rating_preference = match_config.rating_preference - - base_time = random.choice(match_config.challenge_initial_time) - increment = random.choice(match_config.challenge_increment) - days = random.choice(match_config.challenge_days) - - play_correspondence = [bool(days), not bool(base_time or increment)] - if random.choice(play_correspondence): - base_time = 0 - increment = 0 - else: - days = 0 - - game_type = game_category(variant, base_time, increment, days) - - min_rating = match_config.opponent_min_rating - max_rating = match_config.opponent_max_rating - rating_diff = match_config.opponent_rating_difference - bot_rating = self.perf().get(game_type, {}).get("rating", 0) - if rating_diff is not None and bot_rating > 0: - min_rating = bot_rating - rating_diff - max_rating = bot_rating + rating_diff - logger.info(f"Seeking {game_type} game with opponent rating in [{min_rating}, {max_rating}] ...") - allow_tos_violation = match_config.opponent_allow_tos_violation - - def is_suitable_opponent(bot: USER_PROFILE_TYPE) -> bool: - perf = bot.get("perfs", {}).get(game_type, {}) - return (bot["username"] != self.username() - and not self.in_block_list(bot["username"]) - and not bot.get("disabled") - and (allow_tos_violation or not bot.get("tosViolation")) # Terms of Service violation. - and perf.get("games", 0) > 0 - and min_rating <= perf.get("rating", 0) <= max_rating) - - online_bots = self.li.get_online_bots() - online_bots = list(filter(is_suitable_opponent, online_bots)) - - def ready_for_challenge(bot: USER_PROFILE_TYPE) -> bool: - aspects = [variant, game_type, mode] if self.challenge_filter == FilterType.FINE else [] - return all(self.should_accept_challenge(bot["username"], aspect) for aspect in aspects) - - ready_bots = list(filter(ready_for_challenge, online_bots)) - online_bots = ready_bots or online_bots - bot_username = None - weights = self.get_weights(online_bots, rating_preference, min_rating, max_rating, game_type) - - try: - bot = random.choices(online_bots, weights=weights)[0] - bot_profile = self.li.get_public_data(bot["username"]) - if bot_profile.get("blocking"): - self.add_to_block_list(bot["username"]) - else: - bot_username = bot["username"] - except Exception: - if online_bots: - logger.exception("Error:") - else: - logger.error("No suitable bots found to challenge.") - - return bot_username, base_time, increment, days, variant, mode - - def get_random_config_value(self, config: Configuration, parameter: str, choices: list[str]) -> str: - """Choose a random value from `choices` if the parameter value in the config is `random`.""" - value: str = config.lookup(parameter) - return value if value != "random" else random.choice(choices) - - def challenge(self, active_games: set[str], challenge_queue: MULTIPROCESSING_LIST_TYPE) -> None: - """ - Challenge an opponent. - - :param active_games: The games that the bot is playing. - :param challenge_queue: The queue containing the challenges. - """ - if active_games or challenge_queue or not self.should_create_challenge(): - return - - logger.info("Challenging a random bot") - self.update_user_profile() - bot_username, base_time, increment, days, variant, mode = self.choose_opponent() - logger.info(f"Will challenge {bot_username} for a {variant} game.") - challenge_id = self.create_challenge(bot_username, base_time, increment, days, variant, mode) if bot_username else "" - logger.info(f"Challenge id is {challenge_id if challenge_id else 'None'}.") - self.challenge_id = challenge_id - - def game_done(self) -> None: - """Reset the timer for when the last game ended, and prints the earliest that the next challenge will be created.""" - self.last_game_ended_delay.reset() - self.show_earliest_challenge_time() - - def show_earliest_challenge_time(self) -> None: - """Show the earliest that the next challenge will be created.""" - if self.matchmaking_cfg.allow_matchmaking: - postgame_timeout = self.last_game_ended_delay.time_until_expiration() - time_to_next_challenge = self.min_wait_time - self.last_challenge_created_delay.time_since_reset() - time_left = max(postgame_timeout, time_to_next_challenge) - earliest_challenge_time = datetime.datetime.now() + time_left - challenges = "challenge" + ("" if len(self.daily_challenges) == 1 else "s") - logger.info(f"Next challenge will be created after {earliest_challenge_time.strftime('%X')} " - f"({len(self.daily_challenges)} {challenges} in last 24 hours)") - - def add_to_block_list(self, username: str) -> None: - """Add a bot to the blocklist.""" - self.add_challenge_filter(username, "") - - def in_block_list(self, username: str) -> bool: - """Check if an opponent is in the block list to prevent future challenges.""" - return not self.should_accept_challenge(username, "") - - def add_challenge_filter(self, username: str, game_aspect: str) -> None: - """ - Prevent creating another challenge when an opponent has decline a challenge. - - :param username: The name of the opponent. - :param game_aspect: The aspect of a game (time control, chess variant, etc.) - that caused the opponent to decline a challenge. If the parameter is empty, - that is equivalent to adding the opponent to the block list. - """ - self.challenge_type_acceptable[(username, game_aspect)] = False - - def should_accept_challenge(self, username: str, game_aspect: str) -> bool: - """ - Whether a bot is likely to accept a challenge to a game. - - :param username: The name of the opponent. - :param game_aspect: A category of the challenge type (time control, chess variant, etc.) to test for acceptance. - If game_aspect is empty, this is equivalent to checking if the opponent is in the block list. - """ - return self.challenge_type_acceptable[(username, game_aspect)] - - def accepted_challenge(self, event: EVENT_TYPE) -> None: - """ - Set the challenge id to an empty string, if the challenge was accepted. - - Otherwise, we would attempt to cancel the challenge later. - """ - if self.challenge_id == event["game"]["id"]: - self.challenge_id = "" - - def declined_challenge(self, event: EVENT_TYPE) -> None: - """ - Handle a challenge that was declined by the opponent. - - Depends on whether `FilterType` is `NONE`, `COARSE`, or `FINE`. - """ - challenge = model.Challenge(event["challenge"], self.user_profile) - opponent = challenge.opponent - reason = event["challenge"]["declineReason"] - logger.info(f"{opponent} declined {challenge}: {reason}") - if self.challenge_id == challenge.id: - self.challenge_id = "" - if not challenge.from_self or self.challenge_filter == FilterType.NONE: - return - - mode = "rated" if challenge.rated else "casual" - decline_details: dict[str, str] = {"generic": "", - "later": "", - "nobot": "", - "toofast": challenge.speed, - "tooslow": challenge.speed, - "timecontrol": challenge.speed, - "rated": mode, - "casual": mode, - "standard": challenge.variant, - "variant": challenge.variant} - - reason_key = event["challenge"]["declineReasonKey"].lower() - if reason_key not in decline_details: - logger.warning(f"Unknown decline reason received: {reason_key}") - game_problem = decline_details.get(reason_key, "") if self.challenge_filter == FilterType.FINE else "" - self.add_challenge_filter(opponent.name, game_problem) - logger.info(f"Will not challenge {opponent} to another {game_problem}".strip() + " game.") - - self.show_earliest_challenge_time() - - -def game_category(variant: str, base_time: int, increment: int, days: int) -> str: - """ - Get the game type (e.g. bullet, atomic, classical). Lichess has one rating for every variant regardless of time control. - - :param variant: The game's variant. - :param base_time: The base time in seconds. - :param increment: The increment in seconds. - :param days: If the game is correspondence, we have some days to play the move. - :return: The game category. - """ - game_duration = base_time + increment * 40 - if variant != "standard": - return variant - elif days: - return "correspondence" - elif game_duration < 179: - return "bullet" - elif game_duration < 479: - return "blitz" - elif game_duration < 1499: - return "rapid" - else: - return "classical" +"""Challenge other bots.""" +import random +import logging +from lib import model +from lib.timer import Timer, seconds, minutes, days +from collections import defaultdict +from collections.abc import Sequence +from lib import lichess +import datetime +from lib.config import Configuration, FilterType +from typing import Any, Optional +USER_PROFILE_TYPE = dict[str, Any] +EVENT_TYPE = dict[str, Any] +MULTIPROCESSING_LIST_TYPE = Sequence[model.Challenge] +DAILY_TIMERS_TYPE = list[Timer] + +logger = logging.getLogger(__name__) + +daily_challenges_file_name = "daily_challenge_times.txt" +timestamp_format = "%Y-%m-%d %H:%M:%S\n" + + +def read_daily_challenges() -> DAILY_TIMERS_TYPE: + """Read the challenges we have created in the past 24 hours from a text file.""" + timers: DAILY_TIMERS_TYPE = [] + try: + with open(daily_challenges_file_name) as file: + for line in file: + timers.append(Timer(days(1), datetime.datetime.strptime(line, timestamp_format))) + except FileNotFoundError: + pass + + return [timer for timer in timers if not timer.is_expired()] + + +def write_daily_challenges(daily_challenges: DAILY_TIMERS_TYPE) -> None: + """Write the challenges we have created in the past 24 hours to a text file.""" + with open(daily_challenges_file_name, "w") as file: + for timer in daily_challenges: + file.write(timer.starting_timestamp(timestamp_format)) + + +class Matchmaking: + """Challenge other bots.""" + + def __init__(self, li: lichess.Lichess, config: Configuration, user_profile: USER_PROFILE_TYPE) -> None: + """Initialize values needed for matchmaking.""" + self.li = li + self.variants = list(filter(lambda variant: variant != "fromPosition", config.challenge.variants)) + self.matchmaking_cfg = config.matchmaking + self.user_profile = user_profile + self.last_challenge_created_delay = Timer(seconds(25)) # Challenges expire after 20 seconds. + self.last_game_ended_delay = Timer(minutes(self.matchmaking_cfg.challenge_timeout)) + self.last_user_profile_update_time = Timer(minutes(5)) + self.min_wait_time = seconds(60) # Wait before new challenge to avoid api rate limits. + self.challenge_id: str = "" + self.daily_challenges: DAILY_TIMERS_TYPE = read_daily_challenges() + + # (opponent name, game aspect) --> other bot is likely to accept challenge + # game aspect is the one the challenged bot objects to and is one of: + # - game speed (bullet, blitz, etc.) + # - variant (standard, horde, etc.) + # - casual/rated + # - empty string (if no other reason is given or self.filter_type is COARSE) + self.challenge_type_acceptable: defaultdict[tuple[str, str], bool] = defaultdict(lambda: True) + self.challenge_filter = self.matchmaking_cfg.challenge_filter + + for name in self.matchmaking_cfg.block_list: + self.add_to_block_list(name) + + def should_create_challenge(self) -> bool: + """Whether we should create a challenge.""" + matchmaking_enabled = self.matchmaking_cfg.allow_matchmaking + time_has_passed = self.last_game_ended_delay.is_expired() + challenge_expired = self.last_challenge_created_delay.is_expired() and self.challenge_id + min_wait_time_passed = self.last_challenge_created_delay.time_since_reset() > self.min_wait_time + if challenge_expired: + self.li.cancel(self.challenge_id) + logger.info(f"Challenge id {self.challenge_id} cancelled.") + self.challenge_id = "" + self.show_earliest_challenge_time() + return bool(matchmaking_enabled and (time_has_passed or challenge_expired) and min_wait_time_passed) + + def create_challenge(self, username: str, base_time: int, increment: int, days: int, variant: str, + mode: str) -> str: + """Create a challenge.""" + params = {"rated": mode == "rated", "variant": variant} + + if days: + params["days"] = days + elif base_time or increment: + params["clock.limit"] = base_time + params["clock.increment"] = increment + else: + logger.error("At least one of challenge_days, challenge_initial_time, or challenge_increment " + "must be greater than zero in the matchmaking section of your config file.") + return "" + + try: + self.update_daily_challenge_record() + self.last_challenge_created_delay.reset() + response = self.li.challenge(username, params) + challenge_id: str = response.get("challenge", {}).get("id", "") + if not challenge_id: + logger.error(response) + self.add_to_block_list(username) + self.show_earliest_challenge_time() + return challenge_id + except Exception as e: + logger.warning("Could not create challenge") + logger.debug(e, exc_info=e) + self.show_earliest_challenge_time() + return "" + + def update_daily_challenge_record(self) -> None: + """ + Record timestamp of latest challenge and update minimum wait time. + + As the number of challenges in a day increase, the minimum wait time between challenges increases. + 0 - 49 challenges --> 1 minute + 50 - 99 challenges --> 2 minutes + 100 - 149 challenges --> 3 minutes + etc. + """ + self.daily_challenges = [timer for timer in self.daily_challenges if not timer.is_expired()] + self.daily_challenges.append(Timer(days(1))) + self.min_wait_time = seconds(60) * ((len(self.daily_challenges) // 50) + 1) + write_daily_challenges(self.daily_challenges) + + def perf(self) -> dict[str, dict[str, Any]]: + """Get the bot's rating in every variant. Bullet, blitz, rapid etc. are considered different variants.""" + user_perf: dict[str, dict[str, Any]] = self.user_profile["perfs"] + return user_perf + + def username(self) -> str: + """Our username.""" + username: str = self.user_profile["username"] + return username + + def update_user_profile(self) -> None: + """Update our user profile data, to get our latest rating.""" + if self.last_user_profile_update_time.is_expired(): + self.last_user_profile_update_time.reset() + try: + self.user_profile = self.li.get_profile() + except Exception: + pass + + def get_weights(self, online_bots: list[USER_PROFILE_TYPE], rating_preference: str, min_rating: int, max_rating: int, + game_type: str) -> list[int]: + """Get the weight for each bot. A higher weights means the bot is more likely to get challenged.""" + def rating(bot: USER_PROFILE_TYPE) -> int: + return int(bot.get("perfs", {}).get(game_type, {}).get("rating", 0)) + + if rating_preference == "high": + # A bot with max_rating rating will be twice as likely to get picked than a bot with min_rating rating. + reduce_ratings_by = min(min_rating - (max_rating - min_rating), min_rating - 1) + weights = [rating(bot) - reduce_ratings_by for bot in online_bots] + elif rating_preference == "low": + # A bot with min_rating rating will be twice as likely to get picked than a bot with max_rating rating. + reduce_ratings_by = max(max_rating - (min_rating - max_rating), max_rating + 1) + weights = [reduce_ratings_by - rating(bot) for bot in online_bots] + else: + weights = [1] * len(online_bots) + return weights + + def choose_opponent(self) -> tuple[Optional[str], int, int, int, str, str]: + """Choose an opponent.""" + override_choice = random.choice(self.matchmaking_cfg.overrides.keys() + [None]) + logger.info(f"Using the {override_choice or 'default'} matchmaking configuration.") + override = {} if override_choice is None else self.matchmaking_cfg.overrides.lookup(override_choice) + match_config = self.matchmaking_cfg | override + + variant = self.get_random_config_value(match_config, "challenge_variant", self.variants) + mode = self.get_random_config_value(match_config, "challenge_mode", ["casual", "rated"]) + rating_preference = match_config.rating_preference + + base_time = random.choice(match_config.challenge_initial_time) + increment = random.choice(match_config.challenge_increment) + days = random.choice(match_config.challenge_days) + + play_correspondence = [bool(days), not bool(base_time or increment)] + if random.choice(play_correspondence): + base_time = 0 + increment = 0 + else: + days = 0 + + game_type = game_category(variant, base_time, increment, days) + + min_rating = match_config.opponent_min_rating + max_rating = match_config.opponent_max_rating + rating_diff = match_config.opponent_rating_difference + bot_rating = self.perf().get(game_type, {}).get("rating", 0) + if rating_diff is not None and bot_rating > 0: + min_rating = bot_rating - rating_diff + max_rating = bot_rating + rating_diff + logger.info(f"Seeking {game_type} game with opponent rating in [{min_rating}, {max_rating}] ...") + allow_tos_violation = match_config.opponent_allow_tos_violation + + def is_suitable_opponent(bot: USER_PROFILE_TYPE) -> bool: + perf = bot.get("perfs", {}).get(game_type, {}) + return (bot["username"] != self.username() + and not self.in_block_list(bot["username"]) + and not bot.get("disabled") + and (allow_tos_violation or not bot.get("tosViolation")) # Terms of Service violation. + and perf.get("games", 0) > 0 + and min_rating <= perf.get("rating", 0) <= max_rating) + + online_bots = self.li.get_online_bots() + online_bots = list(filter(is_suitable_opponent, online_bots)) + + def ready_for_challenge(bot: USER_PROFILE_TYPE) -> bool: + aspects = [variant, game_type, mode] if self.challenge_filter == FilterType.FINE else [] + return all(self.should_accept_challenge(bot["username"], aspect) for aspect in aspects) + + ready_bots = list(filter(ready_for_challenge, online_bots)) + online_bots = ready_bots or online_bots + bot_username = None + weights = self.get_weights(online_bots, rating_preference, min_rating, max_rating, game_type) + + try: + bot = random.choices(online_bots, weights=weights)[0] + bot_profile = self.li.get_public_data(bot["username"]) + if bot_profile.get("blocking"): + self.add_to_block_list(bot["username"]) + else: + bot_username = bot["username"] + except Exception: + if online_bots: + logger.exception("Error:") + else: + logger.error("No suitable bots found to challenge.") + + return bot_username, base_time, increment, days, variant, mode + + def get_random_config_value(self, config: Configuration, parameter: str, choices: list[str]) -> str: + """Choose a random value from `choices` if the parameter value in the config is `random`.""" + value: str = config.lookup(parameter) + return value if value != "random" else random.choice(choices) + + def challenge(self, active_games: set[str], challenge_queue: MULTIPROCESSING_LIST_TYPE) -> None: + """ + Challenge an opponent. + + :param active_games: The games that the bot is playing. + :param challenge_queue: The queue containing the challenges. + """ + if active_games or challenge_queue or not self.should_create_challenge(): + return + + logger.info("Challenging a random bot") + self.update_user_profile() + bot_username, base_time, increment, days, variant, mode = self.choose_opponent() + logger.info(f"Will challenge {bot_username} for a {variant} game.") + challenge_id = self.create_challenge(bot_username, base_time, increment, days, variant, mode) if bot_username else "" + logger.info(f"Challenge id is {challenge_id if challenge_id else 'None'}.") + self.challenge_id = challenge_id + + def game_done(self) -> None: + """Reset the timer for when the last game ended, and prints the earliest that the next challenge will be created.""" + self.last_game_ended_delay.reset() + self.show_earliest_challenge_time() + + def show_earliest_challenge_time(self) -> None: + """Show the earliest that the next challenge will be created.""" + if self.matchmaking_cfg.allow_matchmaking: + postgame_timeout = self.last_game_ended_delay.time_until_expiration() + time_to_next_challenge = self.min_wait_time - self.last_challenge_created_delay.time_since_reset() + time_left = max(postgame_timeout, time_to_next_challenge) + earliest_challenge_time = datetime.datetime.now() + time_left + challenges = "challenge" + ("" if len(self.daily_challenges) == 1 else "s") + logger.info(f"Next challenge will be created after {earliest_challenge_time.strftime('%X')} " + f"({len(self.daily_challenges)} {challenges} in last 24 hours)") + + def add_to_block_list(self, username: str) -> None: + """Add a bot to the blocklist.""" + self.add_challenge_filter(username, "") + + def in_block_list(self, username: str) -> bool: + """Check if an opponent is in the block list to prevent future challenges.""" + return not self.should_accept_challenge(username, "") + + def add_challenge_filter(self, username: str, game_aspect: str) -> None: + """ + Prevent creating another challenge when an opponent has decline a challenge. + + :param username: The name of the opponent. + :param game_aspect: The aspect of a game (time control, chess variant, etc.) + that caused the opponent to decline a challenge. If the parameter is empty, + that is equivalent to adding the opponent to the block list. + """ + self.challenge_type_acceptable[(username, game_aspect)] = False + + def should_accept_challenge(self, username: str, game_aspect: str) -> bool: + """ + Whether a bot is likely to accept a challenge to a game. + + :param username: The name of the opponent. + :param game_aspect: A category of the challenge type (time control, chess variant, etc.) to test for acceptance. + If game_aspect is empty, this is equivalent to checking if the opponent is in the block list. + """ + return self.challenge_type_acceptable[(username, game_aspect)] + + def accepted_challenge(self, event: EVENT_TYPE) -> None: + """ + Set the challenge id to an empty string, if the challenge was accepted. + + Otherwise, we would attempt to cancel the challenge later. + """ + if self.challenge_id == event["game"]["id"]: + self.challenge_id = "" + + def declined_challenge(self, event: EVENT_TYPE) -> None: + """ + Handle a challenge that was declined by the opponent. + + Depends on whether `FilterType` is `NONE`, `COARSE`, or `FINE`. + """ + challenge = model.Challenge(event["challenge"], self.user_profile) + opponent = challenge.opponent + reason = event["challenge"]["declineReason"] + logger.info(f"{opponent} declined {challenge}: {reason}") + if self.challenge_id == challenge.id: + self.challenge_id = "" + if not challenge.from_self or self.challenge_filter == FilterType.NONE: + return + + mode = "rated" if challenge.rated else "casual" + decline_details: dict[str, str] = {"generic": "", + "later": "", + "nobot": "", + "toofast": challenge.speed, + "tooslow": challenge.speed, + "timecontrol": challenge.speed, + "rated": mode, + "casual": mode, + "standard": challenge.variant, + "variant": challenge.variant} + + reason_key = event["challenge"]["declineReasonKey"].lower() + if reason_key not in decline_details: + logger.warning(f"Unknown decline reason received: {reason_key}") + game_problem = decline_details.get(reason_key, "") if self.challenge_filter == FilterType.FINE else "" + self.add_challenge_filter(opponent.name, game_problem) + logger.info(f"Will not challenge {opponent} to another {game_problem}".strip() + " game.") + + self.show_earliest_challenge_time() + + +def game_category(variant: str, base_time: int, increment: int, days: int) -> str: + """ + Get the game type (e.g. bullet, atomic, classical). Lichess has one rating for every variant regardless of time control. + + :param variant: The game's variant. + :param base_time: The base time in seconds. + :param increment: The increment in seconds. + :param days: If the game is correspondence, we have some days to play the move. + :return: The game category. + """ + game_duration = base_time + increment * 40 + if variant != "standard": + return variant + elif days: + return "correspondence" + elif game_duration < 179: + return "bullet" + elif game_duration < 479: + return "blitz" + elif game_duration < 1499: + return "rapid" + else: + return "classical" diff --git a/model.py b/lib/model.py similarity index 99% rename from model.py rename to lib/model.py index 5bd92600d..4369f3e14 100644 --- a/model.py +++ b/lib/model.py @@ -4,8 +4,8 @@ import logging import datetime from enum import Enum -from timer import Timer, msec, seconds, sec_str, to_msec, to_seconds, years -from config import Configuration +from lib.timer import Timer, msec, seconds, sec_str, to_msec, to_seconds, years +from lib.config import Configuration from typing import Any from collections import defaultdict diff --git a/strategies.py b/lib/strategies.py similarity index 98% rename from strategies.py rename to lib/strategies.py index 4476c7131..5ad9721cb 100644 --- a/strategies.py +++ b/lib/strategies.py @@ -8,7 +8,7 @@ import chess from chess.engine import PlayResult, Limit import random -from engine_wrapper import MinimalEngine, MOVE +from lib.engine_wrapper import MinimalEngine, MOVE from typing import Any import logging diff --git a/timer.py b/lib/timer.py similarity index 100% rename from timer.py rename to lib/timer.py diff --git a/versioning.yml b/lib/versioning.yml similarity index 100% rename from versioning.yml rename to lib/versioning.yml diff --git a/lichess-bot.py b/lichess-bot.py index e0072c8f7..2ce1b7de4 100644 --- a/lichess-bot.py +++ b/lichess-bot.py @@ -3,14 +3,11 @@ import chess import chess.pgn from chess.variant import find_variant -import engine_wrapper -import model +from lib import engine_wrapper, model, lichess, matchmaking import json -import lichess import logging import logging.handlers import multiprocessing -import matchmaking import signal import time import datetime @@ -22,9 +19,9 @@ import sys import yaml import traceback -from config import load_config, Configuration -from conversation import Conversation, ChatLine -from timer import Timer, seconds, msec, hours, to_seconds +from lib.config import load_config, Configuration +from lib.conversation import Conversation, ChatLine +from lib.timer import Timer, seconds, msec, hours, to_seconds from requests.exceptions import ChunkedEncodingError, ConnectionError, HTTPError, ReadTimeout from asyncio.exceptions import TimeoutError as MoveTimeout from rich.logging import RichHandler @@ -47,7 +44,7 @@ logger = logging.getLogger(__name__) -with open("versioning.yml") as version_file: +with open("lib/versioning.yml") as version_file: versioning_info = yaml.safe_load(version_file) __version__ = versioning_info["lichess_bot_version"] diff --git a/test_bot/conftest.py b/test_bot/conftest.py index c76a2c8d9..5317cdc65 100644 --- a/test_bot/conftest.py +++ b/test_bot/conftest.py @@ -6,8 +6,8 @@ def pytest_sessionfinish(session: Any, exitstatus: Any) -> None: """Remove files created when testing lichess-bot.""" - shutil.copyfile("correct_lichess.py", "lichess.py") - os.remove("correct_lichess.py") + shutil.copyfile("lib/correct_lichess.py", "lib/lichess.py") + os.remove("lib/correct_lichess.py") if os.path.exists("TEMP") and not os.getenv("GITHUB_ACTIONS"): shutil.rmtree("TEMP") if os.path.exists("logs"): diff --git a/test_bot/lichess.py b/test_bot/lichess.py index faca3b744..de6dca52b 100644 --- a/test_bot/lichess.py +++ b/test_bot/lichess.py @@ -5,7 +5,7 @@ import json import logging import traceback -from timer import seconds, to_msec +from lib.timer import seconds, to_msec from typing import Union, Any, Optional, Generator logger = logging.getLogger(__name__) diff --git a/test-requirements.txt b/test_bot/test-requirements.txt similarity index 100% rename from test-requirements.txt rename to test_bot/test-requirements.txt diff --git a/test_bot/test_bot.py b/test_bot/test_bot.py index 03dcf2910..6a7a2d324 100644 --- a/test_bot/test_bot.py +++ b/test_bot/test_bot.py @@ -12,14 +12,14 @@ import stat import shutil import importlib -import config +from lib import config import tarfile -from timer import Timer, to_seconds, seconds +from lib.timer import Timer, to_seconds, seconds from typing import Any if __name__ == "__main__": sys.exit(f"The script {os.path.basename(__file__)} should only be run by pytest.") -shutil.copyfile("lichess.py", "correct_lichess.py") -shutil.copyfile("test_bot/lichess.py", "lichess.py") +shutil.copyfile("lib/lichess.py", "lib/correct_lichess.py") +shutil.copyfile("test_bot/lichess.py", "lib/lichess.py") lichess_bot = importlib.import_module("lichess-bot") platform = sys.platform @@ -285,10 +285,11 @@ def test_homemade() -> None: if platform != "linux" and platform != "win32": assert True return - with open("strategies.py") as file: + strategies_py = "lib/strategies.py" + with open(strategies_py) as file: original_strategies = file.read() - with open("strategies.py", "a") as file: + with open(strategies_py, "a") as file: file.write(f""" class Stockfish(ExampleEngine): def __init__(self, commands, options, stderr, draw_or_resign, **popen_args): @@ -310,7 +311,7 @@ def search(self, board, time_limit, *args): CONFIG["pgn_directory"] = "TEMP/homemade_game_record" win = run_bot(CONFIG, logging_level) shutil.rmtree("logs") - with open("strategies.py", "w") as file: + with open(strategies_py, "w") as file: file.write(original_strategies) lichess_bot.logger.info("Finished Testing Homemade") assert win == "1"