From b2144c2564f740ea120d037e9ec6129d8a3bea2c Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 3 Oct 2025 23:01:01 +0200 Subject: [PATCH 01/21] Remove explicit CodeQL configuration --- .github/workflows/codeql.yml | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index bc991c885..000000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: CodeQL - -on: [push, pull_request] - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - steps: - - uses: actions/checkout@v4 - - uses: github/codeql-action/init@v3 - with: - languages: python - - uses: github/codeql-action/analyze@v3 From 760360b8ddb65129aea46f84d99b5491e6ed6435 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 3 Oct 2025 23:04:49 +0200 Subject: [PATCH 02/21] Explicitly specify CI workflow permissions --- .github/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 22d3b278a..9eca0fce3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,6 +2,9 @@ name: Test on: [push, pull_request] +permissions: + contents: read + jobs: test: strategy: From 6b1cfedd442a05767ee28c7752a800ad4190f423 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 3 Oct 2025 23:14:56 +0200 Subject: [PATCH 03/21] Fix chess.gaviota bytearray usage does not pass mypy 1.18 --- chess/gaviota.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chess/gaviota.py b/chess/gaviota.py index 281df836b..dc19557cd 100644 --- a/chess/gaviota.py +++ b/chess/gaviota.py @@ -1716,7 +1716,7 @@ def _tb_probe(self, req: Request) -> int: z = self.egtb_block_getsize_zipped(req.egkey, block) self.egtb_block_park(req.egkey, block, stream) - buffer_zipped = stream.read(z) + buffer_zipped: bytearray | bytes = stream.read(z) if buffer_zipped[0] == 0: # If flag is zero, plain LZMA is following. From 71f5a21fe9a83770081fa9f7d2deb3835db984e4 Mon Sep 17 00:00:00 2001 From: Jackson Hall Date: Sat, 4 Oct 2025 16:52:41 -0400 Subject: [PATCH 04/21] Add and use rank/file constants --- chess/__init__.py | 80 +++++++++++++++++++++++++++++------------------ chess/gaviota.py | 8 ++--- chess/syzygy.py | 2 +- 3 files changed, 55 insertions(+), 35 deletions(-) diff --git a/chess/__init__.py b/chess/__init__.py index 28bcad0cd..3d3dad4f0 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -64,8 +64,28 @@ def piece_name(piece_type: PieceType) -> str: "P": "♙", "p": "♟", } +File: TypeAlias = int +FILE_A: File = 0 +FILE_B: File = 1 +FILE_C: File = 2 +FILE_D: File = 3 +FILE_E: File = 4 +FILE_F: File = 5 +FILE_G: File = 6 +FILE_H: File = 7 +FILES = [FILE_A, FILE_B, FILE_C, FILE_D, FILE_E, FILE_F, FILE_G, FILE_H] FILE_NAMES = ["a", "b", "c", "d", "e", "f", "g", "h"] +Rank: TypeAlias = int +RANK_1: Rank = 0 +RANK_2: Rank = 1 +RANK_3: Rank = 2 +RANK_4: Rank = 3 +RANK_5: Rank = 4 +RANK_6: Rank = 5 +RANK_7: Rank = 6 +RANK_8: Rank = 7 +RANKS = [RANK_1, RANK_2, RANK_3, RANK_4, RANK_5, RANK_6, RANK_7, RANK_8] RANK_NAMES = ["1", "2", "3", "4", "5", "6", "7", "8"] STARTING_FEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" @@ -251,15 +271,15 @@ def square_name(square: Square) -> str: """Gets the name of the square, like ``a3``.""" return SQUARE_NAMES[square] -def square(file_index: int, rank_index: int) -> Square: +def square(file_index: File, rank_index: Rank) -> Square: """Gets a square number by file and rank index.""" return rank_index * 8 + file_index -def square_file(square: Square) -> int: +def square_file(square: Square) -> File: """Gets the file index of the square where ``0`` is the a-file.""" return square & 7 -def square_rank(square: Square) -> int: +def square_rank(square: Square) -> Rank: """Gets the rank index of the square where ``0`` is the first rank.""" return square >> 3 @@ -376,24 +396,24 @@ def square_mirror(square: Square) -> Square: BB_LIGHT_SQUARES: Bitboard = 0x55aa_55aa_55aa_55aa BB_DARK_SQUARES: Bitboard = 0xaa55_aa55_aa55_aa55 -BB_FILE_A: Bitboard = 0x0101_0101_0101_0101 << 0 -BB_FILE_B: Bitboard = 0x0101_0101_0101_0101 << 1 -BB_FILE_C: Bitboard = 0x0101_0101_0101_0101 << 2 -BB_FILE_D: Bitboard = 0x0101_0101_0101_0101 << 3 -BB_FILE_E: Bitboard = 0x0101_0101_0101_0101 << 4 -BB_FILE_F: Bitboard = 0x0101_0101_0101_0101 << 5 -BB_FILE_G: Bitboard = 0x0101_0101_0101_0101 << 6 -BB_FILE_H: Bitboard = 0x0101_0101_0101_0101 << 7 +BB_FILE_A: Bitboard = 0x0101_0101_0101_0101 << FILE_A +BB_FILE_B: Bitboard = 0x0101_0101_0101_0101 << FILE_B +BB_FILE_C: Bitboard = 0x0101_0101_0101_0101 << FILE_C +BB_FILE_D: Bitboard = 0x0101_0101_0101_0101 << FILE_D +BB_FILE_E: Bitboard = 0x0101_0101_0101_0101 << FILE_E +BB_FILE_F: Bitboard = 0x0101_0101_0101_0101 << FILE_F +BB_FILE_G: Bitboard = 0x0101_0101_0101_0101 << FILE_G +BB_FILE_H: Bitboard = 0x0101_0101_0101_0101 << FILE_H BB_FILES: List[Bitboard] = [BB_FILE_A, BB_FILE_B, BB_FILE_C, BB_FILE_D, BB_FILE_E, BB_FILE_F, BB_FILE_G, BB_FILE_H] -BB_RANK_1: Bitboard = 0xff << (8 * 0) -BB_RANK_2: Bitboard = 0xff << (8 * 1) -BB_RANK_3: Bitboard = 0xff << (8 * 2) -BB_RANK_4: Bitboard = 0xff << (8 * 3) -BB_RANK_5: Bitboard = 0xff << (8 * 4) -BB_RANK_6: Bitboard = 0xff << (8 * 5) -BB_RANK_7: Bitboard = 0xff << (8 * 6) -BB_RANK_8: Bitboard = 0xff << (8 * 7) +BB_RANK_1: Bitboard = 0xff << (8 * RANK_1) +BB_RANK_2: Bitboard = 0xff << (8 * RANK_2) +BB_RANK_3: Bitboard = 0xff << (8 * RANK_3) +BB_RANK_4: Bitboard = 0xff << (8 * RANK_4) +BB_RANK_5: Bitboard = 0xff << (8 * RANK_5) +BB_RANK_6: Bitboard = 0xff << (8 * RANK_6) +BB_RANK_7: Bitboard = 0xff << (8 * RANK_7) +BB_RANK_8: Bitboard = 0xff << (8 * RANK_8) BB_RANKS: List[Bitboard] = [BB_RANK_1, BB_RANK_2, BB_RANK_3, BB_RANK_4, BB_RANK_5, BB_RANK_6, BB_RANK_7, BB_RANK_8] BB_BACKRANKS: Bitboard = BB_RANK_1 | BB_RANK_8 @@ -1847,7 +1867,7 @@ def generate_pseudo_legal_moves(self, from_mask: Bitboard = BB_ALL, to_mask: Bit self.occupied_co[not self.turn] & to_mask) for to_square in scan_reversed(targets): - if square_rank(to_square) in [0, 7]: + if square_rank(to_square) in [RANK_1, RANK_8]: yield Move(from_square, to_square, QUEEN) yield Move(from_square, to_square, ROOK) yield Move(from_square, to_square, BISHOP) @@ -1870,7 +1890,7 @@ def generate_pseudo_legal_moves(self, from_mask: Bitboard = BB_ALL, to_mask: Bit for to_square in scan_reversed(single_moves): from_square = to_square + (8 if self.turn == BLACK else -8) - if square_rank(to_square) in [0, 7]: + if square_rank(to_square) in [RANK_1, RANK_8]: yield Move(from_square, to_square, QUEEN) yield Move(from_square, to_square, ROOK) yield Move(from_square, to_square, BISHOP) @@ -1897,7 +1917,7 @@ def generate_pseudo_legal_ep(self, from_mask: Bitboard = BB_ALL, to_mask: Bitboa capturers = ( self.pawns & self.occupied_co[self.turn] & from_mask & BB_PAWN_ATTACKS[not self.turn][self.ep_square] & - BB_RANKS[4 if self.turn else 3]) + BB_RANKS[RANK_5 if self.turn else RANK_4]) for capturer in scan_reversed(capturers): yield Move(capturer, self.ep_square) @@ -1977,9 +1997,9 @@ def is_pseudo_legal(self, move: Move) -> bool: if piece != PAWN: return False - if self.turn == WHITE and square_rank(move.to_square) != 7: + if self.turn == WHITE and square_rank(move.to_square) != RANK_8: return False - elif self.turn == BLACK and square_rank(move.to_square) != 0: + elif self.turn == BLACK and square_rank(move.to_square) != RANK_1: return False # Handle castling. @@ -2401,18 +2421,18 @@ def push(self, move: Move) -> None: else: self.castling_rights &= ~BB_RANK_8 elif captured_piece_type == KING and not self.promoted & to_bb: - if self.turn == WHITE and square_rank(move.to_square) == 7: + if self.turn == WHITE and square_rank(move.to_square) == RANK_8: self.castling_rights &= ~BB_RANK_8 - elif self.turn == BLACK and square_rank(move.to_square) == 0: + elif self.turn == BLACK and square_rank(move.to_square) == RANK_1: self.castling_rights &= ~BB_RANK_1 # Handle special pawn moves. if piece_type == PAWN: diff = move.to_square - move.from_square - if diff == 16 and square_rank(move.from_square) == 1: + if diff == 16 and square_rank(move.from_square) == RANK_2: self.ep_square = move.from_square + 8 - elif diff == -16 and square_rank(move.from_square) == 6: + elif diff == -16 and square_rank(move.from_square) == RANK_7: self.ep_square = move.from_square - 8 elif move.to_square == ep_square and abs(diff) in [7, 9] and not captured_piece_type: # Remove pawns captured en passant. @@ -3605,11 +3625,11 @@ def _valid_ep_square(self) -> Optional[Square]: return None if self.turn == WHITE: - ep_rank = 5 + ep_rank = RANK_6 pawn_mask = shift_down(BB_SQUARES[self.ep_square]) seventh_rank_mask = shift_up(BB_SQUARES[self.ep_square]) else: - ep_rank = 2 + ep_rank = RANK_3 pawn_mask = shift_up(BB_SQUARES[self.ep_square]) seventh_rank_mask = shift_down(BB_SQUARES[self.ep_square]) diff --git a/chess/gaviota.py b/chess/gaviota.py index dc19557cd..c352a27ca 100644 --- a/chess/gaviota.py +++ b/chess/gaviota.py @@ -110,12 +110,12 @@ def idx_is_empty(x: int) -> int: def flip_type(x: chess.Square, y: chess.Square) -> int: ret = 0 - if chess.square_file(x) > 3: + if chess.square_file(x) > chess.FILE_D: x = flip_we(x) y = flip_we(y) ret |= 1 - if chess.square_rank(x) > 3: + if chess.square_rank(x) > chess.RANK_4: x = flip_ns(x) y = flip_ns(y) ret |= 2 @@ -351,11 +351,11 @@ def init_ppidx() -> Tuple[List[List[int]], List[int], List[int]]: def norm_kkindex(x: chess.Square, y: chess.Square) -> Tuple[int, int]: - if chess.square_file(x) > 3: + if chess.square_file(x) > chess.FILE_D: x = flip_we(x) y = flip_we(y) - if chess.square_rank(x) > 3: + if chess.square_rank(x) > chess.RANK_4: x = flip_ns(x) y = flip_ns(y) diff --git a/chess/syzygy.py b/chess/syzygy.py index c61890550..0c6b7822c 100644 --- a/chess/syzygy.py +++ b/chess/syzygy.py @@ -761,7 +761,7 @@ def calc_symlen(self, d: PairsData, s: int, tmp: List[int]) -> None: d.symlen[s] = d.symlen[s1] + d.symlen[s2] + 1 tmp[s] = 1 - def pawn_file(self, pos: List[chess.Square]) -> int: + def pawn_file(self, pos: List[chess.Square]) -> chess.File: for i in range(1, self.pawns[0]): if FLAP[pos[0]] > FLAP[pos[i]]: pos[0], pos[i] = pos[i], pos[0] From bd8074d20e7aa667315b54470d0f3aae6390e69d Mon Sep 17 00:00:00 2001 From: Jackson Hall Date: Sat, 4 Oct 2025 20:47:45 -0400 Subject: [PATCH 05/21] Add `parse_file`/`parse_rank`, `file_name`/rank_name` --- chess/__init__.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/chess/__init__.py b/chess/__init__.py index 3d3dad4f0..84bfa632a 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -275,6 +275,32 @@ def square(file_index: File, rank_index: Rank) -> Square: """Gets a square number by file and rank index.""" return rank_index * 8 + file_index +def parse_file(name: str) -> File: + """ + Gets the file index for the given file *name* + (e.g., ``a`` returns ``0``). + + :raises: :exc:`ValueError` if the file name is invalid. + """ + return FILE_NAMES.index(name) + +def file_name(file: File) -> str: + """Gets the name of the file, like ``a``.""" + return FILE_NAMES[file] + +def parse_rank(name: str) -> File: + """ + Gets the rank index for the given rank *name* + (e.g., ``1`` returns ``0``). + + :raises: :exc:`ValueError` if the rank name is invalid. + """ + return FILE_NAMES.index(name) + +def rank_name(rank: Rank) -> str: + """Gets the name of the rank, like ``1``.""" + return FILE_NAMES[rank] + def square_file(square: Square) -> File: """Gets the file index of the square where ``0`` is the a-file.""" return square & 7 From 376d603694913a82bcb2efa594972a5fba5804f6 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 11 Oct 2025 09:20:54 +0200 Subject: [PATCH 06/21] Explicitly support Python 3.14 --- .github/workflows/test.yml | 24 ++++++++++++------------ setup.py | 1 + tox.ini | 2 +- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9eca0fce3..6b31bce24 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,11 +10,11 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest] - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - run: .github/workflows/setup-${{ matrix.os }}.sh @@ -24,10 +24,10 @@ jobs: perft: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: - python-version: "3.13" + python-version: "3.14" - run: pip install -e . - run: python examples/perft/perft.py -t 1 examples/perft/random.perft --max-nodes 10000 - run: python examples/perft/perft.py -t 1 examples/perft/chess960.perft --max-nodes 100000 @@ -42,11 +42,11 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - run: pip install -e . @@ -59,10 +59,10 @@ jobs: readme: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: - python-version: "3.13" + python-version: "3.14" - run: sudo apt-get update && sudo apt-get install -y docutils-common - run: python setup.py --long-description | rst2html --strict --no-raw > /dev/null - run: pip install -e . diff --git a/setup.py b/setup.py index 9d6aa8c58..5815947cd 100755 --- a/setup.py +++ b/setup.py @@ -84,6 +84,7 @@ def read_description(): "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Games/Entertainment :: Board Games", "Topic :: Games/Entertainment :: Turn Based Strategy", "Topic :: Software Development :: Libraries :: Python Modules", diff --git a/tox.ini b/tox.ini index 5970b2c79..493a46feb 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py38,py39,py310,py311,py312,py313 +envlist = py38,py39,py310,py311,py312,py313,py314 [testenv] passenv = LD_LIBRARY_PATH From e4386c2f1efcb686c1d6222681cc84d1f0b06ded Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 11 Oct 2025 15:25:33 +0200 Subject: [PATCH 07/21] Remove chess.engine.DefaultEventLoopPolicy (breaking change forced by Python) --- chess/engine.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index b979b278f..8482c31ba 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -50,10 +50,6 @@ def override(fn: F, /) -> F: MANAGED_OPTIONS = ["uci_chess960", "uci_variant", "multipv", "ponder"] -# No longer needed, but alias kept around for compatibility. -EventLoopPolicy = asyncio.DefaultEventLoopPolicy - - def run_in_background(coroutine: Callable[[concurrent.futures.Future[T]], Coroutine[Any, Any, None]], *, name: Optional[str] = None, debug: Optional[bool] = None) -> T: """ Runs ``coroutine(future)`` in a new event loop on a background thread. From e974a37e52a59709a0988872a12c1f01244a8c15 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 11 Oct 2025 15:30:39 +0200 Subject: [PATCH 08/21] Fix test_sf_forced_mates() failing due to ambiguous mate --- test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test.py b/test.py index c120c049f..2ebd357d0 100755 --- a/test.py +++ b/test.py @@ -3079,7 +3079,7 @@ def test_sf_forced_mates(self): for epd in epds: operations = board.set_epd(epd) - result = engine.play(board, chess.engine.Limit(mate=5), game=object()) + result = engine.play(board, chess.engine.Limit(mate=3), game=object()) self.assertIn(result.move, operations["bm"], operations["id"]) @catchAndSkip(FileNotFoundError, "need stockfish") From 8412bd56a282f7fe7071a8b1788b6b791d5e7b0e Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 11 Oct 2025 15:33:30 +0200 Subject: [PATCH 09/21] asyncio.iscoroutinefunction() -> inspect.iscoroutinefunction() --- chess/engine.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/chess/engine.py b/chess/engine.py index 8482c31ba..913940190 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -8,6 +8,7 @@ import copy import dataclasses import enum +import inspect import logging import math import shlex @@ -58,7 +59,7 @@ def run_in_background(coroutine: Callable[[concurrent.futures.Future[T]], Corout The coroutine and all remaining tasks continue running in the background until complete. """ - assert asyncio.iscoroutinefunction(coroutine) + assert inspect.iscoroutinefunction(coroutine) future: concurrent.futures.Future[T] = concurrent.futures.Future() From 624d3a730c180e749ea04a473a828b9c31ff52a4 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 11 Oct 2025 15:38:19 +0200 Subject: [PATCH 10/21] Do not fail-fast matrix jobs that may have interesting results --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6b31bce24..9d0880bb4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,6 +11,7 @@ jobs: matrix: os: [ubuntu-latest, windows-latest] python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + fail-fast: false runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v5 @@ -43,6 +44,7 @@ jobs: matrix: os: [ubuntu-latest, windows-latest] python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + fail-fast: false runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 From 4d9b3bfd860bfa95731d4e208fd98c7c10a15533 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Mon, 27 Oct 2025 21:24:16 +0100 Subject: [PATCH 11/21] Fix Gaviota tables opened as writable (fixes #1166) --- chess/gaviota.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chess/gaviota.py b/chess/gaviota.py index c352a27ca..8beb18d4d 100644 --- a/chess/gaviota.py +++ b/chess/gaviota.py @@ -1663,7 +1663,7 @@ def _open_tablebase(self, req: Request) -> BinaryIO: if stream is None: path = self.available_tables[req.egkey] - stream = open(path, "rb+") + stream = open(path, "rb") self.egtb_loadindexes(req.egkey, stream) self.streams[req.egkey] = stream From f8575f962caf3fcddcf95d935d1d75a1cceb586b Mon Sep 17 00:00:00 2001 From: Jackson Hall Date: Sun, 2 Nov 2025 18:46:03 -0500 Subject: [PATCH 12/21] Add `Board.gives_checkmate()` --- chess/__init__.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/chess/__init__.py b/chess/__init__.py index 84bfa632a..b51c31ca3 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -1980,6 +1980,17 @@ def gives_check(self, move: Move) -> bool: finally: self.pop() + def gives_checkmate(self, move: Move) -> bool: + """ + Probes if the given move would put the opponent in checkmate. The move + must be at least pseudo-legal. + """ + self.push(move) + try: + return self.is_checkmate() + finally: + self.pop() + def is_into_check(self, move: Move) -> bool: king = self.king(self.turn) if king is None: From d59bad55df4b8759e53b0ee8673ec8e69c5f5a82 Mon Sep 17 00:00:00 2001 From: johndoknjas Date: Fri, 2 Jan 2026 10:02:39 -0800 Subject: [PATCH 13/21] Ensure hash is always set after threads. --- chess/engine.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 913940190..72b579dc9 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1875,12 +1875,16 @@ def _parse_uci_bestmove(board: chess.Board, args: str) -> BestMove: return BestMove(move, ponder) -def _chain_config(a: ConfigMapping, b: ConfigMapping) -> Iterator[Tuple[str, ConfigValue]]: - for name, value in a.items(): +def _chain_config(a: ConfigMapping, b: ConfigMapping, with_hash_reordering: bool = True) -> Iterator[Tuple[str, ConfigValue]]: + merged = dict(a) + for k, v in b.items(): + merged.setdefault(k, v) + if with_hash_reordering and 'Hash' in merged and 'Threads' in merged: + hash_val = merged['Hash'] + del merged['Hash'] + merged['Hash'] = hash_val + for name, value in merged.items(): yield name, value - for name, value in b.items(): - if name not in a: - yield name, value class UciOptionMap(MutableMapping[str, T]): From a5bbe3ea49f04b6a153efd278d1d17b073a11fc0 Mon Sep 17 00:00:00 2001 From: johndoknjas Date: Fri, 2 Jan 2026 20:46:19 -0800 Subject: [PATCH 14/21] Remove optional parameter. --- chess/engine.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 72b579dc9..0a9af75d9 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1875,11 +1875,11 @@ def _parse_uci_bestmove(board: chess.Board, args: str) -> BestMove: return BestMove(move, ponder) -def _chain_config(a: ConfigMapping, b: ConfigMapping, with_hash_reordering: bool = True) -> Iterator[Tuple[str, ConfigValue]]: +def _chain_config(a: ConfigMapping, b: ConfigMapping) -> Iterator[Tuple[str, ConfigValue]]: merged = dict(a) for k, v in b.items(): merged.setdefault(k, v) - if with_hash_reordering and 'Hash' in merged and 'Threads' in merged: + if 'Hash' in merged and 'Threads' in merged: hash_val = merged['Hash'] del merged['Hash'] merged['Hash'] = hash_val From a28315bbfe31410120e97aa2f2fe56a19043e242 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Sat, 3 Jan 2026 12:29:26 +0100 Subject: [PATCH 15/21] Comment Hash after Threads --- chess/engine.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index 0a9af75d9..c66bc0c45 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1875,16 +1875,16 @@ def _parse_uci_bestmove(board: chess.Board, args: str) -> BestMove: return BestMove(move, ponder) -def _chain_config(a: ConfigMapping, b: ConfigMapping) -> Iterator[Tuple[str, ConfigValue]]: +def _chain_config(a: ConfigMapping, b: ConfigMapping) -> Iterable[Tuple[str, ConfigValue]]: merged = dict(a) for k, v in b.items(): merged.setdefault(k, v) - if 'Hash' in merged and 'Threads' in merged: - hash_val = merged['Hash'] - del merged['Hash'] - merged['Hash'] = hash_val - for name, value in merged.items(): - yield name, value + if "Hash" in merged and "Threads" in merged: + # Move Hash after Threads, as recommended by Stockfish. + hash_val = merged["Hash"] + del merged["Hash"] + merged["Hash"] = hash_val + return merged.items() class UciOptionMap(MutableMapping[str, T]): From 76cbe9843b7be94676cf19ea2a446e4eb3ac4291 Mon Sep 17 00:00:00 2001 From: winapiadmin <138602885+winapiadmin@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:41:52 +0000 Subject: [PATCH 16/21] Multiple kings per color (was_into_check and more affected) (#1179) * fixed multiple kings for was_into_check * fixed king() behavior on multiple kings minus prev commit * add test cases (very little) * using the precomputed king mask * removed testcase of was_into_check() on multiple kings... ... because it would cause the function to return False (because king() didn't detect any king because of decision) --- chess/__init__.py | 2 +- test.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/chess/__init__.py b/chess/__init__.py index b51c31ca3..347f22ea1 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -916,7 +916,7 @@ def king(self, color: Color) -> Optional[Square]: considered. """ king_mask = self.occupied_co[color] & self.kings & ~self.promoted - return msb(king_mask) if king_mask else None + return msb(king_mask) if king_mask and popcount(king_mask) == 1 else None def attacks_mask(self, square: Square) -> Bitboard: bb_square = BB_SQUARES[square] diff --git a/test.py b/test.py index 2ebd357d0..4927a2b80 100755 --- a/test.py +++ b/test.py @@ -1720,6 +1720,10 @@ def test_impossible_check_due_to_en_passant(self): self.assertFalse(board.has_legal_en_passant()) self.assertEqual(len(list(board.legal_moves)), 2) + def test_multiple_kings(self): + board = chess.Board("KKKK1kkk/8/8/8/8/8/8/8 w - - 0 1") + self.assertEqual(board.king(chess.WHITE), None) + class LegalMoveGeneratorTestCase(unittest.TestCase): From 312f3bf07758628e4ee9befbd9e3df7dd5eccea6 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 13 Feb 2026 22:52:23 +0100 Subject: [PATCH 17/21] Introduce Board._effective_promoted() --- chess/__init__.py | 55 ++++++++++++++++++++++++++++------------------- chess/variant.py | 27 +++++++---------------- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/chess/__init__.py b/chess/__init__.py index 347f22ea1..7fe4cb9cc 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -907,16 +907,19 @@ def color_at(self, square: Square) -> Optional[Color]: else: return None + def _effective_promoted(self) -> Bitboard: + return BB_EMPTY + def king(self, color: Color) -> Optional[Square]: """ - Finds the king square of the given side. Returns ``None`` if there - is no king of that color. + Finds the unique king square of the given side. Returns ``None`` if + there is no king or multiple kings of that color. In variants with king promotions, only non-promoted kings are considered. """ - king_mask = self.occupied_co[color] & self.kings & ~self.promoted - return msb(king_mask) if king_mask and popcount(king_mask) == 1 else None + king_mask = self.occupied_co[color] & self.kings & ~self._effective_promoted() + return msb(king_mask) if king_mask and not king_mask & (king_mask - 1) else None def attacks_mask(self, square: Square) -> Bitboard: bb_square = BB_SQUARES[square] @@ -1135,7 +1138,7 @@ def set_piece_at(self, square: Square, piece: Optional[Piece], promoted: bool = else: self._set_piece_at(square, piece.piece_type, piece.color, promoted) - def board_fen(self, *, promoted: Optional[bool] = False) -> str: + def board_fen(self, *, promoted: Optional[bool] = None) -> str: """ Gets the board FEN (e.g., ``rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR``). @@ -1153,7 +1156,14 @@ def board_fen(self, *, promoted: Optional[bool] = False) -> str: builder.append(str(empty)) empty = 0 builder.append(piece.symbol()) - if promoted and BB_SQUARES[square] & self.promoted: + + if promoted is None: + promoted_mask = self._effective_promoted() + elif promoted: + promoted_mask = self.promoted + else: + promoted_mask = BB_EMPTY + if BB_SQUARES[square] & promoted_mask: builder.append("~") if BB_SQUARES[square] & BB_FILE_H: @@ -1335,7 +1345,7 @@ def chess960_pos(self) -> Optional[int]: return None if self.pawns != BB_RANK_2 | BB_RANK_7: return None - if self.promoted: + if self._effective_promoted(): return None # Piece counts. @@ -2452,12 +2462,12 @@ def push(self, move: Move) -> None: # Update castling rights. self.castling_rights &= ~to_bb & ~from_bb - if piece_type == KING and not promoted: + if piece_type == KING and not self._effective_promoted() & from_bb: if self.turn == WHITE: self.castling_rights &= ~BB_RANK_1 else: self.castling_rights &= ~BB_RANK_8 - elif captured_piece_type == KING and not self.promoted & to_bb: + elif captured_piece_type == KING and not self._effective_promoted() & to_bb: if self.turn == WHITE and square_rank(move.to_square) == RANK_8: self.castling_rights &= ~BB_RANK_8 elif self.turn == BLACK and square_rank(move.to_square) == RANK_1: @@ -3404,8 +3414,8 @@ def _reduces_castling_rights(self, move: Move) -> bool: cr = self.clean_castling_rights() touched = BB_SQUARES[move.from_square] ^ BB_SQUARES[move.to_square] return bool(touched & cr or - cr & BB_RANK_1 and touched & self.kings & self.occupied_co[WHITE] & ~self.promoted or - cr & BB_RANK_8 and touched & self.kings & self.occupied_co[BLACK] & ~self.promoted) + cr & BB_RANK_1 and touched & self.kings & self.occupied_co[WHITE] & ~self._effective_promoted() or + cr & BB_RANK_8 and touched & self.kings & self.occupied_co[BLACK] & ~self._effective_promoted()) def is_irreversible(self, move: Move) -> bool: """ @@ -3459,16 +3469,16 @@ def clean_castling_rights(self) -> Bitboard: black_castling &= (BB_A8 | BB_H8) # The kings must be on e1 or e8. - if not self.occupied_co[WHITE] & self.kings & ~self.promoted & BB_E1: + if not self.occupied_co[WHITE] & self.kings & ~self._effective_promoted() & BB_E1: white_castling = 0 - if not self.occupied_co[BLACK] & self.kings & ~self.promoted & BB_E8: + if not self.occupied_co[BLACK] & self.kings & ~self._effective_promoted() & BB_E8: black_castling = 0 return white_castling | black_castling else: # The kings must be on the back rank. - white_king_mask = self.occupied_co[WHITE] & self.kings & BB_RANK_1 & ~self.promoted - black_king_mask = self.occupied_co[BLACK] & self.kings & BB_RANK_8 & ~self.promoted + white_king_mask = self.occupied_co[WHITE] & self.kings & BB_RANK_1 & ~self._effective_promoted() + black_king_mask = self.occupied_co[BLACK] & self.kings & BB_RANK_8 & ~self._effective_promoted() if not white_king_mask: white_castling = 0 if not black_king_mask: @@ -3506,7 +3516,7 @@ def has_kingside_castling_rights(self, color: Color) -> bool: castling rights. """ backrank = BB_RANK_1 if color == WHITE else BB_RANK_8 - king_mask = self.kings & self.occupied_co[color] & backrank & ~self.promoted + king_mask = self.kings & self.occupied_co[color] & backrank & ~self._effective_promoted() if not king_mask: return False @@ -3527,7 +3537,7 @@ def has_queenside_castling_rights(self, color: Color) -> bool: castling rights. """ backrank = BB_RANK_1 if color == WHITE else BB_RANK_8 - king_mask = self.kings & self.occupied_co[color] & backrank & ~self.promoted + king_mask = self.kings & self.occupied_co[color] & backrank & ~self._effective_promoted() if not king_mask: return False @@ -3600,11 +3610,11 @@ def status(self) -> Status: errors |= STATUS_EMPTY # There must be exactly one king of each color. - if not self.occupied_co[WHITE] & self.kings: + if not self.occupied_co[WHITE] & self.kings & ~self._effective_promoted(): errors |= STATUS_NO_WHITE_KING - if not self.occupied_co[BLACK] & self.kings: + if not self.occupied_co[BLACK] & self.kings & ~self._effective_promoted(): errors |= STATUS_NO_BLACK_KING - if popcount(self.occupied & self.kings) > 2: + if popcount(self.occupied & self.kings & ~self._effective_promoted()) > 2: errors |= STATUS_TOO_MANY_KINGS # There can not be more than 16 pieces of any color. @@ -3638,7 +3648,7 @@ def status(self) -> Status: # More than the maximum number of possible checkers in the variant. checkers = self.checkers_mask() - our_kings = self.kings & self.occupied_co[self.turn] & ~self.promoted + our_kings = self.kings & self.occupied_co[self.turn] & ~self._effective_promoted() if checkers: if popcount(checkers) > 2: errors |= STATUS_TOO_MANY_CHECKERS @@ -3822,7 +3832,7 @@ def generate_castling_moves(self, from_mask: Bitboard = BB_ALL, to_mask: Bitboar return backrank = BB_RANK_1 if self.turn == WHITE else BB_RANK_8 - king = self.occupied_co[self.turn] & self.kings & ~self.promoted & backrank & from_mask + king = self.occupied_co[self.turn] & self.kings & ~self._effective_promoted() & backrank & from_mask king &= -king if not king: return @@ -3879,6 +3889,7 @@ def _to_chess960(self, move: Move) -> Move: def _transposition_key(self) -> Hashable: return (self.pawns, self.knights, self.bishops, self.rooks, self.queens, self.kings, + self._effective_promoted(), self.occupied_co[WHITE], self.occupied_co[BLACK], self.turn, self.clean_castling_rights(), self.ep_square if self.has_legal_en_passant() else None) diff --git a/chess/variant.py b/chess/variant.py index 6e9161dc8..ba4c0f1ce 100644 --- a/chess/variant.py +++ b/chess/variant.py @@ -127,16 +127,8 @@ def is_legal(self, move: chess.Move) -> bool: else: return not any(self.generate_pseudo_legal_captures()) - def _transposition_key(self) -> Hashable: - if self.has_chess960_castling_rights(): - return (super()._transposition_key(), self.kings & self.promoted) - else: - return super()._transposition_key() - - def board_fen(self, *, promoted: Optional[bool] = None) -> str: - if promoted is None: - promoted = self.has_chess960_castling_rights() - return super().board_fen(promoted=promoted) + def _effective_promoted(self) -> chess.Bitboard: + return self.kings & self.promoted if self.castling_rights else chess.BB_EMPTY def status(self) -> chess.Status: status = super().status() @@ -261,9 +253,9 @@ def _push_capture(self, move: chess.Move, capture_square: chess.Square, piece_ty # Destroy castling rights. self.castling_rights &= ~explosion_radius - if explosion_radius & self.kings & self.occupied_co[chess.WHITE] & ~self.promoted: + if explosion_radius & self.kings & self.occupied_co[chess.WHITE] & ~self._effective_promoted(): self.castling_rights &= ~chess.BB_RANK_1 - if explosion_radius & self.kings & self.occupied_co[chess.BLACK] & ~self.promoted: + if explosion_radius & self.kings & self.occupied_co[chess.BLACK] & ~self._effective_promoted(): self.castling_rights &= ~chess.BB_RANK_8 # Explode the capturing piece. @@ -930,9 +922,11 @@ def _is_halfmoves(self, n: int) -> bool: def is_irreversible(self, move: chess.Move) -> bool: return self._reduces_castling_rights(move) + def _effective_promoted(self) -> chess.Bitboard: + return self.promoted & ~self.kings & ~self.pawns + def _transposition_key(self) -> Hashable: return (super()._transposition_key(), - self.promoted, str(self.pockets[chess.WHITE]), str(self.pockets[chess.BLACK])) def legal_drop_squares_mask(self) -> chess.Bitboard: @@ -1009,7 +1003,7 @@ def has_insufficient_material(self, color: chess.Color) -> bool: # a different color complex. return ( chess.popcount(self.occupied) + sum(len(pocket) for pocket in self.pockets) <= 3 and - not self.promoted and + not self._effective_promoted() and not self.pawns and not self.rooks and not self.queens and @@ -1041,11 +1035,6 @@ def set_fen(self, fen: str) -> None: self.pockets[chess.WHITE] = white_pocket self.pockets[chess.BLACK] = black_pocket - def board_fen(self, *, promoted: Optional[bool] = None) -> str: - if promoted is None: - promoted = True - return super().board_fen(promoted=promoted) - def epd(self, shredder: bool = False, en_passant: chess.EnPassantSpec = "legal", promoted: Optional[bool] = None, **operations: Union[None, str, int, float, chess.Move, Iterable[chess.Move]]) -> str: epd = super().epd(shredder=shredder, en_passant=en_passant, promoted=promoted) board_part, info_part = epd.split(" ", 1) From fc50a27fa3cfa07243f78eca93ea6126347ea1fa Mon Sep 17 00:00:00 2001 From: Christopher Akiki Date: Fri, 13 Mar 2026 17:49:05 +0100 Subject: [PATCH 18/21] [MINOR:TYPO] Update pgn.py instanciate -> instantiate --- chess/pgn.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chess/pgn.py b/chess/pgn.py index f40980d48..5ae5b43b0 100644 --- a/chess/pgn.py +++ b/chess/pgn.py @@ -402,7 +402,7 @@ def remove_variation(self, move: Union[int, chess.Move, GameNode]) -> None: def add_variation(self, move: chess.Move, *, comment: Union[str, list[str]] = "", starting_comment: Union[str, list[str]] = "", nags: Iterable[int] = []) -> ChildNode: """Creates a child node with the given attributes.""" - # Instanciate ChildNode only in this method. + # Instantiate ChildNode only in this method. return ChildNode(self, move, comment=comment, starting_comment=starting_comment, nags=nags) def add_main_variation(self, move: chess.Move, *, comment: str = "", nags: Iterable[int] = []) -> ChildNode: From 5e2a2bc153b7646497f3e811f3cfd28aaca1b1ea Mon Sep 17 00:00:00 2001 From: Cady Date: Mon, 30 Mar 2026 16:21:49 -0400 Subject: [PATCH 19/21] Fixed typo in README.rst, issue 1183 --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index a6c3185c6..d4d51a128 100644 --- a/README.rst +++ b/README.rst @@ -314,7 +314,7 @@ If you like, share interesting things you are using python-chess for, for exampl | .. image:: https://github.com/niklasf/python-chess/blob/master/docs/images/clente-chess.png?raw=true | `clente/chess `_ | | :height: 64 | | | :width: 64 | | -| :target: https://github.com/clente/chess | Oppinionated wrapper to use python-chess from the R programming language | +| :target: https://github.com/clente/chess | Opinionated wrapper to use python-chess from the R programming language | +------------------------------------------------------------------------------------------------------+----------------------------------------------------------------------------------------------+ | .. image:: https://github.com/niklasf/python-chess/blob/master/docs/images/crazyara.png?raw=true | https://crazyara.org/ | | :height: 64 | | From efb8b4278c85e0145b29b867de03dc715456dd86 Mon Sep 17 00:00:00 2001 From: Litschi Date: Fri, 27 Mar 2026 19:31:11 +0100 Subject: [PATCH 20/21] feat: added piece_count function instead of chess.popcount(board.occupied) --- CHANGELOG.rst | 9 +++++++++ chess/__init__.py | 3 +++ chess/gaviota.py | 8 ++++---- chess/syzygy.py | 10 +++++----- test.py | 2 +- 5 files changed, 22 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 03a9555d1..212db89b1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,15 @@ Changelog for python-chess ========================== +New in unreleased (27th Mar 2026) +--------------------------------- + +Bugfixes: +* Fixed typo in README.rst. + +Changes: +* Added ``board.piece_count`` function. + New in v1.11.2 (25th Feb 2025) ------------------------------ diff --git a/chess/__init__.py b/chess/__init__.py index 7fe4cb9cc..12b249bd5 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -841,6 +841,9 @@ def clear_board(self) -> None: :class:`~chess.Board` also clears the move stack. """ self._clear_board() + + def piece_count(self) -> int: + return popcount(self.occupied) def pieces_mask(self, piece_type: PieceType, color: Color) -> Bitboard: if piece_type == PAWN: diff --git a/chess/gaviota.py b/chess/gaviota.py index 8beb18d4d..7152a18f0 100644 --- a/chess/gaviota.py +++ b/chess/gaviota.py @@ -1519,8 +1519,8 @@ def probe_dtm(self, board: chess.Board) -> int: raise KeyError(f"gaviota tables do not contain positions with castling rights: {board.fen()}") # Supports only up to 5 pieces. - if chess.popcount(board.occupied) > 5: - raise KeyError(f"gaviota tables support up to 5 pieces, not {chess.popcount(board.occupied)}: {board.fen()}") + if board.piece_count() > 5: + raise KeyError(f"gaviota tables support up to 5 pieces, not {board.piece_count()}: {board.fen()}") # KvK is a draw. if board.occupied == board.kings: @@ -1885,8 +1885,8 @@ def _probe_hard(self, board: chess.Board, wdl_only: bool = False) -> int: if board.castling_rights: raise KeyError(f"gaviota tables do not contain positions with castling rights: {board.fen()}") - if chess.popcount(board.occupied) > 5: - raise KeyError(f"gaviota tables support up to 5 pieces, not {chess.popcount(board.occupied)}: {board.fen()}") + if board.piece_count() > 5: + raise KeyError(f"gaviota tables support up to 5 pieces, not {board.piece_count()}: {board.fen()}") stm = ctypes.c_uint(0 if board.turn == chess.WHITE else 1) ep_square = ctypes.c_uint(board.ep_square if board.ep_square else 64) diff --git a/chess/syzygy.py b/chess/syzygy.py index 0c6b7822c..2250db5b5 100644 --- a/chess/syzygy.py +++ b/chess/syzygy.py @@ -1548,8 +1548,8 @@ def probe_wdl_table(self, board: chess.Board) -> int: try: table = typing.cast(WdlTable, self.wdl[key]) except KeyError: - if chess.popcount(board.occupied) > TBPIECES: - raise KeyError(f"syzygy tables support up to {TBPIECES} pieces, not {chess.popcount(board.occupied)}: {board.fen()}") + if board.piece_count() > TBPIECES: + raise KeyError(f"syzygy tables support up to {TBPIECES} pieces, not {board.piece_count()}: {board.fen()}") raise MissingTableError(f"did not find wdl table {key}") self._bump_lru(table) @@ -1567,8 +1567,8 @@ def probe_ab(self, board: chess.Board, alpha: int, beta: int, threats: bool = Fa # positions that have more pieces than the maximum number of supported # pieces. We artificially limit this to one additional level, to # make sure search remains somewhat bounded. - if chess.popcount(board.occupied) > TBPIECES + 1: - raise KeyError(f"syzygy tables support up to {TBPIECES} pieces, not {chess.popcount(board.occupied)}: {board.fen()}") + if board.piece_count() > TBPIECES + 1: + raise KeyError(f"syzygy tables support up to {TBPIECES} pieces, not {board.piece_count()}: {board.fen()}") # Special case: Variant with compulsory captures. if self.variant.captures_compulsory: @@ -1613,7 +1613,7 @@ def sprobe_ab(self, board: chess.Board, alpha: int, beta: int, threats: bool = F threats_found = False - if threats or chess.popcount(board.occupied) >= 6: + if threats or board.piece_count() >= 6: for threat in board.generate_legal_moves(~board.pawns): board.push(threat) try: diff --git a/test.py b/test.py index 4927a2b80..228c62f3f 100755 --- a/test.py +++ b/test.py @@ -1220,7 +1220,7 @@ def test_clear(self): self.assertFalse(board.ep_square) self.assertFalse(board.piece_at(chess.E1)) - self.assertEqual(chess.popcount(board.occupied), 0) + self.assertEqual(board.piece_count(), 0) def test_threefold_repetition(self): board = chess.Board() From a345dbd131fb5cbcbffdc9e50901d359480926c5 Mon Sep 17 00:00:00 2001 From: Niklas Fiekas Date: Fri, 3 Apr 2026 21:24:06 +0200 Subject: [PATCH 21/21] Document board.piece_count() --- CHANGELOG.rst | 9 --------- chess/__init__.py | 7 ++++++- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 212db89b1..03a9555d1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,15 +1,6 @@ Changelog for python-chess ========================== -New in unreleased (27th Mar 2026) ---------------------------------- - -Bugfixes: -* Fixed typo in README.rst. - -Changes: -* Added ``board.piece_count`` function. - New in v1.11.2 (25th Feb 2025) ------------------------------ diff --git a/chess/__init__.py b/chess/__init__.py index 12b249bd5..9ea44f36e 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -841,8 +841,13 @@ def clear_board(self) -> None: :class:`~chess.Board` also clears the move stack. """ self._clear_board() - + def piece_count(self) -> int: + """ + Gets the number of pieces on the board. + + Does not include Crazyhouse pockets. + """ return popcount(self.occupied) def pieces_mask(self, piece_type: PieceType, color: Color) -> Bitboard: