From cfd079f6c12a931f4302e640b03f207ceec0e7e8 Mon Sep 17 00:00:00 2001 From: Daniel Brenner Date: Thu, 14 May 2026 19:15:37 -0400 Subject: [PATCH 1/2] Add type hints to Board and Move (partial pass, #46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First pass of #46, scoped to the two most-depended-on classes per the issue's suggested order. Adds: - `from __future__ import annotations` so annotations are lazy and Move/Board forward references work without string-quoting them. - Module-level type aliases: SquareName (for the flexible inputs that square_name_to_array_idxs accepts), BoardIdxs (file_idx, rank_idx tuple), PieceChar (single piece letter). - Parameter and return annotations on every method of Board and Move. Annotations only — no behavior changes. All four TestRLM cases pass plus test_entered_move_processing. Follow-up passes will cover Piece + subclasses and Game/GameController. Co-Authored-By: Claude Opus 4.7 (1M context) --- rlm.py | 95 ++++++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 62 insertions(+), 33 deletions(-) diff --git a/rlm.py b/rlm.py index eb7b2af..38746c0 100755 --- a/rlm.py +++ b/rlm.py @@ -1,9 +1,11 @@ #!/usr/bin/python3 # RLM.py # -# The python implementation of the Random Legal Move chess-playin', sass talkin', ... thing +# The python implementation of the Random Legal Move chess-playin', sass talkin', ... thing # -# +# +from __future__ import annotations + versionNumber = 0.40 # basic gameplay is now possible! import numpy as np @@ -14,17 +16,29 @@ import gzip import pandas as pd -# Let's start off with this as a single file and refactor it into multple files when we feel +# Let's start off with this as a single file and refactor it into multple files when we feel # like this one is getting unwieldy # --Okay. :) + +# --- Type aliases ---------------------------------------------------------- +# SquareName: anything that Board.square_name_to_array_idxs() can interpret. +# Includes 'a3', ['a','3'], [0,2], ('a',3), etc. Always length 2 in practice +# but Python's type system can't enforce that here. +SquareName = str | tuple | list +# BoardIdxs: a (file_idx, rank_idx) pair — note that numpy indexing into +# board_array is (rank, file), opposite order. See issue #37. +BoardIdxs = tuple[int | None, int | None] +# PieceChar: a single character from 'KQRBNPkqrbnp' (or '-' for empty). +PieceChar = str + class Board: '''A class to hold and manipulate representations of the chess board''' EMPTY_SQUARE = '-' # class constant, a single character string to represent an empty square # In the terminal, the unicode glyphs actually look reversed, so: - def __init__(self, board_position=None, piece_list=None): + def __init__(self, board_position: np.ndarray | str | None = None, piece_list: list | None = None): ''' Constructor for Board class. Optional board_position input allows construction from a given position. board_position can be supplied as a numpy array of single @@ -108,7 +122,7 @@ def __init__(self, board_position=None, piece_list=None): 6: 'g', 7: 'h', } - def __getitem__(self, square_name): + def __getitem__(self, square_name: SquareName) -> str | None: ''' Allows indexing into Board objects. If b is a Board, then b['a3'] should return the piece which is on square a3. This function should handle indexing in pretty much any sensible way we can think of. The actual intepretation of the square name is handled @@ -132,7 +146,7 @@ def __getitem__(self, square_name): VALID_BOARD_SQUARE_CONTENTS_PATTERN = re.compile('(^[pnbrkqPNBRKQ]$)|(^%s$)' % EMPTY_SQUARE) - def __setitem__(self, square_name, new_value): + def __setitem__(self, square_name: SquareName, new_value: PieceChar) -> None: ''' Allows setting of board positions via indexing expressions. If b is a Board, then b['a3'] = 'P' should place a white pawn on square 'a3' of the board. All the ways of specifiying a square name allowed by square_name_to_array_idxs() are allowed. new_value must be a single @@ -146,7 +160,7 @@ def __setitem__(self, square_name, new_value): self.board_array[rank_idx, file_idx] = new_value # NB that indexing into numpy array is rank,file whereas everywhere else we use file,rank @classmethod - def square_name_to_array_idxs(cls, square_name): + def square_name_to_array_idxs(cls, square_name: SquareName) -> BoardIdxs: '''This function should handle interpreting square names in pretty much any sensible way we can think of. Here are some thoughts of how it might make sense to call this: @@ -176,12 +190,12 @@ def square_name_to_array_idxs(cls, square_name): return file_idx, rank_idx - def copy(self): + def copy(self) -> Board: '''Return a copy of the existing board''' board_copy = Board(board_position=self.board_array ) return board_copy - def move(self, source_square_name, destination_square_name): + def move(self, source_square_name: SquareName, destination_square_name: SquareName) -> str: '''Moves whatever piece is on the source square to the destination square. Throws an error if the source square is empty. Returns the contents of the destination square (might be handy for capture processing). Square names @@ -199,7 +213,7 @@ def move(self, source_square_name, destination_square_name): return destination_occupant # return the captured piece (or empty square if it was empty) - def list_pieces(self): + def list_pieces(self) -> tuple[list[str], list[str]]: '''Lists all pieces which are on the board, divided into a list of white pieces and a list of black pieces. (Note that these are single characters, not Piece objects)''' pieces = [piece for piece in self.board_array.ravel() if not (piece==Board.EMPTY_SQUARE)] @@ -208,7 +222,7 @@ def list_pieces(self): return white_pieces, black_pieces @classmethod - def is_same_square(cls, square_name_1, square_name_2): + def is_same_square(cls, square_name_1: SquareName | None, square_name_2: SquareName | None) -> bool: # Returns True if square name 1 and 2 refer to the same board location, even if they are in different formats # If not, or if either is None, returns False if square_name_1 is None or square_name_2 is None: @@ -220,18 +234,18 @@ def is_same_square(cls, square_name_1, square_name_2): return sq1==sq2 @classmethod - def square_rank_str(cls, square_idxs): + def square_rank_str(cls, square_idxs: BoardIdxs) -> str: # Returns the rank number as a single character string (one-based, not zero-based) return str(int(square_idxs[1])+1) @classmethod - def square_file_lett(cls, square_idxs): + def square_file_lett(cls, square_idxs: BoardIdxs) -> str: # Returns the file letter as a single character string file_idx = square_idxs[0] file_lett = Board.IDX_TO_FILE_DICT[file_idx] return file_lett - def __str__(self): + def __str__(self) -> str: '''This is called whenever a board is converted to a string (like when it is being printed)''' # How about something like this: ''' @@ -328,7 +342,7 @@ def is_FEN(cls, possible_FEN: str) -> bool: return True - def to_FEN_board(self): + def to_FEN_board(self) -> str: '''Export current board position as FEN board string''' row_strings = [] for rank_idx in range(7,-1,-1): @@ -358,7 +372,7 @@ def to_FEN_board(self): return FEN_board_string - def find_king_square(self, color): + def find_king_square(self, color: str) -> str: ''' Should return the square of the king of the given color (color should start with 'w' or 'b', case insensitive, representing white or black). Square is returned as algebraic string''' @@ -374,7 +388,7 @@ def find_king_square(self, color): @classmethod - def convert_FEN_to_board_array(cls, FEN): + def convert_FEN_to_board_array(cls, FEN: str) -> np.ndarray: '''Converts FEN or FEN board position to a board array and returns it''' # FEN's look like "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" FEN_board = FEN.split()[0] # keep only first part of FEN if full FEN @@ -403,7 +417,7 @@ def convert_FEN_to_board_array(cls, FEN): return board_array @classmethod - def squareIdx_to_boardIdxs(cls, squareIdx, board_size=(8,8)): + def squareIdx_to_boardIdxs(cls, squareIdx: int, board_size: tuple[int, int] = (8, 8)) -> tuple[int, int]: # FEN square index to board_array indices. Must take into # account that ranks are in a different order and that board # array indices are 0-based. @@ -426,7 +440,7 @@ def squareIdx_to_boardIdxs(cls, squareIdx, board_size=(8,8)): @classmethod - def square_to_alg_name(cls, square_name): + def square_to_alg_name(cls, square_name: SquareName) -> str: '''Convert any square representation to algebraic square name, i.e. letter file followed by 1-based rank''' sq_arr = cls.square_name_to_array_idxs(square_name) # standardize alg_name = cls.IDX_TO_FILE_DICT[sq_arr[0]] + "%i" % (sq_arr[1] + 1) # convert @@ -437,7 +451,17 @@ class Move: '''Should handle having an internal representation of moves and converting to various output representations ''' - def __init__(self, char, starting_square, destination_square, captured_piece=None, is_castling=False, promotion_piece=None, is_en_passant_capture=False, new_en_passant_square=None): + def __init__( + self, + char: PieceChar, + starting_square: SquareName, + destination_square: SquareName, + captured_piece: PieceChar | None = None, + is_castling: bool = False, + promotion_piece: PieceChar | None = None, + is_en_passant_capture: bool = False, + new_en_passant_square: SquareName | None = None, + ): ''' Move representation keeps track of everything needed to relate to moves. ''' self.single_char = char @@ -450,18 +474,18 @@ def __init__(self, char, starting_square, destination_square, captured_piece=Non self.new_en_passant_square = new_en_passant_square - def is_capture(self): + def is_capture(self) -> bool: return not (self.captured_piece is None) - def is_promotion(self): + def is_promotion(self) -> bool: return not (self.promotion_piece is None) - def to_tuple(self): + def to_tuple(self) -> tuple: # For debugging, this is a way to return the move in the internal format which is used in initialization move_tuple = (self.single_char, self.starting_square, self.destination_square, self.captured_piece, self.is_castling, self.promotion_piece, self.is_en_passant_capture, self.new_en_passant_square) return move_tuple - def to_long_algebraic(self, use_figurine=False, note_ep=False): + def to_long_algebraic(self, use_figurine: bool = False, note_ep: bool = False) -> str: '''Long algebraic includes starting and destination square''' if use_figurine: p = self.PIECE_TO_FIGURINE_DICT[self.single_char] @@ -484,7 +508,7 @@ def to_long_algebraic(self, use_figurine=False, note_ep=False): return move_string - def to_short_algebraic(self, board): + def to_short_algebraic(self, board: Board) -> str: # Same as long algebraic except drop destination square # TODO: actually, this should have a lot more logic so it includes elements of starting square if necessary # TODO: hmm, to do this, we actually need the board, because that's what we need to resolve ambiguities @@ -530,16 +554,16 @@ def to_short_algebraic(self, board): } - def __str__(self): + def __str__(self) -> str: '''String representation of Move class.''' return self.to_long_algebraic() # just use long algebraic for now - def __repr__(self): + def __repr__(self) -> str: '''String representation for Move objects (this one shows up for example in lists)''' return 'MoveObject.['+str(self)+']' @classmethod - def square_to_string(cls, sq): + def square_to_string(cls, sq: BoardIdxs) -> str: '''Convert array indices to string''' # should this handle non-array index square representations also? file_letter = cls.IDX_TO_FILE_DICT[sq[0]] @@ -547,7 +571,12 @@ def square_to_string(cls, sq): return file_letter+rank_number @classmethod - def parse_move_without_game(cls, entered_move, white_is_moving=True, make_assumptions=False): + def parse_move_without_game( + cls, + entered_move: str, + white_is_moving: bool = True, + make_assumptions: bool = False, + ) -> dict: ''' This function tries to extract as much information as possible from an entered move text string, parsing it into move elements. It keeps track of what move elements are known, what remain unknown, and what have some partial information (e.g. a piece was captured, but it wasn't specified which). @@ -766,7 +795,7 @@ def parse_move_without_game(cls, entered_move, white_is_moving=True, make_assump return move_elem_dict @classmethod - def find_matches_to_partial_move(cls, partial_move_dict, move_list): + def find_matches_to_partial_move(cls, partial_move_dict: dict, move_list: list[Move]) -> list[Move]: ''' Find all possible matches where every known or partially known element of partial_move_dict is consistent with a Move object on the given move_list. partial_move_dict should be the output of parse_move_without_game(). @@ -791,7 +820,7 @@ def find_matches_to_partial_move(cls, partial_move_dict, move_list): return matched_moves @classmethod - def parse_entered_move(cls, entered_move, white_is_moving, legal_moves_list): + def parse_entered_move(cls, entered_move: str, white_is_moving: bool, legal_moves_list: list[Move]) -> list[Move]: # Parses entered text move, and returns the set of legal moves which is # consistent with entered info partial_move_dict = cls.parse_move_without_game(entered_move, white_is_moving) @@ -800,14 +829,14 @@ def parse_entered_move(cls, entered_move, white_is_moving, legal_moves_list): @classmethod - def is_on_move_list(cls, move, move_list): + def is_on_move_list(cls, move: Move, move_list: list[Move]) -> bool: # Returns true if given move is on given move list for list_move in move_list: if list_move == move: return True return False - def __eq__(self, move_to_match): + def __eq__(self, move_to_match: object) -> bool: # Returns true if self and move_to_match represent the same move in all respects if (self.single_char == move_to_match.single_char and self.starting_square == move_to_match.starting_square and From 518616d3a762dab44677b75dd16971b34b7ed7ea Mon Sep 17 00:00:00 2001 From: Daniel Brenner Date: Thu, 14 May 2026 19:37:42 -0400 Subject: [PATCH 2/2] Add mypy CI workflow, advisory (#46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third CI workflow file, mirroring the lint workflow's pattern: - Triggers on every push and every pull_request. - continue-on-error: true so findings surface without blocking merge. - --ignore-missing-imports keeps the workflow runnable without installing numpy/pandas type stubs. Runs on rlm.py only for now; can widen once tests/ has type hints too. Current mypy reports ~60 errors against the partially-typed codebase — several map to bugs already filed separately (e.g. the setdefault issue in #33, the tuple-assert in #32, and __eq__'s object signature vs. body that assumes Move). The advisory mode means these accumulate as a backlog rather than blocking merge. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/typecheck.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/typecheck.yml diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml new file mode 100644 index 0000000..1410e5a --- /dev/null +++ b/.github/workflows/typecheck.yml @@ -0,0 +1,21 @@ +name: typecheck (advisory) + +on: + push: + pull_request: + +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install mypy + run: | + python -m pip install --upgrade pip + python -m pip install mypy + - name: Run mypy (advisory) + continue-on-error: true + run: mypy rlm.py --ignore-missing-imports