From d7be2867fba30d26dd2d4289136966a035d49d46 Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Tue, 21 Feb 2023 13:06:30 -0600 Subject: [PATCH 01/25] Add flake8 config --- setup.cfg | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/setup.cfg b/setup.cfg index 4ff9b06..2020d46 100644 --- a/setup.cfg +++ b/setup.cfg @@ -49,3 +49,8 @@ where = src [options.entry_points] console_scripts = cli-chess = cli_chess.__main__:run + +[flake8] +max-line-length = 150 +per-file-ignores = + */__init__.py: F401 From 671709089826c3c527892ee4729ae340558e2051 Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Tue, 21 Feb 2023 13:09:43 -0600 Subject: [PATCH 02/25] Cleanup --- src/cli_chess/core/api/game_state_dispatcher.py | 6 +++--- .../core/game/offline_game/offline_game_model.py | 2 +- .../game/offline_game/offline_game_presenter.py | 2 +- .../core/game/online_game/online_game_view.py | 6 ------ .../game/online_game/watch_tv/watch_tv_model.py | 2 +- src/cli_chess/core/main/main_view.py | 2 +- src/cli_chess/menus/main_menu/main_menu_view.py | 2 +- src/cli_chess/menus/menu_view.py | 2 +- .../offline_games_menu_view.py | 2 +- .../online_games_menu/online_games_menu_model.py | 3 ++- .../online_games_menu/online_games_menu_view.py | 2 +- .../menus/settings_menu/settings_menu_model.py | 2 +- .../vs_computer_menu/vs_computer_menu_model.py | 16 ++++++++-------- src/cli_chess/modules/board/board_model.py | 2 +- src/cli_chess/modules/engine/engine_model.py | 2 +- .../modules/token_manager/token_manager_view.py | 2 +- .../tests/modules/board/test_board_model.py | 1 - src/cli_chess/tests/modules/test_common.py | 2 +- src/cli_chess/utils/config.py | 2 +- 19 files changed, 27 insertions(+), 33 deletions(-) diff --git a/src/cli_chess/core/api/game_state_dispatcher.py b/src/cli_chess/core/api/game_state_dispatcher.py index 3185b10..3c9ea38 100644 --- a/src/cli_chess/core/api/game_state_dispatcher.py +++ b/src/cli_chess/core/api/game_state_dispatcher.py @@ -76,7 +76,7 @@ def make_move(self, move: str): def send_takeback_request(self) -> None: """Sends a takeback request to our opponent""" try: - log.debug(f"GameStateDispatcher: Sending takeback offer to opponent") + log.debug("GameStateDispatcher: Sending takeback offer to opponent") self.api_client.board.offer_takeback(self.game_id) except Exception: raise @@ -84,7 +84,7 @@ def send_takeback_request(self) -> None: def send_draw_offer(self) -> None: """Sends a draw offer to our opponent""" try: - log.debug(f"GameStateDispatcher: Sending draw offer to opponent") + log.debug("GameStateDispatcher: Sending draw offer to opponent") self.api_client.board.offer_draw(self.game_id) except Exception: raise @@ -92,7 +92,7 @@ def send_draw_offer(self) -> None: def resign(self) -> None: """Resigns the game""" try: - log.debug(f"GameStateDispatcher: Sending resignation") + log.debug("GameStateDispatcher: Sending resignation") self.api_client.board.resign_game(self.game_id) except Exception: raise diff --git a/src/cli_chess/core/game/offline_game/offline_game_model.py b/src/cli_chess/core/game/offline_game/offline_game_model.py index be004e8..306262b 100644 --- a/src/cli_chess/core/game/offline_game/offline_game_model.py +++ b/src/cli_chess/core/game/offline_game/offline_game_model.py @@ -95,7 +95,7 @@ def _save_game_metadata(self, **kwargs) -> None: # Engine information engine_name = "Fairy-Stockfish" # TODO: Implement a better solution for when other engines are supported - engine_name = engine_name + f" Lvl {data.get(GameOption.COMPUTER_SKILL_LEVEL)}" if not data.get(GameOption.SPECIFY_ELO) else engine_name + engine_name = engine_name + f" Lvl {data.get(GameOption.COMPUTER_SKILL_LEVEL)}" if not data.get(GameOption.SPECIFY_ELO) else engine_name # noqa: E501 self.game_metadata['players'][COLOR_NAMES[not self.my_color]]['title'] = "" self.game_metadata['players'][COLOR_NAMES[not self.my_color]]['name'] = engine_name self.game_metadata['players'][COLOR_NAMES[not self.my_color]]['rating'] = data.get(GameOption.COMPUTER_ELO, "") diff --git a/src/cli_chess/core/game/offline_game/offline_game_presenter.py b/src/cli_chess/core/game/offline_game/offline_game_presenter.py index fd95376..4017dac 100644 --- a/src/cli_chess/core/game/offline_game/offline_game_presenter.py +++ b/src/cli_chess/core/game/offline_game/offline_game_presenter.py @@ -65,4 +65,4 @@ async def make_engine_move(self) -> None: self.board_presenter.make_move(engine_move.move.uci(), human=False) except Exception as e: log.critical(f"Received an invalid move from the engine: {e}") - self.view.show_error(f"Invalid move received from engine - inspect logs") + self.view.show_error("Invalid move received from engine - inspect logs") diff --git a/src/cli_chess/core/game/online_game/online_game_view.py b/src/cli_chess/core/game/online_game/online_game_view.py index 1483178..4614fd3 100644 --- a/src/cli_chess/core/game/online_game/online_game_view.py +++ b/src/cli_chess/core/game/online_game/online_game_view.py @@ -15,12 +15,6 @@ from __future__ import annotations from cli_chess.core.game import PlayableGameViewBase -from cli_chess.utils.ui_common import handle_mouse_click -from prompt_toolkit.layout import Window, FormattedTextControl, VSplit, D -from prompt_toolkit.formatted_text import StyleAndTextTuples -from prompt_toolkit.key_binding import KeyBindings -from prompt_toolkit.keys import Keys -from prompt_toolkit.filters import Condition from typing import TYPE_CHECKING if TYPE_CHECKING: from cli_chess.core.game.online_game import OnlineGamePresenter diff --git a/src/cli_chess/core/game/online_game/watch_tv/watch_tv_model.py b/src/cli_chess/core/game/online_game/watch_tv/watch_tv_model.py index c0dd320..6a1eaa9 100644 --- a/src/cli_chess/core/game/online_game/watch_tv/watch_tv_model.py +++ b/src/cli_chess/core/game/online_game/watch_tv/watch_tv_model.py @@ -84,7 +84,7 @@ def _save_game_metadata(self, **kwargs) -> None: if data['players'][color].get('user'): self.game_metadata['players'][color] = data['players'][color]['user'] self.game_metadata['players'][color]['rating'] = data['players'][color]['rating'] - self.game_metadata['players'][color]['ratingDiff'] = data.get('players', {}).get(color, {}).get('ratingDiff', "") # NOTE: Not included on aborted games + self.game_metadata['players'][color]['ratingDiff'] = data.get('players', {}).get(color, {}).get('ratingDiff', "") # NOTE: Not included on aborted games # noqa elif data['players'][color].get('aiLevel'): self.game_metadata['players'][color]['name'] = f"Stockfish level {data['players'][color]['aiLevel']}" diff --git a/src/cli_chess/core/main/main_view.py b/src/cli_chess/core/main/main_view.py index 1268bc2..12cbb37 100644 --- a/src/cli_chess/core/main/main_view.py +++ b/src/cli_chess/core/main/main_view.py @@ -74,7 +74,7 @@ def _get_function_bar_fragments() -> StyleAndTextTuples: Window(FormattedTextControl(f"cli-chess {__version__}"), align=WindowAlign.RIGHT) ], height=D(max=1, preferred=1)) - def _create_function_bar_key_bindings(self) -> "_MergedKeyBindings": + def _create_function_bar_key_bindings(self) -> "_MergedKeyBindings": # noqa: F821 """Creates the key bindings for the function bar""" main_menu_key_bindings = self.presenter.main_menu_presenter.view.get_function_bar_key_bindings() diff --git a/src/cli_chess/menus/main_menu/main_menu_view.py b/src/cli_chess/menus/main_menu/main_menu_view.py index 625d987..44cf507 100644 --- a/src/cli_chess/menus/main_menu/main_menu_view.py +++ b/src/cli_chess/menus/main_menu/main_menu_view.py @@ -100,7 +100,7 @@ def get_function_bar_fragments(self) -> StyleAndTextTuples: fragments = self.presenter.settings_menu_presenter.view.get_function_bar_fragments() return fragments - def get_function_bar_key_bindings(self) -> "_MergedKeyBindings": + def get_function_bar_key_bindings(self) -> "_MergedKeyBindings": # noqa: F821 """Returns the appropriate function bar key bindings based on menu item selection""" online_games_kb = ConditionalKeyBindings( self.presenter.online_games_menu_presenter.view.get_function_bar_key_bindings(), diff --git a/src/cli_chess/menus/menu_view.py b/src/cli_chess/menus/menu_view.py index c039fee..27af327 100644 --- a/src/cli_chess/menus/menu_view.py +++ b/src/cli_chess/menus/menu_view.py @@ -152,7 +152,7 @@ def value_click(): sel_class = ",focused-selected" tokens.append(("class:menu.option" + sel_class, f"{menu_option.option_name:<{self.column_width}}", label_click)) - tokens.append(("class:menu.multi-value" + sel_class, f"{menu_option.values[menu_option.selected_value['index']]:<{self.column_width}}", value_click)) + tokens.append(("class:menu.multi-value" + sel_class, f"{menu_option.values[menu_option.selected_value['index']]:<{self.column_width}}", value_click)) # noqa: E501 tokens.append(("class:menu", "\n")) for i, opt in enumerate(self.presenter.get_visible_menu_options()): diff --git a/src/cli_chess/menus/offline_games_menu/offline_games_menu_view.py b/src/cli_chess/menus/offline_games_menu/offline_games_menu_view.py index 52819f2..1a245b3 100644 --- a/src/cli_chess/menus/offline_games_menu/offline_games_menu_view.py +++ b/src/cli_chess/menus/offline_games_menu/offline_games_menu_view.py @@ -16,7 +16,7 @@ from __future__ import annotations from cli_chess.menus import MenuView from cli_chess.menus.offline_games_menu import OfflineGamesMenuOptions -from prompt_toolkit.layout import Container, Window, FormattedTextControl, ConditionalContainer, VSplit, HSplit +from prompt_toolkit.layout import Container, ConditionalContainer, VSplit, HSplit from prompt_toolkit.filters import Condition, is_done from prompt_toolkit.widgets import Box from prompt_toolkit.formatted_text import StyleAndTextTuples diff --git a/src/cli_chess/menus/online_games_menu/online_games_menu_model.py b/src/cli_chess/menus/online_games_menu/online_games_menu_model.py index 302d554..42248b4 100644 --- a/src/cli_chess/menus/online_games_menu/online_games_menu_model.py +++ b/src/cli_chess/menus/online_games_menu/online_games_menu_model.py @@ -28,7 +28,8 @@ def __init__(self): self.menu = self._create_menu() super().__init__(self.menu) - def _create_menu(self) -> MenuCategory: + @staticmethod + def _create_menu() -> MenuCategory: """Create the menu options""" menu_options = [ MenuOption(OnlineGamesMenuOptions.CREATE_GAME, "Create an online game against a random opponent"), diff --git a/src/cli_chess/menus/online_games_menu/online_games_menu_view.py b/src/cli_chess/menus/online_games_menu/online_games_menu_view.py index 4891ce2..e0f1bbf 100644 --- a/src/cli_chess/menus/online_games_menu/online_games_menu_view.py +++ b/src/cli_chess/menus/online_games_menu/online_games_menu_view.py @@ -59,7 +59,7 @@ def get_function_bar_fragments(self) -> StyleAndTextTuples: fragments = self.presenter.tv_channel_menu_presenter.view.get_function_bar_fragments() return fragments - def get_function_bar_key_bindings(self) -> "_MergedKeyBindings": + def get_function_bar_key_bindings(self) -> "_MergedKeyBindings": # noqa: F821 """Returns the appropriate function bar key bindings based on menu item selection""" vs_ai_kb = ConditionalKeyBindings( self.presenter.vs_computer_menu_presenter.view.get_function_bar_key_bindings(), diff --git a/src/cli_chess/menus/settings_menu/settings_menu_model.py b/src/cli_chess/menus/settings_menu/settings_menu_model.py index 0bb9087..4cf1d9a 100644 --- a/src/cli_chess/menus/settings_menu/settings_menu_model.py +++ b/src/cli_chess/menus/settings_menu/settings_menu_model.py @@ -32,7 +32,7 @@ def __init__(self): def _create_menu() -> MenuCategory: """Create the menu options""" menu_options = [ - MenuOption(SettingsMenuOptions.LICHESS_AUTHENTICATION, "Authenticate with Lichess by adding your API access token (required for playing online)"), + MenuOption(SettingsMenuOptions.LICHESS_AUTHENTICATION, "Authenticate with Lichess by adding your API access token (required for playing online)"), # noqa: E501 MenuOption(SettingsMenuOptions.GAME_SETTINGS, "Customize the look and feel when playing games"), MenuOption(SettingsMenuOptions.PROGRAM_SETTINGS, "Customize the look and feel of cli-chess"), ] diff --git a/src/cli_chess/menus/vs_computer_menu/vs_computer_menu_model.py b/src/cli_chess/menus/vs_computer_menu/vs_computer_menu_model.py index 3f97165..ceef6e4 100644 --- a/src/cli_chess/menus/vs_computer_menu/vs_computer_menu_model.py +++ b/src/cli_chess/menus/vs_computer_menu/vs_computer_menu_model.py @@ -32,10 +32,10 @@ def __init__(self): def _create_menu() -> MenuCategory: """Create the online menu options""" menu_options = [ - MultiValueMenuOption(GameOption.VARIANT, "Choose the variant to play", [option for option in OnlineGameOptions.variant_options_dict]), - MultiValueMenuOption(GameOption.TIME_CONTROL, "Choose the time control", [option for option in OnlineGameOptions.time_control_options_dict]), - MultiValueMenuOption(GameOption.COMPUTER_SKILL_LEVEL, "Choose the skill level of the computer", [option for option in OnlineGameOptions.skill_level_options_dict]), - MultiValueMenuOption(GameOption.COLOR, "Choose the side you would like to play as", [option for option in OnlineGameOptions.color_options]), + MultiValueMenuOption(GameOption.VARIANT, "Choose the variant to play", [option for option in OnlineGameOptions.variant_options_dict]), # noqa: E501 + MultiValueMenuOption(GameOption.TIME_CONTROL, "Choose the time control", [option for option in OnlineGameOptions.time_control_options_dict]), # noqa: E501 + MultiValueMenuOption(GameOption.COMPUTER_SKILL_LEVEL, "Choose the skill level of the computer", [option for option in OnlineGameOptions.skill_level_options_dict]), # noqa: E501 + MultiValueMenuOption(GameOption.COLOR, "Choose the side you would like to play as", [option for option in OnlineGameOptions.color_options]), # noqa: E501 ] return MenuCategory("Play Online vs Computer", menu_options) @@ -50,12 +50,12 @@ def _create_menu() -> MenuCategory: """Create the offline menu options""" menu_options = [ # Todo: Implement ability to use custom or lichess fairy-stockfish defined strength levels - MultiValueMenuOption(GameOption.VARIANT, "Choose the variant to play", [option for option in OfflineGameOptions.variant_options_dict]), - MultiValueMenuOption(GameOption.TIME_CONTROL, "Choose the time control", [option for option in OfflineGameOptions.time_control_options_dict]), + MultiValueMenuOption(GameOption.VARIANT, "Choose the variant to play", [option for option in OfflineGameOptions.variant_options_dict]), # noqa: E501 + MultiValueMenuOption(GameOption.TIME_CONTROL, "Choose the time control", [option for option in OfflineGameOptions.time_control_options_dict]), # noqa: E501 MultiValueMenuOption(GameOption.SPECIFY_ELO, "Would you like the computer to play as a specific Elo?", ["No", "Yes"]), - MultiValueMenuOption(GameOption.COMPUTER_SKILL_LEVEL, "Choose the skill level of the computer", [option for option in OfflineGameOptions.skill_level_options_dict]), + MultiValueMenuOption(GameOption.COMPUTER_SKILL_LEVEL, "Choose the skill level of the computer", [option for option in OfflineGameOptions.skill_level_options_dict]), # noqa: E501 MultiValueMenuOption(GameOption.COMPUTER_ELO, "Choose the Elo of the computer", list(range(500, 2850)), visible=False), - MultiValueMenuOption(GameOption.COLOR, "Choose the side you would like to play as", [option for option in OfflineGameOptions.color_options]), + MultiValueMenuOption(GameOption.COLOR, "Choose the side you would like to play as", [option for option in OfflineGameOptions.color_options]), # noqa: E501 ] return MenuCategory("Play Offline vs Computer", menu_options) diff --git a/src/cli_chess/modules/board/board_model.py b/src/cli_chess/modules/board/board_model.py index 95cb254..5f72648 100644 --- a/src/cli_chess/modules/board/board_model.py +++ b/src/cli_chess/modules/board/board_model.py @@ -273,7 +273,7 @@ def is_light_square(square: chess.Square) -> bool: if square in chess.SQUARES: return chess.BB_LIGHT_SQUARES & chess.BB_SQUARES[square] else: - raise(ValueError(f"Illegal square: {square}")) + raise ValueError(f"Illegal square: {square}") def is_white_orientation(self) -> bool: """Returns True if the board orientation is set as white""" diff --git a/src/cli_chess/modules/engine/engine_model.py b/src/cli_chess/modules/engine/engine_model.py index 7cea861..af75ed4 100644 --- a/src/cli_chess/modules/engine/engine_model.py +++ b/src/cli_chess/modules/engine/engine_model.py @@ -40,7 +40,7 @@ async def create_engine_model(board_model: BoardModel, game_parameters: dict): """Create an instance of the engine model with the engine loaded""" model = EngineModel(board_model, game_parameters) - await model._init() + await model._init() # noqa return model diff --git a/src/cli_chess/modules/token_manager/token_manager_view.py b/src/cli_chess/modules/token_manager/token_manager_view.py index ab870b3..63a2b4f 100644 --- a/src/cli_chess/modules/token_manager/token_manager_view.py +++ b/src/cli_chess/modules/token_manager/token_manager_view.py @@ -72,7 +72,7 @@ def _create_container(self) -> HSplit: VSplit([ Label("Linked account: ", dont_extend_width=True), ConditionalContainer(Label("None", style="class:label.error bold italic"), Condition(lambda: not self.lichess_username)), - ConditionalContainer(Label(text=lambda: self.lichess_username, style="class:label.success bold"), Condition(lambda: self.lichess_username != "")), + ConditionalContainer(Label(text=lambda: self.lichess_username, style="class:label.success bold"), Condition(lambda: self.lichess_username != "")), # noqa: E501 ], height=D(max=1)), ], width=D(max=self.container_width), height=D(preferred=8)) diff --git a/src/cli_chess/tests/modules/board/test_board_model.py b/src/cli_chess/tests/modules/board/test_board_model.py index 0c2682d..086d159 100644 --- a/src/cli_chess/tests/modules/board/test_board_model.py +++ b/src/cli_chess/tests/modules/board/test_board_model.py @@ -15,7 +15,6 @@ from cli_chess.modules.board import BoardModel import chess -from chess import variant from unittest.mock import Mock import pytest diff --git a/src/cli_chess/tests/modules/test_common.py b/src/cli_chess/tests/modules/test_common.py index 4607fc1..a9a4a54 100644 --- a/src/cli_chess/tests/modules/test_common.py +++ b/src/cli_chess/tests/modules/test_common.py @@ -13,7 +13,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from cli_chess.modules.common import * +from cli_chess.modules.common import get_piece_unicode_symbol, UNICODE_PIECE_SYMBOLS from string import ascii_lowercase diff --git a/src/cli_chess/utils/config.py b/src/cli_chess/utils/config.py index 7731c49..93273d2 100644 --- a/src/cli_chess/utils/config.py +++ b/src/cli_chess/utils/config.py @@ -78,7 +78,7 @@ def __init__(self, filename: str = DEFAULT_CONFIG_FILENAME) -> None: # Event called on any configuration write event (across sections) self.e_config_updated = Event() - def _get_parser(self) -> "ConfigParser": + def _get_parser(self) -> "ConfigParser": # noqa: F821 """Returns the config parser object""" parser = configparser.ConfigParser() parser.read(self.full_filename) From c7ae9da9dbf93bc458150ee32bef790eec861f03 Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Tue, 21 Feb 2023 15:35:32 -0600 Subject: [PATCH 03/25] Add ci.yml --- .github/workflows/ci.yml | 66 ++++++++++++++++++++++++++++++++++++++ .github/workflows/test.yml | 41 ----------------------- 2 files changed, 66 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3b509fe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI + +on: + push: + branches: + - master + - development + pull_request: + branches: + - master + - development + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: '3.10' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[dev] + + - name: Run flake8 + run: | + echo "$PWD" + flake8 . --config=setup.cfg --count --show-source --statistics + + test: + name: Test + needs: lint + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.7", "3.8", "3.9", "3.10"] # TODO: Add 3.11 after `wrapt` update: https://github.com/GrahamDumpleton/wrapt/issues/226 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e .[dev] + + - name: Run pytest + run: | + pytest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index e6fc755..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Test - -on: - push: - branches: - - development - pull_request: - branches: - - master - -jobs: - test: - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.7", "3.8", "3.9", "3.10"] # TODO: Add 3.11 after `wrapt` update: https://github.com/GrahamDumpleton/wrapt/issues/226 - - steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e .[dev] - - - name: Lint with flake8 - run: | - # TODO: Add flake8 linting tests - # stop the build if there are Python syntax errors or undefined names - #flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - #flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - - name: Run unit tests - run: | - pytest From 5dfe4b47015e5a22524d710810ea06f36bd2e9cc Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Wed, 22 Feb 2023 08:58:42 -0600 Subject: [PATCH 04/25] Update .gitignore --- .gitignore | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 70b7651..cd419d6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,9 @@ config.ini -*.swp -*.~ -*.spec +.coverage *.pyc *.egg-info +*.spec *.eggs/ -.coverage -.venv/ -.vscode/ -.idea/ -venv/ dist/ build/ -engines/ __pycache__/ From e94dac91727fe63a534308bddc332f3a00a371c1 Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Wed, 22 Feb 2023 09:35:14 -0600 Subject: [PATCH 05/25] Update README --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index d6d129f..cf083d6 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,15 @@ A highly customizable way to play chess in your terminal. Supports online play ( offline play against the Fairy-Stockfish engine. All Lichess variants are supported.

+

+ + CI Workflow + + + PyPI + +

+ ## Main Features - Play online using your Lichess.org account - Play offline against the Fairy-Stockfish engine From 9a96f3caec64ca8d471479894f16b06e2dbe8d7d Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Thu, 23 Feb 2023 08:28:29 -0600 Subject: [PATCH 06/25] Update option order --- src/cli_chess/menus/main_menu/main_menu_model.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli_chess/menus/main_menu/main_menu_model.py b/src/cli_chess/menus/main_menu/main_menu_model.py index 1876db6..9df7fb7 100644 --- a/src/cli_chess/menus/main_menu/main_menu_model.py +++ b/src/cli_chess/menus/main_menu/main_menu_model.py @@ -18,8 +18,8 @@ class MainMenuOptions(Enum): - ONLINE_GAMES = "Online Games" OFFLINE_GAMES = "Offline Games" + ONLINE_GAMES = "Online Games" SETTINGS = "Settings" ABOUT = "About" QUIT = "Quit" @@ -34,8 +34,8 @@ def __init__(self): def _create_menu() -> MenuCategory: """Create the menu category with options""" menu_options = [ - MenuOption(MainMenuOptions.ONLINE_GAMES, "Play games online using Lichess.org"), MenuOption(MainMenuOptions.OFFLINE_GAMES, "Play games offline"), + MenuOption(MainMenuOptions.ONLINE_GAMES, "Play games online using Lichess.org"), MenuOption(MainMenuOptions.SETTINGS, "Modify cli-chess settings"), MenuOption(MainMenuOptions.ABOUT, ""), ] From 1dc2bda80bebb90b653778560c6bd8904ca10af2 Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Thu, 23 Feb 2023 08:42:03 -0600 Subject: [PATCH 07/25] Set ELO step to 25 --- src/cli_chess/menus/vs_computer_menu/vs_computer_menu_model.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/cli_chess/menus/vs_computer_menu/vs_computer_menu_model.py b/src/cli_chess/menus/vs_computer_menu/vs_computer_menu_model.py index ceef6e4..a665b60 100644 --- a/src/cli_chess/menus/vs_computer_menu/vs_computer_menu_model.py +++ b/src/cli_chess/menus/vs_computer_menu/vs_computer_menu_model.py @@ -49,12 +49,11 @@ def __init__(self): def _create_menu() -> MenuCategory: """Create the offline menu options""" menu_options = [ - # Todo: Implement ability to use custom or lichess fairy-stockfish defined strength levels MultiValueMenuOption(GameOption.VARIANT, "Choose the variant to play", [option for option in OfflineGameOptions.variant_options_dict]), # noqa: E501 MultiValueMenuOption(GameOption.TIME_CONTROL, "Choose the time control", [option for option in OfflineGameOptions.time_control_options_dict]), # noqa: E501 MultiValueMenuOption(GameOption.SPECIFY_ELO, "Would you like the computer to play as a specific Elo?", ["No", "Yes"]), MultiValueMenuOption(GameOption.COMPUTER_SKILL_LEVEL, "Choose the skill level of the computer", [option for option in OfflineGameOptions.skill_level_options_dict]), # noqa: E501 - MultiValueMenuOption(GameOption.COMPUTER_ELO, "Choose the Elo of the computer", list(range(500, 2850)), visible=False), + MultiValueMenuOption(GameOption.COMPUTER_ELO, "Choose the Elo of the computer", list(range(500, 2850, 25)), visible=False), MultiValueMenuOption(GameOption.COLOR, "Choose the side you would like to play as", [option for option in OfflineGameOptions.color_options]), # noqa: E501 ] return MenuCategory("Play Offline vs Computer", menu_options) From ae92b88a484ca526f64da6c065f5ea86d4387e46 Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Fri, 24 Feb 2023 15:11:55 -0600 Subject: [PATCH 08/25] Remove comment --- src/cli_chess/core/game/online_game/watch_tv/watch_tv_model.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/cli_chess/core/game/online_game/watch_tv/watch_tv_model.py b/src/cli_chess/core/game/online_game/watch_tv/watch_tv_model.py index 6a1eaa9..777ecf2 100644 --- a/src/cli_chess/core/game/online_game/watch_tv/watch_tv_model.py +++ b/src/cli_chess/core/game/online_game/watch_tv/watch_tv_model.py @@ -98,8 +98,6 @@ def stream_event_received(self, **kwargs): try: # TODO: Data needs to be organized and sent to presenter to handle display if 'startGameEvent' in kwargs: - # NOTE: If the variant is 3check the initial export fen will include the check counts - # but follow up game stream FENs will not. Lila GH issue #: 12357 event = kwargs['startGameEvent'] variant = event['variant']['key'] white_rating = int(event['players']['white'].get('rating') or 0) From 2b23eb6c2c4bb8723d146111cf4498bf81a41fbc Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Fri, 24 Feb 2023 15:52:29 -0600 Subject: [PATCH 09/25] Adjust move list view --- src/cli_chess/modules/move_list/move_list_view.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/cli_chess/modules/move_list/move_list_view.py b/src/cli_chess/modules/move_list/move_list_view.py index 6ed70d6..dee71dd 100644 --- a/src/cli_chess/modules/move_list/move_list_view.py +++ b/src/cli_chess/modules/move_list/move_list_view.py @@ -37,8 +37,7 @@ def __init__(self, presenter: MoveListPresenter): def _create_container(self) -> Box: """Create the move list container""" return Box(self._move_list_output, - width=D(min=1), - height=D(min=1, max=4, preferred=4), + height=D(max=4), padding=0) def update(self, formatted_move_list: List[str]): From 10c5601db1bc1f1ab5da5b29cdf2bd3b7b6ad4cb Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Fri, 24 Feb 2023 17:24:40 -0600 Subject: [PATCH 10/25] Clear event listeners on game completion --- src/cli_chess/core/api/game_state_dispatcher.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/cli_chess/core/api/game_state_dispatcher.py b/src/cli_chess/core/api/game_state_dispatcher.py index 3c9ea38..ff6b27b 100644 --- a/src/cli_chess/core/api/game_state_dispatcher.py +++ b/src/cli_chess/core/api/game_state_dispatcher.py @@ -47,10 +47,12 @@ def run(self): self.e_game_state_dispatcher_event.notify(gameFull=event) elif event['type'] == "gameState": + status = event.get('status', None) + is_game_over = status != None and status != "started" and status != "created" + self.e_game_state_dispatcher_event.notify(gameState=event) - is_game_over = event.get('winner') if is_game_over: - break + self._game_ended() elif event['type'] == "chatLine": self.e_game_state_dispatcher_event.notify(chatLine=event) @@ -97,6 +99,6 @@ def resign(self) -> None: except Exception: raise - def clear_listeners(self) -> None: - """Remove all event listeners""" + def _game_ended(self) -> None: + """Handles removing all event listeners since the game has completed""" self.e_game_state_dispatcher_event.listeners.clear() From 86b6c389e08a876795a6e5d2d7ce01aaff8b57a7 Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Fri, 24 Feb 2023 17:25:44 -0600 Subject: [PATCH 11/25] Update game metadata status --- src/cli_chess/core/game/game_model_base.py | 6 ++++-- .../core/game/online_game/online_game_model.py | 11 ++++------- .../core/game/online_game/watch_tv/watch_tv_model.py | 4 ++-- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/cli_chess/core/game/game_model_base.py b/src/cli_chess/core/game/game_model_base.py index 950b4e8..2f80c56 100644 --- a/src/cli_chess/core/game/game_model_base.py +++ b/src/cli_chess/core/game/game_model_base.py @@ -51,8 +51,6 @@ def _default_game_metadata() -> dict: return { 'gameId': "", 'variant': "", - 'winner': "", - 'status': "", 'players': { 'white': { 'title': "", @@ -79,6 +77,10 @@ def _default_game_metadata() -> dict: 'increment': 0 }, }, + 'state': { + 'status': "", + 'winner': "" + } } diff --git a/src/cli_chess/core/game/online_game/online_game_model.py b/src/cli_chess/core/game/online_game/online_game_model.py index 86a4480..34dcb4f 100644 --- a/src/cli_chess/core/game/online_game/online_game_model.py +++ b/src/cli_chess/core/game/online_game/online_game_model.py @@ -69,12 +69,9 @@ def _game_end(self) -> None: self.game_in_progress = False self.playing_game_id = None self._unsubscribe_from_iem_events() - self.game_state_dispatcher.clear_listeners() def handle_iem_event(self, **kwargs) -> None: - """Handle event from the incoming event manager""" - # TODO: Need to ensure IEM events we are responding to in this class are specific to this game being played. - # Eg. We don't want to end the current game in progress, because one of our other correspondence games ended. + """Handles events received from the IncomingEventManager""" if 'gameStart' in kwargs: event = kwargs['gameStart']['game'] # TODO: There has to be a better way to ensure this is the right game... @@ -86,11 +83,10 @@ def handle_iem_event(self, **kwargs) -> None: elif 'gameFinish' in kwargs: event = kwargs['gameFinish']['game'] if self.game_in_progress and self.playing_game_id == event['gameId']: - self._save_game_metadata(iem_gameFinish=event) self._game_end() def handle_game_state_dispatcher_event(self, **kwargs) -> None: - """Handle event from the game stream""" + """Handles received from the GameStateDispatcher""" if 'gameFull' in kwargs: event = kwargs['gameFull'] self._save_game_metadata(gsd_gameFull=event) @@ -230,7 +226,8 @@ def _save_game_metadata(self, **kwargs) -> None: # NOTE: Times below come from lichess in milliseconds self.game_metadata['clock']['white']['time'] = data['wtime'] self.game_metadata['clock']['black']['time'] = data['btime'] - self.game_metadata['status'] = data['status'] + self.game_metadata['state']['status'] = data.get('status') + self.game_metadata['state']['winner'] = data.get('winner', "") # Not included on draws or abort self._notify_game_model_updated() except Exception as e: diff --git a/src/cli_chess/core/game/online_game/watch_tv/watch_tv_model.py b/src/cli_chess/core/game/online_game/watch_tv/watch_tv_model.py index 777ecf2..0c92f4b 100644 --- a/src/cli_chess/core/game/online_game/watch_tv/watch_tv_model.py +++ b/src/cli_chess/core/game/online_game/watch_tv/watch_tv_model.py @@ -77,8 +77,8 @@ def _save_game_metadata(self, **kwargs) -> None: if 'tv_endGameEvent' in kwargs: data = kwargs['tv_endGameEvent'] - self.game_metadata['status'] = data['status'] - self.game_metadata['winner'] = data.get('winner') # Not included on draws + self.game_metadata['state']['status'] = data.get('status') + self.game_metadata['state']['winner'] = data.get('winner') # Not included on draws or abort for color in COLOR_NAMES: if data['players'][color].get('user'): From 271ec46b8703a288c0cc0fb1b690465cf4d08af0 Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Sun, 26 Feb 2023 12:52:24 -0600 Subject: [PATCH 12/25] Add vulture --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 20d2284..1e8f296 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,8 @@ 'dev': [ 'pytest>=7.2.1,<8.0.0', 'pytest-cov>=4.0.0,<5.0.0', - 'flake8>=5.0.4,<7.0.0' + 'flake8>=5.0.4,<7.0.0', + 'vulture>=2.7,<3.0' ] } From 211bccd0797cb6761ce0881fc05456eea325a5a3 Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Sun, 26 Feb 2023 18:18:19 -0600 Subject: [PATCH 13/25] Update game_metadata dict --- src/cli_chess/core/game/game_model_base.py | 7 +++---- .../core/game/offline_game/offline_game_model.py | 6 ++---- .../core/game/online_game/online_game_presenter.py | 3 +-- .../game/online_game/watch_tv/watch_tv_model.py | 2 +- .../modules/player_info/player_info_presenter.py | 13 +++++-------- .../modules/player_info/player_info_view.py | 2 +- 6 files changed, 13 insertions(+), 20 deletions(-) diff --git a/src/cli_chess/core/game/game_model_base.py b/src/cli_chess/core/game/game_model_base.py index 2f80c56..85b4df3 100644 --- a/src/cli_chess/core/game/game_model_base.py +++ b/src/cli_chess/core/game/game_model_base.py @@ -12,7 +12,6 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . - from cli_chess.modules.board import BoardModel from cli_chess.modules.move_list import MoveListModel from cli_chess.modules.material_difference import MaterialDifferenceModel @@ -56,14 +55,14 @@ def _default_game_metadata() -> dict: 'title': "", 'name': "", 'rating': "", - 'ratingDiff': "", + 'rating_diff': "", 'provisional': False, }, 'black': { 'title': "", 'name': "", 'rating': "", - 'ratingDiff': "", + 'rating_diff': "", 'provisional': False, }, }, @@ -79,7 +78,7 @@ def _default_game_metadata() -> dict: }, 'state': { 'status': "", - 'winner': "" + 'winner': "", } } diff --git a/src/cli_chess/core/game/offline_game/offline_game_model.py b/src/cli_chess/core/game/offline_game/offline_game_model.py index 306262b..f9cb850 100644 --- a/src/cli_chess/core/game/offline_game/offline_game_model.py +++ b/src/cli_chess/core/game/offline_game/offline_game_model.py @@ -70,9 +70,7 @@ def _default_game_metadata(self) -> dict: """Returns the default structure for game metadata""" game_metadata = super()._default_game_metadata() game_metadata.update({ - 'my_color': "", - 'rated': None, - 'speed': None, + 'my_color_str': "" }) return game_metadata @@ -81,7 +79,7 @@ def _save_game_metadata(self, **kwargs) -> None: try: if 'game_parameters' in kwargs: data = kwargs['game_parameters'] - self.game_metadata['my_color'] = COLOR_NAMES[self.my_color] + self.game_metadata['my_color_str'] = COLOR_NAMES[self.my_color] self.game_metadata['variant'] = data[GameOption.VARIANT] self.game_metadata['clock']['white']['time'] = data[GameOption.TIME_CONTROL][0] self.game_metadata['clock']['white']['increment'] = data[GameOption.TIME_CONTROL][1] diff --git a/src/cli_chess/core/game/online_game/online_game_presenter.py b/src/cli_chess/core/game/online_game/online_game_presenter.py index 5040de3..99e89e7 100644 --- a/src/cli_chess/core/game/online_game/online_game_presenter.py +++ b/src/cli_chess/core/game/online_game/online_game_presenter.py @@ -19,7 +19,7 @@ def start_online_game_vs_ai(game_parameters: dict) -> None: - """Start a game vs the lichess ai""" + """Start a game vs the lichess AI""" model = OnlineGameModel(game_parameters) presenter = OnlineGamePresenter(model) change_views(presenter.view, presenter.view.input_field_container) # noqa @@ -28,7 +28,6 @@ def start_online_game_vs_ai(game_parameters: dict) -> None: class OnlineGamePresenter(PlayableGamePresenterBase): def __init__(self, model: OnlineGameModel): - # NOTE: Model subscriptions are currently handled in parent. Override here if needed. self.model = model super().__init__(model) self.view = OnlineGameView(self) diff --git a/src/cli_chess/core/game/online_game/watch_tv/watch_tv_model.py b/src/cli_chess/core/game/online_game/watch_tv/watch_tv_model.py index 0c92f4b..956beba 100644 --- a/src/cli_chess/core/game/online_game/watch_tv/watch_tv_model.py +++ b/src/cli_chess/core/game/online_game/watch_tv/watch_tv_model.py @@ -84,7 +84,7 @@ def _save_game_metadata(self, **kwargs) -> None: if data['players'][color].get('user'): self.game_metadata['players'][color] = data['players'][color]['user'] self.game_metadata['players'][color]['rating'] = data['players'][color]['rating'] - self.game_metadata['players'][color]['ratingDiff'] = data.get('players', {}).get(color, {}).get('ratingDiff', "") # NOTE: Not included on aborted games # noqa + self.game_metadata['players'][color]['rating_diff'] = data.get('players', {}).get(color, {}).get('ratingDiff', "") # NOTE: Not included on aborted games # noqa elif data['players'][color].get('aiLevel'): self.game_metadata['players'][color]['name'] = f"Stockfish level {data['players'][color]['aiLevel']}" diff --git a/src/cli_chess/modules/player_info/player_info_presenter.py b/src/cli_chess/modules/player_info/player_info_presenter.py index 39875e6..c7ac816 100644 --- a/src/cli_chess/modules/player_info/player_info_presenter.py +++ b/src/cli_chess/modules/player_info/player_info_presenter.py @@ -32,14 +32,11 @@ def __init__(self, model: GameModelBase): self.model.e_game_model_updated.add_listener(self.update) def update(self, **kwargs) -> None: - """Updates the view based on orientation changes""" - # TODO: Only respond to updated here if required (eg. based on kwarg) - if 'board_orientation' in kwargs: - pass - - orientation = self.model.board_model.get_board_orientation() - self.view_upper.update(self.get_player_info(not orientation)) - self.view_lower.update(self.get_player_info(orientation)) + """Updates the view based on specific model updates""" + if 'board_orientation' in kwargs or 'gameOver' in kwargs: + orientation = self.model.board_model.get_board_orientation() + self.view_upper.update(self.get_player_info(not orientation)) + self.view_lower.update(self.get_player_info(orientation)) def get_player_info(self, color: Color) -> dict: return self.model.game_metadata['players'][COLOR_NAMES[color]] diff --git a/src/cli_chess/modules/player_info/player_info_view.py b/src/cli_chess/modules/player_info/player_info_view.py index 60dbaed..1cebcc5 100644 --- a/src/cli_chess/modules/player_info/player_info_view.py +++ b/src/cli_chess/modules/player_info/player_info_view.py @@ -53,7 +53,7 @@ def update(self, player_info: dict) -> None: """Updates the player info using the data passed in""" self.player_title = player_info.get('title', "") self.player_name = player_info.get('name', "Unknown") - self._format_rating_diff(player_info.get('ratingDiff', None)) + self._format_rating_diff(player_info.get('rating_diff', None)) rating = player_info.get('rating', "") self.player_rating = (f"({rating})" if not player_info.get('provisional') else f"({rating}?)") if rating else "" From b0e33de38a57af5c83acec695c8220592b2ca0ca Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Sun, 26 Feb 2023 18:18:54 -0600 Subject: [PATCH 14/25] Add event subscription methods --- src/cli_chess/core/api/game_state_dispatcher.py | 9 +++++++-- src/cli_chess/core/api/incoming_event_manger.py | 9 +++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/cli_chess/core/api/game_state_dispatcher.py b/src/cli_chess/core/api/game_state_dispatcher.py index ff6b27b..4c8c668 100644 --- a/src/cli_chess/core/api/game_state_dispatcher.py +++ b/src/cli_chess/core/api/game_state_dispatcher.py @@ -14,6 +14,7 @@ # along with this program. If not, see . from cli_chess.utils import Event, log +from typing import Callable import threading @@ -48,9 +49,9 @@ def run(self): elif event['type'] == "gameState": status = event.get('status', None) - is_game_over = status != None and status != "started" and status != "created" + is_game_over = status and status != "started" and status != "created" - self.e_game_state_dispatcher_event.notify(gameState=event) + self.e_game_state_dispatcher_event.notify(gameState=event, gameOver=is_game_over) if is_game_over: self._game_ended() @@ -102,3 +103,7 @@ def resign(self) -> None: def _game_ended(self) -> None: """Handles removing all event listeners since the game has completed""" self.e_game_state_dispatcher_event.listeners.clear() + + def subscribe_to_events(self, listener: Callable) -> None: + """Subscribes the passed in method to GSD events""" + self.e_game_state_dispatcher_event.add_listener(listener) diff --git a/src/cli_chess/core/api/incoming_event_manger.py b/src/cli_chess/core/api/incoming_event_manger.py index 82ddeed..182a6b5 100644 --- a/src/cli_chess/core/api/incoming_event_manger.py +++ b/src/cli_chess/core/api/incoming_event_manger.py @@ -15,6 +15,7 @@ from cli_chess.utils.event import Event from cli_chess.utils.logging import log +from typing import Callable import threading @@ -77,3 +78,11 @@ def run(self) -> None: def get_active_games(self) -> list: """Returns a list of games in progress for this account""" return self.my_games + + def subscribe_to_iem_events(self, listener: Callable) -> None: + """Subscribes the passed in method to IEM events""" + self.e_new_event_received.add_listener(listener) + + def unsubscribe_from_iem_events(self, listener: Callable) -> None: + """Unsubscribes the passed in method to IEM events""" + self.e_new_event_received.add_listener(listener) From 6b543856610287a6095b71f7eec32a89c1ad51ed Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Sun, 26 Feb 2023 18:20:05 -0600 Subject: [PATCH 15/25] Initial game over result logic --- .../core/game/game_presenter_base.py | 47 ++++++++++++++++- .../game/online_game/online_game_model.py | 52 +++++++++---------- 2 files changed, 69 insertions(+), 30 deletions(-) diff --git a/src/cli_chess/core/game/game_presenter_base.py b/src/cli_chess/core/game/game_presenter_base.py index 98493b5..ee86448 100644 --- a/src/cli_chess/core/game/game_presenter_base.py +++ b/src/cli_chess/core/game/game_presenter_base.py @@ -19,6 +19,8 @@ from cli_chess.modules.move_list import MoveListPresenter from cli_chess.modules.material_difference import MaterialDifferencePresenter from cli_chess.modules.player_info import PlayerInfoPresenter +from cli_chess.utils.logging import log +from chess import Color, COLOR_NAMES from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -54,14 +56,55 @@ def exit(self) -> None: class PlayableGamePresenterBase(GamePresenterBase): def __init__(self, model: PlayableGameModelBase): - # NOTE: Base Model subscriptions are currently handled in parent. - # Override the update function here if needed. self.model = model super().__init__(model) self.view = PlayableGameViewBase(self) self.model.board_model.e_successful_move_made.add_listener(self.view.clear_error) + def update(self, **kwargs) -> None: + """Overrides base and responds to specific model updates""" + if 'gameOver' in kwargs: + self._parse_and_present_game_over() + + def _parse_and_present_game_over(self): + """Handles parsing and presenting the game over status""" + if not self.is_game_in_progress(): + status = self.model.game_metadata['state']['status'] + winner = self.model.game_metadata['state']['winner'].capitalize() + + status_win_reasons = ['mate', 'resign', 'timeout', 'outoftime', 'cheat', 'variantEnd'] + if winner and status in status_win_reasons: + output = f" • {winner} is victorious" + loser = COLOR_NAMES[not Color(COLOR_NAMES.index(winner.lower()))].capitalize() + + if status == "mate": + output = "Checkmate" + output + elif status == "resign": + output = f"{loser} resigned" + output + elif status == "timeout": + output = f"{loser} left the game" + output + elif status == "outoftime": + output = f"{loser} time out" + output + elif status == "cheat": + output = "Cheat detected" + else: # TODO: Handle variantEnd (need to know variant) + log.debug(f"PlayableGamePresenterBase: Received game over with uncaught status: {status} / {winner}") + output = "Game over" + output + + else: # Handle other game end reasons + if status == "aborted": + output = "Game aborted" + elif status == "draw": + output = "Game over • Draw" + elif status == "stalemate": + output = "Game over • Stalemate" + else: + log.debug(f"PlayableGamePresenterBase: Received game over with uncaught status: {status}") + output = "Game over" + + self.view.show_error(output) + def user_input_received(self, inpt: str) -> None: """Respond to the users input. This input can either be the move input, or game actions (such as resign) diff --git a/src/cli_chess/core/game/online_game/online_game_model.py b/src/cli_chess/core/game/online_game/online_game_model.py index 34dcb4f..9d2a3da 100644 --- a/src/cli_chess/core/game/online_game/online_game_model.py +++ b/src/cli_chess/core/game/online_game/online_game_model.py @@ -17,7 +17,7 @@ from cli_chess.modules.game_options import GameOption from cli_chess.core.api import GameStateDispatcher from cli_chess.utils import log, threaded -from chess import Color, COLOR_NAMES +from chess import COLOR_NAMES from typing import Optional @@ -44,12 +44,12 @@ def __init__(self, game_parameters: dict): @threaded def start_ai_challenge(self) -> None: """Sends a request to lichess to start an AI challenge using the selected game parameters""" - # Note: Only add IEM listener right before creating event to lessen chance of grabbing another game - self._subscribe_to_iem_events() + # Note: Only subscribe to IEM events right before creating challenge to lessen chance of grabbing another game + self.api_iem.subscribe_to_iem_events(self.handle_iem_event) self.api_client.challenges.create_ai(level=self.game_metadata['ai_level'], clock_limit=self.game_metadata['clock']['white']['time'], clock_increment=self.game_metadata['clock']['white']['increment'], - color=self.game_metadata['my_color'], + color=self.game_metadata['my_color_str'], variant=self.game_metadata['variant']) def _start_game(self, game_id: str) -> None: @@ -61,14 +61,14 @@ def _start_game(self, game_id: str) -> None: self.playing_game_id = game_id self.game_state_dispatcher = GameStateDispatcher(game_id) - self.game_state_dispatcher.e_game_state_dispatcher_event.add_listener(self.handle_game_state_dispatcher_event) + self.game_state_dispatcher.subscribe_to_events(self.handle_game_state_dispatcher_event) self.game_state_dispatcher.start() def _game_end(self) -> None: """The game we are playing has ended. Handle cleaning up.""" self.game_in_progress = False self.playing_game_id = None - self._unsubscribe_from_iem_events() + self.api_iem.unsubscribe_from_iem_events(self.handle_iem_event) def handle_iem_event(self, **kwargs) -> None: """Handles events received from the IncomingEventManager""" @@ -90,7 +90,6 @@ def handle_game_state_dispatcher_event(self, **kwargs) -> None: if 'gameFull' in kwargs: event = kwargs['gameFull'] self._save_game_metadata(gsd_gameFull=event) - self.my_color = Color(COLOR_NAMES.index(self.game_metadata['my_color'])) self.board_model.reinitialize_board(variant=self.game_metadata['variant'], orientation=self.my_color, fen=event['initialFen']) @@ -106,6 +105,9 @@ def handle_game_state_dispatcher_event(self, **kwargs) -> None: self.board_model.reset(notify=False) self.board_model.make_moves_from_list(event['moves'].split()) + if kwargs['gameOver']: + self._report_game_over(status=event.get('status'), winner=event.get('winner', "")) + elif 'chatLine' in kwargs: event = kwargs['chatLine'] self._save_game_metadata(gsd_chatLine=event) @@ -177,17 +179,14 @@ def resign(self) -> None: raise Warning("Game has already ended") def _save_game_metadata(self, **kwargs) -> None: - """Parses and saves the data of the game being played. - If notify is false, a model update notification will not be sent. - Raises an exception on invalid data. - """ + """Parses and saves the data of the game being played.""" try: if 'game_parameters' in kwargs: # This is the data that came from the menu selections data = kwargs['game_parameters'] - self.game_metadata['my_color'] = data[GameOption.COLOR] + self.game_metadata['my_color_str'] = COLOR_NAMES[self.my_color] self.game_metadata['variant'] = data[GameOption.VARIANT] - self.game_metadata['rated'] = data.get(GameOption.RATED, False) # Games against ai will not have this data - self.game_metadata['ai_level'] = data.get(GameOption.COMPUTER_SKILL_LEVEL) # Only games against ai will have this data + self.game_metadata['rated'] = data.get(GameOption.RATED, False) # Games against AI will not have this data + self.game_metadata['ai_level'] = data.get(GameOption.COMPUTER_SKILL_LEVEL) # Only games against AI will have this data self.game_metadata['clock']['white']['time'] = data[GameOption.TIME_CONTROL][0] * 60 # secs self.game_metadata['clock']['white']['increment'] = data[GameOption.TIME_CONTROL][1] # secs self.game_metadata['clock']['black'] = self.game_metadata['clock']['white'] @@ -198,7 +197,7 @@ def _save_game_metadata(self, **kwargs) -> None: data = kwargs['iem_gameStart'] self.game_metadata['gameId'] = data['gameId'] - self.game_metadata['my_color'] = data['color'] + self.game_metadata['my_color_str'] = data['color'] self.game_metadata['rated'] = data['rated'] self.game_metadata['variant'] = data['variant']['name'] self.game_metadata['speed'] = data['speed'] @@ -226,31 +225,28 @@ def _save_game_metadata(self, **kwargs) -> None: # NOTE: Times below come from lichess in milliseconds self.game_metadata['clock']['white']['time'] = data['wtime'] self.game_metadata['clock']['black']['time'] = data['btime'] - self.game_metadata['state']['status'] = data.get('status') - self.game_metadata['state']['winner'] = data.get('winner', "") # Not included on draws or abort self._notify_game_model_updated() except Exception as e: log.exception(f"Error saving online game metadata: {e}") raise - def _subscribe_to_iem_events(self) -> None: - """Subscribe to IEM events by adding a handler""" - self.api_iem.e_new_event_received.add_listener(self.handle_iem_event) - - def _unsubscribe_from_iem_events(self) -> None: - """Unsubscribe from IEM events. It's important that this happens after a game - completes, or the user quits otherwise IEM subscriptions will build up. - """ - self.api_iem.e_new_event_received.remove_listener(self.handle_iem_event) - def _default_game_metadata(self) -> dict: """Returns the default structure for game metadata""" game_metadata = super()._default_game_metadata() game_metadata.update({ - 'my_color': "", + 'my_color_str': "", 'ai_level': None, 'rated': False, 'speed': None, }) return game_metadata + + def _report_game_over(self, status: str, winner: str) -> None: + """Saves game information and notifies listeners that the game has ended. + This should only ever be called if the game is confirmed to be over + """ + self._game_end() + self.game_metadata['state']['status'] = status # status list can be found in lila status.ts + self.game_metadata['state']['winner'] = winner + self._notify_game_model_updated(gameOver=True) From 3b7f18ffb61c240d4e808bb163e473b70512e6e2 Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Mon, 27 Feb 2023 08:52:47 -0600 Subject: [PATCH 16/25] Bump pt version --- setup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1e8f296..a2aa638 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,9 @@ dependencies = [ "chess>=1.9.4,<2.0.0", "berserk-downstream>=0.11.12,<1.0.0", - "prompt-toolkit>=3.0.36,<4.0.0" + "prompt-toolkit==3.0.38" # pin as breaking changes have been + # introduced in previous patch versions + # read PT changelog before bumping ] dev_dependencies = { From ec5549c546b4855368e16cb68b1d5e4ba2786b23 Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Tue, 28 Feb 2023 12:26:20 -0600 Subject: [PATCH 17/25] Thread token validation --- .../modules/token_manager/token_manager_model.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/cli_chess/modules/token_manager/token_manager_model.py b/src/cli_chess/modules/token_manager/token_manager_model.py index 6d2fcdc..ddcff39 100644 --- a/src/cli_chess/modules/token_manager/token_manager_model.py +++ b/src/cli_chess/modules/token_manager/token_manager_model.py @@ -13,11 +13,9 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from cli_chess.utils.logging import log from cli_chess.utils.config import lichess_config -from cli_chess.utils.event import Event +from cli_chess.utils import Event, log, threaded from berserk import Client, TokenSession -from berserk.exceptions import BerserkError linked_token_scopes = set() @@ -27,6 +25,7 @@ def __init__(self): self.linked_account = "" self.e_token_manager_model_updated = Event() + @threaded def validate_existing_linked_account(self) -> None: """Queries the Lichess config file for an existing token. If a token exists, verification is attempted. Invalid data will be cleared. @@ -78,8 +77,9 @@ def validate_token(api_token: str) -> dict: return token_data[api_token] else: log.error("TokenManager: Valid token but missing required scopes") - except BerserkError as e: - log.error(f"TokenManager: Authentication to Lichess failed - {e.message}") + + except Exception as e: + log.error(f"TokenManager: Authentication to Lichess failed - {e}") def save_account_data(self, api_token: str, account_data: dict, valid=False) -> None: """Saves the passed in lichess api token to the configuration. From 689a13b8fee110bae5b9bcfbd15c593fb46ef606 Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Tue, 28 Feb 2023 12:27:06 -0600 Subject: [PATCH 18/25] Set threaded wrapper as daemon --- src/cli_chess/utils/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli_chess/utils/common.py b/src/cli_chess/utils/common.py index 78a11b8..504c790 100644 --- a/src/cli_chess/utils/common.py +++ b/src/cli_chess/utils/common.py @@ -43,7 +43,7 @@ def str_to_bool(s: str) -> bool: def threaded(fn): """Decorator for a threaded function""" def wrapper(*args, **kwargs): - threading.Thread(target=fn, args=args, kwargs=kwargs).start() + threading.Thread(target=fn, args=args, kwargs=kwargs, daemon=True).start() return wrapper From 964f7f1033ef900eddcc8da68be9a3bf4b48cfbb Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Tue, 28 Feb 2023 14:28:58 -0600 Subject: [PATCH 19/25] Dynamically create API URL --- src/cli_chess/core/api/api_manager.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/cli_chess/core/api/api_manager.py b/src/cli_chess/core/api/api_manager.py index 579961e..b2fbf43 100644 --- a/src/cli_chess/core/api/api_manager.py +++ b/src/cli_chess/core/api/api_manager.py @@ -18,14 +18,7 @@ from berserk import Client, TokenSession from typing import Optional -API_TOKEN_CREATION_URL = ("https://lichess.org/account/oauth/token/create?" + - "scopes[]=challenge:read&" + - "scopes[]=challenge:write&" + - "scopes[]=board:play&" + - "description=cli-chess+token") - required_token_scopes: set = {"board:play", "challenge:read", "challenge:write"} - api_session: Optional[TokenSession] api_client: Optional[Client] api_iem: Optional[IncomingEventManager] @@ -54,3 +47,17 @@ def api_is_ready() -> bool: this is used for toggling the online menu availability """ return api_ready + + +def _create_api_token_url() -> str: + """Created the API token creation url by iterating over scopes""" + url = "https://lichess.org/account/oauth/token/create?" + + for scope in required_token_scopes: + url = url + f"scopes[]={scope}&" + + url = url + "description=cli-chess+token" + return url + + +API_TOKEN_CREATION_URL = _create_api_token_url() From 3b3c6eb593fc71e00311a7e9d299cabad71b3202 Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Wed, 1 Mar 2023 11:39:03 -0600 Subject: [PATCH 20/25] Fix unsubscribe method --- src/cli_chess/core/api/incoming_event_manger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli_chess/core/api/incoming_event_manger.py b/src/cli_chess/core/api/incoming_event_manger.py index 182a6b5..42c6446 100644 --- a/src/cli_chess/core/api/incoming_event_manger.py +++ b/src/cli_chess/core/api/incoming_event_manger.py @@ -85,4 +85,4 @@ def subscribe_to_iem_events(self, listener: Callable) -> None: def unsubscribe_from_iem_events(self, listener: Callable) -> None: """Unsubscribes the passed in method to IEM events""" - self.e_new_event_received.add_listener(listener) + self.e_new_event_received.remove_listener(listener) From 09f4d60234aff5ade229c5faf1480b7a07d9c4dd Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Wed, 1 Mar 2023 19:17:49 -0600 Subject: [PATCH 21/25] Update tests --- .../token_manager/test_token_manager_model.py | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/src/cli_chess/tests/modules/token_manager/test_token_manager_model.py b/src/cli_chess/tests/modules/token_manager/test_token_manager_model.py index 0cd80e2..f4e9f34 100644 --- a/src/cli_chess/tests/modules/token_manager/test_token_manager_model.py +++ b/src/cli_chess/tests/modules/token_manager/test_token_manager_model.py @@ -56,30 +56,6 @@ def mock_success_test_tokens(*args): # noqa } -def test_validate_existing_linked_account(model: TokenManagerModel, lichess_config: LichessConfig, model_listener: Mock, monkeypatch): - # Test with empty API token - lichess_config.set_value(lichess_config.Keys.API_TOKEN, "") - model.validate_existing_linked_account() - assert lichess_config.get_value(lichess_config.Keys.API_TOKEN) == "" - - # Test with invalid existing API token - monkeypatch.setattr(clients.OAuth, "test_tokens", mock_fail_test_tokens) - lichess_config.set_value(lichess_config.Keys.API_TOKEN, "lip_badToken") - model.validate_existing_linked_account() - assert lichess_config.get_value(lichess_config.Keys.API_TOKEN) == "" - - # Test with valid API token - monkeypatch.setattr(clients.OAuth, "test_tokens", mock_success_test_tokens) - lichess_config.set_value(lichess_config.Keys.API_TOKEN, "lip_validToken") - model.validate_existing_linked_account() - assert lichess_config.get_value(lichess_config.Keys.API_TOKEN) == "lip_validToken" - - # Verify model listener is called - model_listener.reset_mock() - model.validate_existing_linked_account() - model_listener.assert_called() - - def test_update_linked_account(model: TokenManagerModel, lichess_config: LichessConfig, model_listener: Mock, monkeypatch): # Test with empty api token assert not model.update_linked_account(api_token="") From 9c3cd6a9cb55aaef6b4908b36071233fcfdae349 Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Wed, 1 Mar 2023 19:22:34 -0600 Subject: [PATCH 22/25] Fix persistent event subscriptions --- .../core/api/game_state_dispatcher.py | 2 +- src/cli_chess/core/game/game_model_base.py | 22 ++++++++++-- .../core/game/game_presenter_base.py | 1 + .../online_game/watch_tv/watch_tv_model.py | 1 + src/cli_chess/modules/board/board_model.py | 15 +++++--- .../material_difference_model.py | 11 ++++-- .../modules/move_list/move_list_model.py | 11 ++++-- src/cli_chess/utils/__init__.py | 2 +- src/cli_chess/utils/event.py | 35 ++++++++++++++++--- 9 files changed, 84 insertions(+), 16 deletions(-) diff --git a/src/cli_chess/core/api/game_state_dispatcher.py b/src/cli_chess/core/api/game_state_dispatcher.py index 4c8c668..cc77fa1 100644 --- a/src/cli_chess/core/api/game_state_dispatcher.py +++ b/src/cli_chess/core/api/game_state_dispatcher.py @@ -102,7 +102,7 @@ def resign(self) -> None: def _game_ended(self) -> None: """Handles removing all event listeners since the game has completed""" - self.e_game_state_dispatcher_event.listeners.clear() + self.e_game_state_dispatcher_event.remove_all_listeners() def subscribe_to_events(self, listener: Callable) -> None: """Subscribes the passed in method to GSD events""" diff --git a/src/cli_chess/core/game/game_model_base.py b/src/cli_chess/core/game/game_model_base.py index 85b4df3..98cdeba 100644 --- a/src/cli_chess/core/game/game_model_base.py +++ b/src/cli_chess/core/game/game_model_base.py @@ -12,10 +12,11 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . + from cli_chess.modules.board import BoardModel from cli_chess.modules.move_list import MoveListModel from cli_chess.modules.material_difference import MaterialDifferenceModel -from cli_chess.utils.event import Event +from cli_chess.utils import EventManager, log from chess import Color, WHITE, COLOR_NAMES from random import getrandbits from abc import ABC, abstractmethod @@ -29,9 +30,13 @@ def __init__(self, orientation: Color = WHITE, variant="standard", fen=""): self.move_list_model = MoveListModel(self.board_model) self.material_diff_model = MaterialDifferenceModel(self.board_model) - self.e_game_model_updated = Event() + self._event_manager = EventManager() + self.e_game_model_updated = self._event_manager.create_event() self.board_model.e_board_model_updated.add_listener(self.update) + # Keep track of all associated models to handle bulk cleanup on exit + self._assoc_models = [self.board_model, self.move_list_model, self.material_diff_model] + def update(self, **kwargs) -> None: """Called automatically as part of an event listener. This function listens to model update events and if deemed necessary triages @@ -40,6 +45,19 @@ def update(self, **kwargs) -> None: if 'board_orientation' in kwargs: self._notify_game_model_updated(**kwargs) + def cleanup(self) -> None: + """Cleans up after this model by clearing all associated models event listeners. + This should only ever be run when the models are no longer needed. + """ + self._event_manager.purge_all_events() + + # Notify associated models to clean up + for model in self._assoc_models: + try: + model.cleanup() + except AttributeError: + log.error(f"GameModelBase: {model} does not have a cleanup method") + def _notify_game_model_updated(self, **kwargs) -> None: """Notify listeners that the model has updated""" self.e_game_model_updated.notify(**kwargs) diff --git a/src/cli_chess/core/game/game_presenter_base.py b/src/cli_chess/core/game/game_presenter_base.py index ee86448..c021b73 100644 --- a/src/cli_chess/core/game/game_presenter_base.py +++ b/src/cli_chess/core/game/game_presenter_base.py @@ -51,6 +51,7 @@ def flip_board(self) -> None: def exit(self) -> None: """Exit current presenter/view""" + self.model.cleanup() self.view.exit() diff --git a/src/cli_chess/core/game/online_game/watch_tv/watch_tv_model.py b/src/cli_chess/core/game/online_game/watch_tv/watch_tv_model.py index 956beba..a5f87dc 100644 --- a/src/cli_chess/core/game/online_game/watch_tv/watch_tv_model.py +++ b/src/cli_chess/core/game/online_game/watch_tv/watch_tv_model.py @@ -243,4 +243,5 @@ def stop_watching(self): # TODO: Need to handle going back to the main menu when the TVStream # connection retries are exhausted. Send event notification to model? log.info("TV Stream: Stopping TV stream") + self.e_tv_stream_event.remove_all_listeners() self.running = False diff --git a/src/cli_chess/modules/board/board_model.py b/src/cli_chess/modules/board/board_model.py index 5f72648..9897963 100644 --- a/src/cli_chess/modules/board/board_model.py +++ b/src/cli_chess/modules/board/board_model.py @@ -13,7 +13,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from cli_chess.utils.event import Event +from cli_chess.utils.event import EventManager from cli_chess.utils.logging import log from random import randint from typing import List @@ -27,10 +27,11 @@ def __init__(self, orientation: chess.Color = chess.WHITE, variant="standard", f self.initial_fen = self.board.fen() self.orientation = chess.WHITE if variant.lower() == "racingkings" else orientation self.highlight_move = chess.Move.null() - self._log_init_info() - self.e_board_model_updated = Event() - self.e_successful_move_made = Event() + + self._event_manager = EventManager() + self.e_board_model_updated = self._event_manager.create_event() + self.e_successful_move_made = self._event_manager.create_event() @staticmethod def _initialize_board(variant: str, fen: str): @@ -304,6 +305,12 @@ def _notify_successful_move_made(self) -> None: """Notifies listeners that a board move has been made""" self.e_successful_move_made.notify() + def cleanup(self) -> None: + """Handles model cleanup tasks. This should only ever + be run when this model is no longer needed. + """ + self._event_manager.purge_all_events() + def _log_init_info(self): """Logs class initialization""" log.debug("=============== BOARD INITIALIZATION ===============") diff --git a/src/cli_chess/modules/material_difference/material_difference_model.py b/src/cli_chess/modules/material_difference/material_difference_model.py index cb893e0..08ca6a4 100644 --- a/src/cli_chess/modules/material_difference/material_difference_model.py +++ b/src/cli_chess/modules/material_difference/material_difference_model.py @@ -14,7 +14,7 @@ # along with this program. If not, see . from cli_chess.modules.board import BoardModel -from cli_chess.utils import Event +from cli_chess.utils import EventManager from typing import Dict from chess import PIECE_SYMBOLS, PIECE_TYPES, PieceType, Color, COLORS, WHITE, BLACK, PAWN, KNIGHT, BISHOP, ROOK, QUEEN, KING import re @@ -37,7 +37,8 @@ def __init__(self, board_model: BoardModel): self.material_difference: Dict[Color, Dict[PieceType, int]] = self.default_material_difference() self.score: Dict[Color, int] = self.default_score() - self.e_material_difference_model_updated = Event() + self._event_manager = EventManager() + self.e_material_difference_model_updated = self._event_manager.create_event() self.update() @staticmethod @@ -142,3 +143,9 @@ def get_board_orientation(self) -> Color: def _notify_material_difference_model_updated(self) -> None: """Notifies listeners of material difference model updates""" self.e_material_difference_model_updated.notify() + + def cleanup(self) -> None: + """Handles model cleanup tasks. This should only ever + be run when this model is no longer needed. + """ + self._event_manager.purge_all_events() diff --git a/src/cli_chess/modules/move_list/move_list_model.py b/src/cli_chess/modules/move_list/move_list_model.py index 1db69ce..6906055 100644 --- a/src/cli_chess/modules/move_list/move_list_model.py +++ b/src/cli_chess/modules/move_list/move_list_model.py @@ -14,7 +14,7 @@ # along with this program. If not, see . from cli_chess.modules.board import BoardModel -from cli_chess.utils import Event, log +from cli_chess.utils import EventManager, log from chess import piece_symbol from typing import List @@ -25,7 +25,8 @@ def __init__(self, board_model: BoardModel) -> None: self.board_model.e_board_model_updated.add_listener(self.update) self.move_list_data = [] - self.e_move_list_model_updated = Event() + self._event_manager = EventManager() + self.e_move_list_model_updated = self._event_manager.create_event() self.update() def update(self, **kwargs) -> None: # noqa @@ -68,3 +69,9 @@ def get_move_list_data(self) -> List[dict]: def _notify_move_list_model_updated(self) -> None: """Notifies listeners of move list model updates""" self.e_move_list_model_updated.notify() + + def cleanup(self) -> None: + """Handles model cleanup tasks. This should only ever + be run when this model is no longer needed. + """ + self._event_manager.purge_all_events() diff --git a/src/cli_chess/utils/__init__.py b/src/cli_chess/utils/__init__.py index a34d6ae..0f72ef0 100644 --- a/src/cli_chess/utils/__init__.py +++ b/src/cli_chess/utils/__init__.py @@ -1,6 +1,6 @@ from .common import is_linux_os, is_windows_os, is_mac_os, str_to_bool, threaded, open_url_in_browser from .config import force_recreate_configs, print_program_config -from .event import Event +from .event import Event, EventManager from .logging import log, redact_from_logs from .argparse import setup_argparse from .styles import default, twilight diff --git a/src/cli_chess/utils/event.py b/src/cli_chess/utils/event.py index 151d8d0..754e652 100644 --- a/src/cli_chess/utils/event.py +++ b/src/cli_chess/utils/event.py @@ -14,13 +14,14 @@ # along with this program. If not, see . from __future__ import annotations -from typing import Callable +from typing import Callable, List class Event: - """Event notification class. Interested listeners can add a callable - to be notified when the event is triggered (using notify()). Generally - this is used for models to notify presenters of updated data. + """Event notification class. This class creates a singular event instance + which listeners can subscribe to with a callable. The callable will be + notified when the event is triggered (using notify()). Generallty, this + class should not be instantiated directly, but rather from the EventManager class. """ def __init__(self): self.listeners = [] @@ -35,7 +36,33 @@ def remove_listener(self, listener: Callable) -> None: if listener in self.listeners: self.listeners.remove(listener) + def remove_all_listeners(self) -> None: + """Removes all listeners associated to this event""" + self.listeners.clear() + def notify(self, *args, **kwargs) -> None: """Notifies all listeners of the event""" for listener in self.listeners: listener(*args, **kwargs) + + +class EventManager: + """Event manager class. Models which use events should create + events using this manager for easier event maintenance + """ + def __init__(self): + self._event_list: List[Event] = [] + + def create_event(self) -> Event: + """Creates and returns a new event for listeners to subscribe to""" + e = Event() + self._event_list.append(e) + return e + + def purge_all_events(self) -> None: + """Purges all events in the event list by removing + all associated events/listeners + """ + for event in self._event_list: + event.remove_all_listeners() + self._event_list.clear() From b7ae03a8a24ce2483bd2b6637a6cc70306c7cc18 Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Thu, 2 Mar 2023 09:32:30 -0600 Subject: [PATCH 23/25] Add ability to purge all listeners --- src/cli_chess/utils/event.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/cli_chess/utils/event.py b/src/cli_chess/utils/event.py index 754e652..8ee9f95 100644 --- a/src/cli_chess/utils/event.py +++ b/src/cli_chess/utils/event.py @@ -59,10 +59,16 @@ def create_event(self) -> Event: self._event_list.append(e) return e - def purge_all_events(self) -> None: - """Purges all events in the event list by removing - all associated events/listeners + def purge_all_event_listeners(self) -> None: + """For each event associated to this event manager + this method will clear all listeners """ for event in self._event_list: event.remove_all_listeners() + + def purge_all_events(self) -> None: + """Purges all events in the event list by removing + all associated events and listeners + """ + self.purge_all_event_listeners() self._event_list.clear() From 0df7fd0e3bc312a66d8c2676675ab6baab4d6a47 Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Thu, 2 Mar 2023 09:33:04 -0600 Subject: [PATCH 24/25] Add tests for EventManager --- src/cli_chess/tests/utils/test_event.py | 147 ++++++++++++++++-------- 1 file changed, 96 insertions(+), 51 deletions(-) diff --git a/src/cli_chess/tests/utils/test_event.py b/src/cli_chess/tests/utils/test_event.py index ff1b6ea..31279b8 100644 --- a/src/cli_chess/tests/utils/test_event.py +++ b/src/cli_chess/tests/utils/test_event.py @@ -13,7 +13,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from cli_chess.utils import Event +from cli_chess.utils import Event, EventManager from unittest.mock import Mock import pytest @@ -35,53 +35,98 @@ def event(listener1): return event -def test_add_listener(event: Event, listener1: Mock, listener2: Mock): - event.add_listener(listener2) - assert listener2 in event.listeners - - event.add_listener(listener1) - assert event.listeners.count(listener1) == 1 - - -def test_remove_listener(event: Event, listener1: Mock, listener2: Mock): - assert listener2 not in event.listeners - event.remove_listener(listener2) - assert listener1 in event.listeners - - event.add_listener(listener2) - event.remove_listener(listener1) - assert listener1 not in event.listeners - assert listener2 in event.listeners - - -def test_notify(event: Event, listener1: Mock, listener2: Mock): - listener1.assert_not_called() - listener2.assert_not_called() - - event.notify() - listener1.assert_called() - listener2.assert_not_called() - - # Test notification after adding a listener - listener1.reset_mock() - event.add_listener(listener2) - event.notify() - listener1.assert_called() - listener2.assert_called() - - # Test notification after removing a listener - listener1.reset_mock() - listener2.reset_mock() - event.remove_listener(listener1) - event.notify() - listener1.assert_not_called() - listener2.assert_called() - - # Try notifying without any listeners - listener1.reset_mock() - listener2.reset_mock() - event.listeners.clear() - assert not event.listeners - event.notify() - listener1.assert_not_called() - listener2.assert_not_called() +@pytest.fixture +def event_manager(): + event_manger = EventManager() + event_manger.create_event().add_listener(listener1) + return event_manger + + +class TestEvent: + def test_add_listener(self, event: Event, listener1: Mock, listener2: Mock): + event.add_listener(listener2) + assert listener2 in event.listeners + + event.add_listener(listener1) + assert event.listeners.count(listener1) == 1 + + def test_remove_listener(self, event: Event, listener1: Mock, listener2: Mock): + assert listener2 not in event.listeners + event.remove_listener(listener2) + assert listener1 in event.listeners + + event.add_listener(listener2) + event.remove_listener(listener1) + assert listener1 not in event.listeners + assert listener2 in event.listeners + + def test_notify(self, event: Event, listener1: Mock, listener2: Mock): + listener1.assert_not_called() + listener2.assert_not_called() + + event.notify() + listener1.assert_called() + listener2.assert_not_called() + + # Test notification after adding a listener + listener1.reset_mock() + event.add_listener(listener2) + event.notify() + listener1.assert_called() + listener2.assert_called() + + # Test notification after removing a listener + listener1.reset_mock() + listener2.reset_mock() + event.remove_listener(listener1) + event.notify() + listener1.assert_not_called() + listener2.assert_called() + + # Try notifying without any listeners + listener1.reset_mock() + listener2.reset_mock() + event.listeners.clear() + assert not event.listeners + event.notify() + listener1.assert_not_called() + listener2.assert_not_called() + + +class TestEventManager: + def test_create_event(self, event_manager: EventManager, listener1: Mock): + initial_len = len(event_manager._event_list) + event = event_manager.create_event() + event.add_listener(listener1) + assert len(event_manager._event_list) - initial_len == 1 + assert isinstance(event_manager._event_list[-1], Event) + + def test_purge_all_event_listeners(self, event_manager: EventManager, listener2: Mock): + event_manager.create_event().add_listener(listener2) + assert len(event_manager._event_list) == 2 + + # Verify events in the manager have listeners associated + for event in event_manager._event_list: + assert len(event.listeners) == 1 + + # Purge listeners and verifying listeners are cleared but events still exist + event_manager.purge_all_event_listeners() + for event in event_manager._event_list: + assert len(event.listeners) == 0 + assert len(event_manager._event_list) == 2 + + def test_purge_all_events(self, event_manager: EventManager, listener2: Mock): + """Purges all events in the event list by removing + all associated events and listeners + """ + test_event = event_manager.create_event() + test_event.add_listener(listener2) + assert len(event_manager._event_list) == 2 + + # Test purging everything + event_manager.purge_all_events() + assert len(event_manager._event_list) == 0 + + # Test firing a previously linked event + test_event.notify() + listener2.assert_not_called() From 95f1b90b4b84d5fc933a017598e0306f5d9ae468 Mon Sep 17 00:00:00 2001 From: Trevor Bayless Date: Thu, 2 Mar 2023 09:40:10 -0600 Subject: [PATCH 25/25] Temp remove macos-latest testing --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b509fe..995166b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + os: [ubuntu-latest, windows-latest] # TODO: Test on macos-latest when repo is public python-version: ["3.7", "3.8", "3.9", "3.10"] # TODO: Add 3.11 after `wrapt` update: https://github.com/GrahamDumpleton/wrapt/issues/226 permissions: contents: read