diff --git a/.editorconfig b/.editorconfig index df0b5dd62..b5391e3e7 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,3 +1,5 @@ +# https://editorconfig.org/ + root = true [*] diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..7e1e93611 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: niklasf diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..0d23a8f03 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: '/' + schedule: + interval: monthly diff --git a/.github/workflows/setup-ubuntu-latest.sh b/.github/workflows/setup-ubuntu-latest.sh new file mode 100755 index 000000000..10b8d1115 --- /dev/null +++ b/.github/workflows/setup-ubuntu-latest.sh @@ -0,0 +1,17 @@ +#!/bin/sh -e + +# Stockfish +sudo apt-get install -y stockfish + +# Crafty +sudo apt-get install -y crafty + +# Fairy-stockfish +sudo apt-get install -y fairy-stockfish + +# Gaviota libgtb +git clone https://github.com/michiguel/Gaviota-Tablebases.git --depth 1 +cd Gaviota-Tablebases +make +echo "LD_LIBRARY_PATH=`pwd`:${LD_LIBRARY_PATH}" >> $GITHUB_ENV +cd .. diff --git a/.github/workflows/setup-windows-latest.sh b/.github/workflows/setup-windows-latest.sh new file mode 100755 index 000000000..48037cbef --- /dev/null +++ b/.github/workflows/setup-windows-latest.sh @@ -0,0 +1,16 @@ +#!/bin/sh -e + +echo Download stockfish ... +choco install wget +wget https://github.com/official-stockfish/Stockfish/releases/download/sf_16/stockfish-windows-x86-64-avx2.zip + +echo Unzip .. +7z e stockfish-windows-x86-64-avx2.zip stockfish/stockfish-windows-x86-64-avx2.exe + +echo Setup path ... +mv stockfish-windows-x86-64-avx2.exe stockfish.exe +pwd >> $GITHUB_PATH + +echo Download fairy-stockfish ... +wget https://github.com/fairy-stockfish/Fairy-Stockfish/releases/latest/download/fairy-stockfish-largeboard_x86-64.exe +mv fairy-stockfish-largeboard_x86-64.exe fairy-stockfish.exe diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..9d0880bb4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,72 @@ +name: Test + +on: [push, pull_request] + +permissions: + contents: read + +jobs: + test: + strategy: + 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 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - run: .github/workflows/setup-${{ matrix.os }}.sh + shell: bash + - run: pip install -e . + - run: python test.py -v + perft: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + 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 + - run: python examples/perft/perft.py -t 1 examples/perft/tricky.perft + - run: python examples/perft/perft.py -t 1 --variant giveaway examples/perft/giveaway.perft + - run: python examples/perft/perft.py -t 1 --variant atomic examples/perft/atomic.perft + - run: python examples/perft/perft.py -t 1 --variant racingkings examples/perft/racingkings.perft + - run: python examples/perft/perft.py -t 1 --variant horde examples/perft/horde.perft + - run: python examples/perft/perft.py -t 1 --variant crazyhouse examples/perft/crazyhouse.perft + - run: python examples/perft/perft.py -t 1 --variant 3check examples/perft/3check.perft + typing: + strategy: + 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 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - run: pip install -e . + - run: pip install mypy + - run: python -m mypy --strict chess + - run: python -m mypy --strict examples/**/*.py + - run: pip install pyright + - run: python -m pyright chess + - run: python -m pyright examples/**/*.py + readme: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + 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 . + - run: .github/workflows/setup-ubuntu-latest.sh + - run: python -m doctest README.rst diff --git a/.gitignore b/.gitignore index 80d4fd61a..076e4fcff 100644 --- a/.gitignore +++ b/.gitignore @@ -6,10 +6,11 @@ venv/ .coveralls.yml nosetests.xml .tox +.mypy_cache dist/ build/ -python_chess.egg-info/ +*.egg-info/ docs/_build/ data/gaviota/*.gtb.cp4 @@ -18,4 +19,6 @@ data/syzygy/suicide/*.stb[wz] data/syzygy/atomic/*.atb[wz] data/syzygy/giveaway/*.[gs]tb[wz] +fuzz/corpus + release-v*.txt diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 000000000..dbe71809a --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,15 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.10" + +sphinx: + configuration: docs/conf.py + +python: + install: + - method: pip + path: . + - requirements: docs/requirements.txt diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index f33637f0e..000000000 --- a/.travis.yml +++ /dev/null @@ -1,91 +0,0 @@ -dist: xenial -language: python -sudo: false -python: - - "3.4" - - "3.5" - - "3.6" - - "3.7" -matrix: - include: - - python: "pypy3" - dist: trusty - - python: "3.7" - env: PERFT=1 -cache: - directories: - - data/gaviota - - data/syzygy/suicide -before_install: - - # Stockfish - - wget https://stockfish.s3.amazonaws.com/stockfish-8-linux.zip - - unzip stockfish-8-linux.zip - - mkdir -p bin - - cp stockfish-8-linux/Linux/stockfish_8_x64 bin/stockfish - - export PATH="`pwd`/bin:${PATH}" - - which stockfish || (echo $PATH && false) - - # Crafty - - git clone https://github.com/lazydroid/crafty-chess - - cd crafty-chess - - make unix-gcc - - export PATH="`pwd`:${PATH}" - - cd .. - - # Gaviota libgtb - - git clone https://github.com/michiguel/Gaviota-Tablebases.git --depth 1 - - cd Gaviota-Tablebases - - make - - export LD_LIBRARY_PATH="`pwd`:${LD_LIBRARY_PATH}" - - cd .. - - # Gaviota tablebases - - cd data/gaviota - - wget --no-verbose --no-check-certificate --no-clobber --input-file TEST-SOURCE.txt - - cd ../.. - - # Suicide syzygy bases - - cd data/syzygy/suicide - - wget --no-verbose --no-check-certificate --no-clobber --input-file TEST-SOURCE.txt - - cd ../../.. -install: - - pip install --upgrade pip wheel - - pip install --upgrade setuptools - - pip install coverage coveralls - - pip install -e .[test] -script: - - # Unit tests - - if [[ $PERFT -ne 1 ]]; then coverage erase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess test.py -vv SquareTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv MoveTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv PieceTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv BoardTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv LegalMoveGeneratorTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv BaseBoardTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv SquareSetTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv PolyglotTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv PgnTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv SyzygyTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv NativeGaviotaTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv GaviotaTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv SvgTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv SuicideTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv AtomicTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv RacingKingsTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv HordeTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv ThreeCheckTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv CrazyhouseTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv GiveawayTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv UciOptionMapTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv UciEngineTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv CraftyTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv StockfishTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv SpurEngineTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append test.py -vv XboardEngineTestCase; fi - - if [[ $PERFT -ne 1 ]]; then coverage run --source chess --append -m doctest README.rst --verbose; fi - - echo Unit tests complete - - if [[ $PERFT -ne 1 ]]; then coveralls || [[ $? -eq 139 ]]; fi - - # Perft tests - - if [[ $PERFT -eq 1 ]]; then python examples/perft/perft.py -t 1 examples/perft/random.perft --max-nodes 10000; fi - - if [[ $PERFT -eq 1 ]]; then python examples/perft/perft.py -t 1 examples/perft/tricky.perft; fi - - if [[ $PERFT -eq 1 ]]; then python examples/perft/perft.py -t 1 --variant giveaway examples/perft/giveaway.perft; fi - - if [[ $PERFT -eq 1 ]]; then python examples/perft/perft.py -t 1 --variant atomic examples/perft/atomic.perft; fi - - if [[ $PERFT -eq 1 ]]; then python examples/perft/perft.py -t 1 --variant racingkings examples/perft/racingkings.perft; fi - - if [[ $PERFT -eq 1 ]]; then python examples/perft/perft.py -t 1 --variant horde examples/perft/horde.perft; fi - - if [[ $PERFT -eq 1 ]]; then python examples/perft/perft.py -t 1 --variant crazyhouse examples/perft/crazyhouse.perft; fi diff --git a/CHANGELOG-OLD.rst b/CHANGELOG-OLD.rst new file mode 100644 index 000000000..bebcba4d0 --- /dev/null +++ b/CHANGELOG-OLD.rst @@ -0,0 +1,1887 @@ +Old Changelog for python-chess up to 1.0.0 +========================================== + +New in v1.0.0 (24th Sep 2020) +----------------------------- + +Changes: + +* Now requires Python 3.7+. +* `chess.engine` will now cut off illegal principal variations at the first + illegal move instead of discarding them entirely. +* `chess.engine.EngineProtocol` renamed to `chess.engine.Protocol`. +* `chess.engine.Option` is no longer a named tuple. +* Renamed `chess.gaviota` internals. +* Relaxed type annotations of `chess.pgn.GameNode.variation()` and related + methods. +* Changed default colors of `chess.svg.Arrow` and + `chess.pgn.GameNode.arrows()`. These can be overriden with the new + `chess.svg.board(..., colors)` feature. +* Documentation improvements. Will now show type aliases like `chess.Square` + instead of `int`. + +Bugfixes: + +* Fix insufficient material with same-color bishops on both sides. +* Clarify that `chess.Board.can_claim_draw()` and related methods refer to + claims by the player to move. Three-fold repetition could already be claimed + before making the final repeating move. `chess.Board.can_claim_fifty_moves()` + now also allows a claim before the final repeating move. The previous + behavior is `chess.Board.is_fifty_moves()`. +* Fix parsing of green arrows/circles in `chess.pgn.GameNode.arrows()`. +* Fix overloaded type signature of `chess.engine.Protocol.engine()`. + +New features: + +* Added `chess.parse_square()`, to be used instead of + `chess.SQUARE_NAMES.index()`. +* Added `chess.Board.apply_mirror()`. +* Added `chess.svg.board(..., colors)`, to allow overriding the default theme. + +New in v0.31.4 (9th Aug 2020) +----------------------------- + +Bugfixes: + +* Fix inconsistency where `board.is_legal()` was not accepting castling moves + in Chess960 notation (when board is in standard mode), while all other + methods did. +* Fix `chess.pgn.GameNode.set_clock()` with negative or floating point values. +* Avoid leading and trailing spaces in PGN comments when setting annotations. + +New features: + +* Finish typing and declare support for mypy. + +New in v0.31.3 (18th Jul 2020) +------------------------------ + +Bugfixes: + +* Custom castling rights assigned to `board.castling_rights` or castling rights + left over after `Board.set_board_fen()` were not correctly cleaned after + the first move. + +Changes: + +* Ignore up to one consecutive empty line between PGN headers. +* Added PGN Variant `From Position` as an alias for standard chess. +* `chess.pgn.FileExporter.result()` now returns the number of written + characters. +* `chess.engine` now avoids sending 0 for search limits, which some engines + misunderstand as no limit. +* `chess.engine` better handles null moves sent to the engine. +* `chess.engine` now gracefully handles `NULL` ponder moves and uppercase + moves received from UCI engines, which is technically invalid. + +New features: + +* Added `chess.pgn.GameNode.{clock, set_clock}()` to read and write + `[%clk ...]` **PGN annotations**. +* Added `chess.pgn.GameNode.{arrows, set_arrows}()` to read and write + `[%csl ...]` and `[%cal ...]` PGN annotations. +* Added `chess.pgn.GameNode.{eval, set_eval}()` to read and write + `[%eval ...]` PGN annotations. +* Added `SquareSet.ray(a, b)` and `SquareSet.between(a, b)`. + +New in v0.31.2 (2nd Jun 2020) +----------------------------- + +Bugfixes: + +* Fix rejected/accepted in `chess.engine.XBoardProtocol`. +* Misc typing fixes. + +Changes: + +* Deprecated `chess.syzygy.is_table_name()`. Replaced with + `chess.syzygy.is_tablename()` which has additional parameters and defaults to + `one_king`. +* Take advantage of `int.bit_count()` coming in Python 3.10. + +New in v0.31.1 (5th May 2020) +----------------------------- + +Bugfixes: + +* `RacingKingsBoard.is_variant_win()` no longer incorrectly returns `True` + for drawn positions. +* Multiple moves for EPD opcodes *am* and *bm* are now sorted as required by + the specification. +* Coordinates of SVG boards are now properly aligned, even when rendered as + SVG Tiny. + +Changes: + +* SVG boards now have a background color for the coordinate margin, making + coordinates readable on dark backgrounds. +* Added *[Variant "Illegal"]* as an alias for standard chess + (used by Chessbase). + +Features: + +* Added `Board.find_move()`, useful for finding moves that match human input. + +New in v0.31.0 (21st Apr 2020) +------------------------------ + +Changes: + +* Replaced lookup table `chess.BB_BETWEEN[a][b]` with a function + `chess.between(a, b)`. Improves initialization and runtime performance. +* `chess.pgn.BaseVisitor.result()` is now an abstract method, forcing + subclasses to implement it. +* Removed helper attributes from `chess.engine.InfoDict`. Instead it is now + a `TypedDict`. +* `chess.engine.PovScore` equality is now semantic instead of structural: + Scores compare equal to the negative score from the opposite point of view. + +Bugfixes: + +* `chess.Board.is_irreversible()` now considers ceding legal en passant + captures as irreversible. Also documented that false-negatives due to forced + lines are by design. +* Fixed stack overflow in `chess.pgn` when exporting, visiting or getting the + final board of a very long game. +* Clarified documentation regarding board validity. +* `chess.pgn.GameNode.__repr__()` no longer errors if the root node has invalid + FEN or Variant headers. +* Carriage returns are no longer allowed in PGN header values, fixing + reparsability. +* Fixed type error when XBoard name or egt features have a value that looks + like an integer. +* `chess.engine` is now passing type checks with mypy. +* `chess.gaviota` is now passing type checks with mypy. + +Features: + +* Added `chess.Board.gives_check()`. +* `chess.engine.AnalysisResult.wait()` now returns `chess.engine.BestMove`. +* Added `empty_square` parameter for `chess.Board.unicode()` with better + aligned default (⭘). + +New in v0.30.1 (18th Jan 2020) +------------------------------ + +Changes: + +* Positions with more than two checkers are considered invalid and + `board.status()` returns `chess.STATUS_TOO_MANY_CHECKERS`. +* Pawns drops in Crazyhouse are considered zeroing and reset + `board.halfmove_clock` when played. +* Now validating file sizes when opening Syzygy tables and Polyglot opening + books. +* Explicitly warn about untrusted tablebase files and chess engines. + +Bugfixes: + +* Fix Racing Kings game end detection: Black cannot catch up if their own + pieces block the goal. White would win on the next turn, so this did not + impact the game theoretical outcome of the game. +* Fix bugs discovered by fuzzing the EPD parser: Fixed serialization of + empty strings, reparsability of empty move lists, handling of non-finite + floats, and handling of whitespace in opcodes. + +Features: + +* Added `board.checkers()`, returning a set of squares with the pieces giving + check. + +New in v0.30.0 (1st Jan 2020) +----------------------------- + +Changes: + +* **Dropped support for Python 3.5.** +* Remove explicit loop arguments in `chess.engine` module, following + https://bugs.python.org/issue36373. + +Bugfixes: + +* `chess.engine.EngineProtocol.returncode` is no longer poisoned when + `EngineProtocol.quit()` times out. +* `chess.engine.PlayResult.info` was not always of type + `chess.engine.InfoDict`. + +Features: + +* The background thread spawned by `chess.engine.SimpleEngine` is now named + for improved debuggability, revealing the PID of the engine process. +* `chess.engine.EventLoopPolicy` now supports `asyncio.PidfdChildWatcher` + when running on Python 3.9+ and Linux 5.3+. +* Add `chess.Board.san_and_push()`. + +New in v0.29.0 (2nd Dec 2019) +----------------------------- + +Changes: + +* `chess.variant.GiveawayBoard` **now starts with castling rights**. + `chess.variant.AntichessBoard` is the same variant without castling rights. +* UCI info parser no longer reports errors when encountering unknown tokens. +* Performance improvements for repetition detection. +* Since Python 3.8: `chess.syzygy`/`chess.polyglot` use `madvise(MADV_RANDOM)` + to prepare table/book files for random access. + +Bugfixes: + +* Fix syntax error in type annotation of `chess.engine.run_in_background()`. +* Fix castling rights when king is exploded in Atomic. Mitigated by the fact + that the game is over and that it did not affect FEN. +* Fix insufficient material with underpromoted pieces in Crazyhouse. Mitigated + by the fact that affected positions are unreachable in Crazyhouse. + +Features: + +* Support `wdl` in UCI info (usually activated with `UCI_ShowWDL`). + +New in v0.28.3 (3rd Sep 2019) +----------------------------- + +Bugfixes: + +* Follow FICS rules in Atomic castling edge cases. +* Handle self-reported errors by XBoard engines "Error: ..." or + "Illegal move: ...". + +New in v0.28.2 (25th Jul 2019) +------------------------------ + +Bugfixes: + +* Fixed exception propagation, when a UCI engine sends an invalid `bestmove`. + Thanks @fsmosca. + +Changes: + +* `chess.Move.from_uci()` no longer accepts moves from and to the same square, + for example `a1a1`. `0000` is now the only valid null move notation. + +New in v0.28.1 (25th May 2019) +------------------------------ + +Bugfixes: + +* The minimum Python version is 3.5.3 (instead of 3.5.0). +* Fix `board.is_irreversible()` when capturing a rook that had castling rights. + +Changes: + +* `is_en_passant()`, `is_capture()`, `is_zeroing()`, `is_irreversible()`, + `is_castling()`, `is_kingside_castling()` and `is_queenside_castling()` + now consistently return `False` for null moves. +* Added `chess.engine.InfoDict` class with typed shorthands for common keys. +* Support `[Variant "3-check"]` (from chess.com PGNs). + +New in v0.28.0 (20th May 2019) +------------------------------ + +Changes: + +* Dropped support for Python 3.4 (end of life reached). +* `chess.polyglot.Entry.move` **is now a property instead of a method**. + The raw move is now always decoded in the context of the position (relevant + for castling moves). +* `Piece`, `Move`, `BaseBoard` and `Board` comparisons no longer support + duck typing. +* FENs sent to engines now always include potential en passant squares, even if + no legal en passant capture exists. +* Circular SVG arrows now have a `circle` CSS class. +* Superfluous dashes (-) in EPDs are no longer treated as opcodes. +* Removed `GameCreator`, `HeaderCreator` and `BoardCreator` aliases for + `{Game,Headers,Board}Builder`. + +Bugfixes: + +* Notation like `Kh1` is no longer accepted for castling moves. +* Remove stale files from wheels published on PyPI. +* Parsing Three-Check EPDs with moves was always failing. +* Some methods in `chess.variant` were returning bool-ish integers, when they + should have returned `bool`. +* `chess.engine`: Fix line decoding when Windows line-endings arrive seperately + in stdout buffer. +* `chess.engine`: Survive timeout in analysis. +* `chess.engine`: Survive unexpected `bestmove` sent by misbehaving UCI engines. + +New features: + +* **Experimental type signatures for almost all public APIs** (`typing`). + Some modules do not yet internally pass typechecking. +* Added `Board.color_at(square)`. +* Added `chess.engine.AnalysisResult.get()` and `empty()`. +* `chess.engine`: The `UCI_AnalyseMode` option is still automatically managed, + but can now be overwritten. +* `chess.engine.EngineProtocol` and constructors now optionally take + an explicit `loop` argument. + +New in v0.27.3 (21st Mar 2019) +------------------------------ + +Changes: + +* `XBoardProtocol` will no longer raise an exception when the engine resigned. + Instead it sets a new flag `PlayResult.resigned`. `resigned` and + `draw_offered` are keyword-only arguments. +* Renamed `chess.pgn.{Game,Header,Board}Creator` to + `{Game,Headers,Board}Builder`. Aliases kept in place. + +Bugfixes: + +* Make `XBoardProtocol` robust against engines that send a move after claiming + a draw or resigning. Thanks @pascalgeo. +* `XBoardProtocol` no longer ignores `Hint:` sent by the engine. +* Fix handling of illegal moves in `XBoardProtocol`. +* Fix exception when engine is shut down while pondering. +* Fix unhandled internal exception and file descriptor leak when engine + initialization fails. +* Fix `HordeBoard.status()` when black pieces are on the first rank. + Thanks @Wisling. + +New features: + +* Added `chess.pgn.Game.builder()`, `chess.pgn.Headers.builder()` and + `chess.pgn.GameNode.dangling_node()` to simplify subclassing `GameNode`. +* `EngineProtocol.communicate()` is now also available in the synchronous API. + +New in v0.27.2 (16th Mar 2019) +------------------------------ + +Bugfixes: + +* `chess.engine.XBoardProtocol.play()` was searching 100 times longer than + intended when using `chess.engine.Limit.time`, and searching 100 times more + nodes than intended when using `chess.engine.Limit.nodes`. Thanks @pascalgeo. + +New in v0.27.1 (15th Mar 2019) +------------------------------ + +Bugfixes: + +* `chess.engine.XBoardProtocol.play()` was raising `KeyError` when using time + controls with increment or remaining moves. Thanks @pascalgeo. + +New in v0.27.0 (14th Mar 2019) +------------------------------ + +This is the second **release candidate for python-chess 1.0**. If you see the +need for breaking changes, please speak up now! + +Bugfixes: + +* `EngineProtocol.analyse(*, multipv)` was not passing this argument to the + engine and therefore only returned the first principal variation. + Thanks @svangordon. +* `chess.svg.board(*, squares)`: The X symbol on selected squares is now more + visible when it overlaps pieces. + +Changes: + +* **FEN/EPD parsing is now more relaxed**: Incomplete FENs and EPDs are + completed with reasonable defaults (`w - - 0 1`). The EPD parser accepts + fields with moves in UCI notation (for example the technically invalid + `bm g1f3` instead of `bm Nf3`). +* The PGN parser now skips games with invalid FEN headers and variations after + an illegal move (after handling the error as usual). + +New features: + +* Added `Board.is_repetition(count=3)`. +* Document that `chess.engine.EngineProtocol` is compatible with + AsyncSSH 1.16.0. + +New in v0.26.0 (19th Feb 2019) +------------------------------ + +This is the first **release candidate for python-chess 1.0**. If you see the +need for breaking changes, please speak up now! + +Changes: + +* `chess.engine` **is now stable and replaces** + `chess.uci` **and** `chess.xboard`. +* Advanced: `EngineProtocol.initialize()` is now public for use with custom + transports. +* Removed `__ne__` implementations (not required since Python 3). +* Assorted documentation and coding-style improvements. + +New features: + +* Check insufficient material for a specific side: + `board.has_insufficient_material(color)`. +* Copy boards with limited stack depth: `board.copy(stack=depth)`. + +Bugfixes: + +* Properly handle delayed engine errors, for example unsupported options. + +New in v0.25.1 (24th Jan 2019) +------------------------------ + +Bugfixes: + +* `chess.engine` did not correctly handle Windows-style line endings. + Thanks @Bstylestuff. + +New in v0.25.0 (18th Jan 2019) +------------------------------ + +New features: + +* This release introduces a new **experimental API for chess engine + communication**, `chess.engine`, based on `asyncio`. It is intended to + eventually replace `chess.uci` and `chess.xboard`. + +Bugfixes: + +* Fixed race condition in LRU-cache of open Syzygy tables. The LRU-cache is + enabled by default (*max_fds*). +* Fix deprecation warning and unclosed file in setup.py. + Thanks Mickaël Schoentgen. + +Changes: + +* `chess.pgn.read_game()` now ignores BOM at the start of the stream. +* Removed deprecated items. + +New in v0.24.2 (5th Jan 2019) +----------------------------- + +Bugfixes: + +* `CrazyhouseBoard.root()` and `ThreeCheckBoard.root()` were not returning the + correct pockets and number of remaining checks, respectively. Thanks @gbtami. +* `chess.pgn.skip_game()` now correctly skips PGN comments that contain + line-breaks and PGN header tag notation. + +Changes: + +* Renamed `chess.pgn.GameModelCreator` to `GameCreator`. Alias kept in place + and will be removed in a future release. +* Renamed `chess.engine` to `chess._engine`. Use re-exports from `chess.uci` + or `chess.xboard`. +* Renamed `Board.stack` to `Board._stack`. Do not use this directly. +* Improved memory usage: `Board.legal_moves` and `Board.pseudo_legal_moves` + no longer create reference cycles. PGN visitors can manage headers + themselves. +* Removed previously deprecated items. + +Features: + +* Added `chess.pgn.BaseVisitor.visit_board()` and `chess.pgn.BoardCreator`. + +New in v0.24.1, v0.23.11 (7th Dec 2018) +--------------------------------------- + +Bugfixes: + +* Fix `chess.Board.set_epd()` and `chess.Board.from_epd()` with semicolon + in string operand. Thanks @jdart1. +* `chess.pgn.GameNode.uci()` was always raising an exception. + Also included in v0.24.0. + +New in v0.24.0 (3rd Dec 2018) +----------------------------- + +This release **drops support for Python 2**. The *0.23.x* branch will be +maintained for one more month. + +Changes: + +* **Require Python 3.4.** Thanks @hugovk. +* No longer using extra pip features: + `pip install python-chess[engine,gaviota]` is now `pip install python-chess`. +* Various keyword arguments can now be used as **keyword arguments only**. +* `chess.pgn.GameNode.accept()` now + **also visits the move leading to that node**. +* `chess.pgn.GameModelCreator` now requires that `begin_game()` be called. +* `chess.pgn.scan_headers()` and `chess.pgn.scan_offsets()` have been removed. + Instead the new functions `chess.pgn.read_headers()` and + `chess.pgn.skip_game()` can be used for a similar purpose. +* `chess.syzygy`: Invalid magic headers now raise `IOError`. Previously they + were only checked in an assertion. + `type(board).{tbw_magic,tbz_magic,pawnless_tbw_magic,pawnless_tbz_magic}` + are now byte literals. +* `board.status()` constants (`STATUS_`) are now typed using `enum.IntFlag`. + Values remain unchanged. +* `chess.svg.Arrow` is no longer a `namedtuple`. +* `chess.PIECE_SYMBOLS[0]` and `chess.PIECE_NAMES[0]` are now `None` instead + of empty strings. +* Performance optimizations: + + * `chess.pgn.Game.from_board()`, + * `chess.square_name()` + * Replace `collections.deque` with lists almost everywhere. + +* Renamed symbols (aliases will be removed in the next release): + + * `chess.BB_VOID` -> `BB_EMPTY` + * `chess.bswap()` -> `flip_vertical()` + * `chess.pgn.GameNode.main_line()` -> `mainline_moves()` + * `chess.pgn.GameNode.is_main_line()` -> `is_mainline()` + * `chess.variant.BB_HILL` -> `chess.BB_CENTER` + * `chess.syzygy.open_tablebases()` -> `open_tablebase()` + * `chess.syzygy.Tablebases` -> `Tablebase` + * `chess.syzygy.Tablebase.open_directory()` -> `add_directory()` + * `chess.gaviota.open_tablebases()` -> `open_tablebase()` + * `chess.gaviota.open_tablebases_native()` -> `open_tablebase_native()` + * `chess.gaviota.NativeTablebases` -> `NativeTablebase` + * `chess.gaviota.PythonTablebases` -> `PythonTablebase` + * `chess.gaviota.NativeTablebase.open_directory()` -> `add_directory()` + * `chess.gaviota.PythonTablebase.open_directory()` -> `add_directory()` + +Bugfixes: + +* The PGN parser now gives the visitor a chance to handle unknown chess + variants and continue parsing. +* `chess.pgn.GameNode.uci()` was always raising an exception. + +New features: + +* `chess.SquareSet` now extends `collections.abc.MutableSet` and can be + initialized from iterables. +* `board.apply_transform(f)` and `board.transform(f)` can apply bitboard + transformations to a position. Examples: + `chess.flip_{vertical,horizontal,diagonal,anti_diagonal}`. +* `chess.pgn.GameNode.mainline()` iterates over nodes of the mainline. + Can also be used with `reversed()`. Reversal is now also supported for + `chess.pgn.GameNode.mainline_moves()`. +* `chess.svg.Arrow(tail, head, color="#888")` gained an optional *color* + argument. +* `chess.pgn.BaseVisitor.parse_san(board, san)` is used by parsers and can + be overwritten to deal with non-standard input formats. +* `chess.pgn`: Visitors can advise the parser to skip games or variations by + returning the special value `chess.pgn.SKIP` from `begin_game()`, + `end_headers()` or `begin_variation()`. This is only a hint. + The corresponding `end_game()` or `end_variation()` will still be called. +* Added `chess.svg.MARGIN`. + +New in v0.23.10 (31st Oct 2018) +------------------------------- + +Bugfixes: + +* `chess.SquareSet` now correctly handles negative masks. Thanks @hasnul. +* `chess.pgn` now accepts `[Variant "chess 960"]` (with the space). + +New in v0.23.9 (4th Jul 2018) +----------------------------- + +Changes: + +* Updated `Board.is_fivefold_repetition()`. FIDE rules have changed and the + repetition no longer needs to occur on consecutive alternating moves. + Thanks @LegionMammal978. + +New in v0.23.8 (1st Jul 2018) +----------------------------- + +Bugfixes: + +* `chess.syzygy`: Correctly initialize wide DTZ map for experimental 7 piece + table KRBBPvKQ. + +New in v0.23.7 (26th Jun 2018) +------------------------------ + +Bugfixes: + +* Fixed `ThreeCheckBoard.mirror()` and `CrazyhouseBoard.mirror()`, which + were previously resetting remaining checks and pockets respectively. + Thanks @QueensGambit. + +Changes: + +* `Board.move_stack` is now guaranteed to be UCI compatible with respect to + the representation of castling moves and `board.chess960`. +* Drop support for Python 3.3, which is long past end of life. +* `chess.uci`: The `position` command now manages `UCI_Chess960` and + `UCI_Variant` automatically. +* `chess.uci`: The `position` command will now always send the entire history + of moves from the root position. +* Various coding style fixes and improvements. Thanks @hugovk. + +New features: + +* Added `Board.root()`. + +New in v0.23.6 (25th May 2018) +------------------------------ + +Bugfixes: + +* Gaviota: Fix Python based Gaviota tablebase probing when there are multiple + en passant captures. Thanks @bjoernholzhauer. +* Syzygy: Fix DTZ for some mate in 1 positions. Similarly to the fix from + v0.23.1 this is mostly cosmetic. +* Syzygy: Fix DTZ off-by-one in some 6 piece antichess positions with moves + that threaten to force a capture. This is mostly cosmetic. + +Changes: + +* Let `uci.Engine.position()` send history of at least 8 moves if available. + Previously it sent only moves that were relevant for repetition detection. + This is mostly useful for Lc0. Once performance issues are solved, a future + version will always send the entire history. Thanks @SashaMN and @Mk-Chan. +* Various documentation fixes and improvements. + +New features: + +* Added `polyglot.MemoryMappedReader.get(board, default=None)`. + +New in v0.23.5 (11th May 2018) +------------------------------ + +Bugfixes: + +* Atomic chess: KNvKN is not insufficient material. +* Crazyhouse: Detect insufficient material. This can not happen unless the + game was started with insufficient material. + +Changes: + +* Better error messages when parsing info from UCI engine fails. +* Better error message for `b.set_board_fen(b.fen())`. + +New in v0.23.4 (29th Apr 2018) +------------------------------ + +New features: + +* XBoard: Support pondering. Thanks Manik Charan. +* UCI: Support unofficial `info ebf`. + +Bugfixes: + +* Implement 16 bit DTZ mapping, which is required for some of the longest + 7 piece endgames. + +New in v0.23.3 (21st Apr 2018) +------------------------------ + +New features: + +* XBoard: Support `variant`. Thanks gbtami. + +New in v0.23.2 (20th Apr 2018) +------------------------------ + +Bugfixes: + +* XBoard: Handle multiple features and features with spaces. Thanks gbtami. +* XBoard: Ignore debug output prefixed with `#`. Thanks Dan Ravensloft and + Manik Charan. + +New in v0.23.1 (13th Apr 2018) +------------------------------ + +Bugfixes: + +* Fix DTZ in case of mate in 1. This is a cosmetic fix, as the previous + behavior was only off by one (which is allowed by design). + +New in v0.23.0 (8th Apr 2018) +----------------------------- + +New features: + +* Experimental support for 7 piece Syzygy tablebases. + +Changes: + +* `chess.syzygy.filenames()` was renamed to `tablenames()` and + gained an optional `piece_count=6` argument. +* `chess.syzygy.normalize_filename()` was renamed to `normalize_tablename()`. +* The undocumented constructors of `chess.syzygy.WdlTable` and + `chess.syzygy.DtzTable` have been changed. + +New in v0.22.2 (15th Mar 2018) +------------------------------ + +Bugfixes: + +* In standard chess promoted pieces were incorrectly considered as + distinguishable from normal pieces with regard to position equality + and threefold repetition. Thanks to kn-sq-tb for reporting. + +Changes: + +* The PGN `game.headers` are now a custom mutable mapping that validates the + validity of tag names. +* Basic attack and pin methods moved to `BaseBoard`. +* Documentation fixes and improvements. + +New features: + +* Added `Board.lan()` for long algebraic notation. + +New in v0.22.1 (1st Jan 2018) +----------------------------- + +New features: + +* Added `Board.mirror()`, `SquareSet.mirror()` and `bswap()`. +* Added `chess.pgn.GameNode.accept_subgame()`. +* XBoard: Added `resign`, `analyze`, `exit`, `name`, `rating`, `computer`, + `egtpath`, `pause`, `resume`. Completed option parsing. + +Changes: + +* `chess.pgn`: Accept FICS wilds without warning. +* XBoard: Inform engine about game results. + +Bugfixes: + +* `chess.pgn`: Allow games without movetext. +* XBoard: Fixed draw handling. + +New in v0.22.0 (20th Nov 2017) +------------------------------ + +Changes: + +* `len(board.legal_moves)` **replaced by** `board.legal_moves.count()`. + Previously `list(board.legal_moves)` was generating moves twice, resulting in + a considerable slowdown. Thanks to Martin C. Doege for reporting. +* **Dropped Python 2.6 support.** +* XBoard: `offer_draw` renamed to `draw`. + +New features: + +* XBoard: Added `DrawHandler`. + +New in v0.21.2 (17th Nov 2017) +------------------------------ + +Changes: + +* `chess.svg` is now fully SVG Tiny 1.2 compatible. Removed + `chess.svg.DEFAULT_STYLE` which would from now on be always empty. + +New in v0.21.1 (14th Nov 2017) +------------------------------ + +Bugfixes: + +* `Board.set_piece_at()` no longer shadows optional `promoted` + argument from `BaseBoard`. +* Fixed `ThreeCheckBoard.is_irreversible()` and + `ThreeCheckBoard._transposition_key()`. + +New features: + +* Added `Game.without_tag_roster()`. `chess.pgn.StringExporter()` can now + handle games without any headers. +* XBoard: `white`, `black`, `random`, `nps`, `otim`, `undo`, `remove`. Thanks + to Manik Charan. + +Changes: + +* Documentation fixes and tweaks by Boštjan Mejak. +* Changed unicode character for empty squares in `Board.unicode()`. + +New in v0.21.0 (13th Nov 2017) +------------------------------ + +Release yanked. + +New in v0.20.1 (16th Oct 2017) +------------------------------ + +Bugfixes: + +* Fix arrow positioning on SVG boards. +* Documentation fixes and improvements, making most doctests runnable. + +New in v0.20.0 (13th Oct 2017) +------------------------------ + +Bugfixes: + +* Some XBoard commands were not returning futures. +* Support semicolon comments in PGNs. + +Changes: + +* Changed FEN and EPD formatting options. It is now possible to include en + passant squares in FEN and X-FEN style, or to include only strictly relevant + en passant squares. +* Relax en passant square validation in `Board.set_fen()`. +* Ensure `is_en_passant()`, `is_capture()`, `is_zeroing()` and + `is_irreversible()` strictly return bools. +* Accept `Z0` as a null move in PGNs. + +New features: + +* XBoard: Add `memory`, `core`, `stop` and `movenow` commands. + Abstract `post`/`nopost`. Initial `FeatureMap` support. Support `usermove`. +* Added `Board.has_pseudo_legal_en_passant()`. +* Added `Board.piece_map()`. +* Added `SquareSet.carry_rippler()`. +* Factored out some (unstable) low level APIs: `BB_CORNERS`, + `_carry_rippler()`, `_edges()`. + +New in v0.19.0 (27th Jul 2017) +------------------------------ + +New features: + +* **Experimental XBoard engine support.** Thanks to Manik Charan and + Cash Costello. Expect breaking changes in future releases. +* Added an undocumented `chess.polyglot.ZobristHasher` to make Zobrist hashing + easier to extend. + +Bugfixes: + +* Merely pseudo-legal en passant does no longer count for repetitions. +* Fixed repetition detection in Three-Check and Crazyhouse. (Previously + check counters and pockets were ignored.) +* Checking moves in Three-Check are now considered as irreversible by + `ThreeCheckBoard.is_irreversible()`. +* `chess.Move.from_uci("")` was raising `IndexError` instead of `ValueError`. + Thanks Jonny Balls. + +Changes: + +* `chess.syzygy.Tablebases` constructor no longer supports directly opening + a directory. Use `chess.syzygy.open_tablebases()`. +* `chess.gaviota.PythonTablebases` and `NativeTablebases` constructors + no longer support directly opening a directory. + Use `chess.gaviota.open_tablebases()`. +* `chess.Board` instances are now compared by the position they represent, + not by exact match of the internal data structures (or even move history). +* Relaxed castling right validation in Chess960: Kings/rooks of opposing sites + are no longer required to be on the same file. +* Removed misnamed `Piece.__unicode__()` and `BaseBoard.__unicode__()`. Use + `Piece.unicode_symbol()` and `BaseBoard.unicode()` instead. +* Changed `chess.SquareSet.__repr__()`. +* Support `[Variant "normal"]` in PGNs. +* `pip install python-chess[engine]` instead of `python-chess[uci]` (since + the extra dependencies are required for both UCI and XBoard engines). +* Mixed documentation fixes and improvements. + +New in v0.18.4 (27th Jul 2017) +------------------------------ + +Changes: + +* Support `[Variant "fischerandom"]` in PGNs for Cutechess compatibility. + Thanks to Steve Maughan for reporting. + +New in v0.18.3 (28th Jun 2017) +------------------------------ + +Bugfixes: + +* `chess.gaviota.NativeTablebases.get_dtm()` and `get_wdl()` were missing. + +New in v0.18.2 (1st Jun 2017) +----------------------------- + +Bugfixes: + +* Fixed castling in atomic chess when there is a rank attack. +* The halfmove clock in Crazyhouse is no longer incremented unconditionally. + `CrazyhouseBoard.is_zeroing(move)` now considers pawn moves and captures as + zeroing. Added `Board.is_irreversible(move)` that can be used instead. +* Fixed an inconsistency where the `chess.pgn` tokenizer accepts long algebraic + notation but `Board.parse_san()` did not. + +Changes: + +* Added more NAG constants in `chess.pgn`. + +New in v0.18.1 (1st May 2017) +----------------------------- + +Bugfixes: + +* Crazyhouse drops were accepted as pseudo-legal (and legal) even if the + respective piece was not in the pocket. +* `CrazyhouseBoard.pop()` was failing to undo en passant moves. +* `CrazyhouseBoard.pop()` was always returning `None`. +* `Move.__copy__()` was failing to copy Crazyhouse drops. +* Fix ~ order (marker for promoted pieces) in FENs. +* Promoted pieces in Crazyhouse were not communicated with UCI engines. + +Changes: + +* `ThreeCheckBoard.uci_variant` changed from `threecheck` to `3check`. + +New in v0.18.0 (20th Apr 2017) +------------------------------ + +Bugfixes: + +* Fixed `Board.parse_uci()` for crazyhouse drops. Thanks to Ryan Delaney. +* Fixed `AtomicBoard.is_insufficient_material()`. +* Fixed signature of `SuicideBoard.was_into_check()`. +* Explicitly close input and output streams when a `chess.uci.PopenProcess` + terminates. +* The documentation of `Board.attackers()` was wrongly stating that en passant + capturable pawns are considered attacked. + +Changes: + +* `chess.SquareSet` is no longer hashable (since it is mutable). +* Removed functions and constants deprecated in v0.17.0. +* Dropped `gmpy2` and `gmpy` as optional dependencies. They were no longer + improving performance. +* Various tweaks and optimizations for 5% improvement in PGN parsing and perft + speed. (Signature of `_is_safe` and `_ep_skewered` changed). +* Rewritten `chess.svg.board()` using `xml.etree`. No longer supports *pre* and + *post*. Use an XML parser if you need to modify the SVG. Now only inserts + actually used piece defintions. +* Untangled UCI process and engine instanciation, changing signatures of + constructors and allowing arbitrary arguments to `subprocess.Popen`. +* Coding style and documentation improvements. + +New features: + +* `chess.svg.board()` now supports arrows. Thanks to @rheber for implementing + this feature. +* Let `chess.uci.PopenEngine` consistently handle Ctrl+C across platforms + and Python versions. `chess.uci.popen_engine()` now supports a `setpgrp` + keyword argument to start the engine process in a new process group. + Thanks to @dubiousjim. +* Added `board.king(color)` to find the (royal) king of a given side. +* SVGs now have `viewBox` and `chess.svg.board(size=None)` supports and + defaults to `None` (i.e. scaling to the size of the container). + +New in v0.17.0 (6th Mar 2017) +----------------------------- + +Changes: + +* Rewritten move generator, various performance tweaks, code simplications + (500 lines removed) amounting to **doubled PGN parsing and perft speed**. +* Removed `board.generate_evasions()` and `board.generate_non_evasions()`. +* Removed `board.transpositions`. Transpositions are now counted on demand. +* `file_index()`, `rank_index()`, and `pop_count()` have been renamed to + `square_file()`, `square_rank()` and `popcount()` respectively. Aliases will + be removed in some future release. +* `STATUS_ILLEGAL_CHECK` has been renamed to `STATUS_RACE_CHECK`. The alias + will be removed in a future release. +* Removed `DIAG_ATTACKS_NE`, `DIAG_ATTACKS_NW`, `RANK_ATTACKS` and + `FILE_ATTACKS` as well as the corresponding masks. New attack tables + `BB_DIAG_ATTACKS` (combined both diagonal tables), `BB_RANK_ATTACKS` and + `BB_FILE_ATTACKS` are indexed by square instead of mask. +* `board.push()` no longer requires pseudo-legality. +* Documentation improvements. + +Bugfixes: + +* **Positions in variant end are now guaranteed to have no legal moves.** + `board.is_variant_end()` has been added to test for special variant end + conditions. Thanks to salvador-dali. +* `chess.svg`: Fixed a typo in the class names of black queens. Fixed fill + color for black rooks and queens. Added SVG Tiny support. These combined + changes fix display in a number of applications, including + Jupyter Qt Console. Thanks to Alexander Meshcheryakov. +* `board.ep_square` was not consistently `None` instead of `0`. +* Detect invalid racing kings positions: `STATUS_RACE_OVER`, + `STATUS_RACE_MATERIAL`. +* `SAN_REGEX`, `FEN_CASTLING_REGEX` and `TAG_REGEX` now try to match the + entire string and no longer accept newlines. +* Fixed `Move.__hash__()` for drops. + +New features: + +* `board.remove_piece_at()` now returns the removed piece. +* Added `square_distance()` and `square_mirror()`. +* Added `msb()`, `lsb()`, `scan_reversed()` and `scan_forward()`. +* Added `BB_RAYS` and `BB_BETWEEN`. + +New in v0.16.2 (15th Jan 2017) +------------------------------ + +Changes: + +* `board.move_stack` now contains the exact move objects added with + `Board.push()` (instead of normalized copies for castling moves). + This ensures they can be used with `Board.variation_san()` amongst others. +* `board.ep_square` is now `None` instead of `0` for no en passant square. +* `chess.svg`: Better vector graphics for knights. Thanks to ProgramFox. +* Documentation improvements. + +New in v0.16.1 (12th Dec 2016) +------------------------------ + +Bugfixes: + +* Explosions in atomic chess were not destroying castling rights. Thanks to + ProgramFOX for finding this issue. + +New in v0.16.0 (11th Dec 2016) +------------------------------ + +Bugfixes: + +* `pin_mask()`, `pin()` and `is_pinned()` make more sense when already + in check. Thanks to Ferdinand Mosca. + +New features: + +* **Variant support: Suicide, Giveaway, Atomic, King of the Hill, Racing Kings, + Horde, Three-check, Crazyhouse.** `chess.Move` now supports drops. +* More fine grained dependencies. Use *pip install python-chess[uci,gaviota]* to + install dependencies for the full feature set. +* Added `chess.STATUS_EMPTY` and `chess.STATUS_ILLEGAL_CHECK`. +* The `board.promoted` mask keeps track of promoted pieces. +* Optionally copy boards without the move stack: `board.copy(stack=False)`. +* `examples/bratko_kopec` now supports avoid move (am), variants and + displays fractional scores immidiately. Thanks to Daniel Dugovic. +* `perft.py` rewritten with multi-threading support and moved to + `examples/perft`. +* `chess.syzygy.dependencies()`, `chess.syzygy.all_dependencies()` to generate + Syzygy tablebase dependencies. + +Changes: + +* **Endgame tablebase probing (Syzygy, Gaviota):** `probe_wdl()` **,** + `probe_dtz()` **and** `probe_dtm()` **now raise** `KeyError` **or** + `MissingTableError` **instead of returning** *None*. If you prefer getting + `None` in case of an error use `get_wdl()`, `get_dtz()` and `get_dtm()`. +* `chess.pgn.BaseVisitor.result()` returns `True` by default and is no longer + used by `chess.pgn.read_game()` if no game was found. +* Non-fast-forward update of the Git repository to reduce size (old binary + test assets removed). +* `board.pop()` now uses a boardstate stack to undo moves. +* `uci.engine.position()` will send the move history only until the latest + zeroing move. +* Optimize `board.clean_castling_rights()` and micro-optimizations improving + PGN parser performance by around 20%. +* Syzygy tables now directly use the endgame name as hash keys. +* Improve test performance (especially on Travis CI). +* Documentation updates and improvements. + +New in v0.15.4 +-------------- + +New features: + +* Highlight last move and checks when rendering board SVGs. + +New in v0.15.3 (21st Sep 2016) +------------------------------ + +Bugfixes: + +* `pgn.Game.errors` was not populated as documented. Thanks to Ryan Delaney + for reporting. + +New features: + +* Added `pgn.GameNode.add_line()` and `pgn.GameNode.main_line()` which make + it easier to work with lists of moves as variations. + +New in v0.15.2 +-------------- + +Bugfixes: + +* Fix a bug where `shift_right()` and `shift_2_right()` were producing + integers larger than 64bit when shifting squares off the board. This is + very similar to the bug fixed in v0.15.1. Thanks to piccoloprogrammatore + for reporting. + +New in v0.15.1 (12th Sep 2016) +------------------------------ + +Bugfixes: + +* Fix a bug where `shift_up_right()` and `shift_up_left()` were producing + integers larger than 64bit when shifting squares off the board. + +New features: + +* Replaced `__html__` with experimental SVG rendering for IPython. + +New in v0.15.0 (11th Aug 2016) +------------------------------ + +Changes: + +* `chess.uci.Score` **no longer has** `upperbound` **and** `lowerbound` + **attributes**. Previously these were always *False*. + +* Significant improvements of move generation speed, around **2.3x faster + PGN parsing**. Removed the following internal attributes and methods of + the `Board` class: `attacks_valid`, `attacks_to`, `attacks_from`, + `_pinned()`, `attacks_valid_stack`, `attacks_from_stack`, `attacks_to_stack`, + `generate_attacks()`. + +* UCI: Do not send *isready* directly after *go*. Though allowed by the UCI + protocol specification it is just not nescessary and many engines were having + trouble with this. + +* Polyglot: Use less memory for uniform random choices from big opening books + (reservoir sampling). + +* Documentation improvements. + +Bugfixes: + +* Allow underscores in PGN header tags. Found and fixed by Bajusz Tamás. + +New features: + +* Added `Board.chess960_pos()` to identify the Chess960 starting position + number of positions. + +* Added `chess.BB_BACKRANKS` and `chess.BB_PAWN_ATTACKS`. + +New in v0.14.1 (7th Jun 2016) +----------------------------- + +Bugfixes: + +* Backport Bugfix for Syzygy DTZ related to en passant. + See official-stockfish/Stockfish@6e2ca97d93812b2. + +Changes: + +* Added optional argument *max_fds=128* to `chess.syzygy.open_tablebases()`. + An LRU cache is used to keep at most *max_fds* files open. This allows using + many tables without running out of file descriptors. + Previously all tables were opened at once. + +* Syzygy and Gaviota now store absolute tablebase paths, in case you change + the working directory of the process. + +* The default implementation of `chess.uci.InfoHandler.score()` will no longer + store score bounds in `info["score"]`, only real scores. + +* Added `Board.set_chess960_pos()`. + +* Documentation improvements. + +New in v0.14.0 (7th Apr 2016) +----------------------------- + +Changes: + +* `Board.attacker_mask()` **has been renamed to** `Board.attackers_mask()` for + consistency. + +* **The signature of** `Board.generate_legal_moves()` **and** + `Board.generate_pseudo_legal_moves()` **has been changed.** Previously it + was possible to select piece types for selective move generation: + + `Board.generate_legal_moves(castling=True, pawns=True, knights=True, bishops=True, rooks=True, queens=True, king=True)` + + Now it is possible to select arbitrary sets of origin and target squares. + `to_mask` uses the corresponding rook squares for castling moves. + + `Board.generate_legal_moves(from_mask=BB_ALL, to_mask=BB)` + + To generate all knight and queen moves do: + + `board.generate_legal_moves(board.knights | board.queens)` + + To generate only castling moves use: + + `Board.generate_castling_moves(from_mask=BB_ALL, to_mask=BB_ALL)` + +* Additional hardening has been added on top of the bugfix from v0.13.3. + Diagonal skewers on the last double pawn move are now handled correctly, + even though such positions can not be reached with a sequence of legal moves. + +* `chess.syzygy` now uses the more efficient selective move generation. + +New features: + +* The following move generation methods have been added: + `Board.generate_pseudo_legal_ep(from_mask=BB_ALL, to_mask=BB_ALL)`, + `Board.generate_legal_ep(from_mask=BB_ALL, to_mask=BB_ALL)`, + `Board.generate_pseudo_legal_captures(from_mask=BB_ALL, to_mask=BB_ALL)`, + `Board.generate_legal_captures(from_mask=BB_ALL, to_mask=BB_ALL)`. + + +New in v0.13.3 (7th Apr 2016) +----------------------------- + +**This is a bugfix release for a move generation bug.** Other than the bugfix +itself there are only minimal fully backwardscompatible changes. +You should update immediately. + +Bugfixes: + +* When capturing en passant, both the capturer and the captured pawn disappear + from the fourth or fifth rank. If those pawns were covering a horizontal + attack on the king, then capturing en passant should not have been legal. + + `Board.generate_legal_moves()` and `Board.is_into_check()` have been fixed. + + The same principle applies for diagonal skewers, but nothing has been done + in this release: If the last double pawn move covers a diagonal attack, then + the king would have already been in check. + + v0.14.0 adds additional hardening for all cases. It is recommended you + upgrade to v0.14.0 as soon as you can deal with the + non-backwards compatible changes. + +Changes: + +* `chess.uci` now uses `subprocess32` if applicable (and available). + Additionally a lock is used to work around a race condition in Python 2, that + can occur when spawning engines from multiple threads at the same time. + +* Consistently handle tabs in UCI engine output. + +New in v0.13.2 (19th Jan 2016) +------------------------------ + +Changes: + +* `chess.syzygy.open_tablebases()` now raises if the given directory + does not exist. + +* Allow visitors to handle invalid `FEN` tags in PGNs. + +* Gaviota tablebase probing fails faster for piece counts > 5. + +Minor new features: + +* Added `chess.pgn.Game.from_board()`. + +New in v0.13.1 (20th Dec 2015) +------------------------------ + +Changes: + +* Missing *SetUp* tags in PGNs are ignored. + +* Incompatible comparisons on `chess.Piece`, `chess.Move`, `chess.Board` + and `chess.SquareSet` now return *NotImplemented* instead of *False*. + +Minor new features: + +* Factored out basic board operations to `chess.BaseBoard`. This is inherited + by `chess.Board` and extended with the usual move generation features. + +* Added optional *claim_draw* argument to `chess.Base.is_game_over()`. + +* Added `chess.Board.result(claim_draw=False)`. + +* Allow `chess.Board.set_piece_at(square, None)`. + +* Added `chess.SquareSet.from_square(square)`. + +New in v0.13.0 (10th Nov 2015) +------------------------------ + +* `chess.pgn.Game.export()` and `chess.pgn.GameNode.export()` have been + removed and replaced with a new visitor concept. + +* `chess.pgn.read_game()` no longer takes an `error_handler` argument. Errors + are now logged. Use the new visitor concept to change this behaviour. + +New in v0.12.5 (18th Oct 2015) +------------------------------ + +Bugfixes: + +* Context manager support for pure Python Gaviota probing code. Various + documentation fixes for Gaviota probing. Thanks to Jürgen Précour for + reporting. + +* PGN variation start comments for variations on the very first move were + assigned to the game. Thanks to Norbert Räcke for reporting. + +New in v0.12.4 (13th Oct 2015) +------------------------------ + +Bugfixes: + +* Another en passant related Bugfix for pure Python Gaviota tablebase probing. + +New features: + +* Added `pgn.GameNode.is_end()`. + +Changes: + +* Big speedup for `pgn` module. Boards are cached less agressively. Board + move stacks are copied faster. + +* Added tox.ini to specify test suite and flake8 options. + +New in v0.12.3 (9th Oct 2015) +----------------------------- + +Bugfixes: + +* Some invalid castling rights were silently ignored by `Board.set_fen()`. Now + it is ensured information is stored for retrieval using `Board.status()`. + +New in v0.12.2 (7th Oct 2015) +----------------------------- + +Bugfixes: + +* Some Gaviota probe results were incorrect for positions where black could + capture en passant. + +New in v0.12.1 (7th Oct 2015) +----------------------------- + +Changes: + +* Robust handling of invalid castling rights. You can also use the new + method `Board.clean_castling_rights()` to get the subset of strictly valid + castling rights. + +New in v0.12.0 (3rd Oct 2015) +----------------------------- + +New features: + +* Python 2.6 support. Patch by vdbergh. + +* Pure Python Gaviota tablebase probing. Thanks to Jean-Noël Avila. + +New in v0.11.1 (7th Sep 2015) +----------------------------- + +Bugfixes: + +* `syzygy.Tablebases.probe_dtz()` has was giving wrong results for some + positions with possible en passant capturing. This was found and fixed + upstream: https://github.com/official-stockfish/Stockfish/issues/394. + +* Ignore extra spaces in UCI `info` lines, as for example sent by the + Hakkapeliitta engine. Thanks to Jürgen Précour for reporting. + +New in v0.11.0 (6th Sep 2015) +----------------------------- + +Changes: + +* **Chess960** support and the **representation of castling moves** has been + changed. + + The constructor of board has a new `chess960` argument, defaulting to + `False`: `Board(fen=STARTING_FEN, chess960=False)`. That property is + available as `Board.chess960`. + + In Chess960 mode the behaviour is as in the previous release. Castling moves + are represented as a king move to the corresponding rook square. + + In the default standard chess mode castling moves are represented with + the standard UCI notation, e.g. `e1g1` for king-side castling. + + `Board.uci(move, chess960=None)` creates UCI representations for moves. + Unlike `Move.uci()` it can convert them in the context of the current + position. + + `Board.has_chess960_castling_rights()` has been added to test for castling + rights that are impossible in standard chess. + + The modules `chess.polyglot`, `chess.pgn` and `chess.uci` will transparently + handle both modes. + +* In a previous release `Board.fen()` has been changed to only display an + en passant square if a legal en passant move is indeed possible. This has + now also been adapted for `Board.shredder_fen()` and `Board.epd()`. + +New features: + +* Get individual FEN components: `Board.board_fen()`, `Board.castling_xfen()`, + `Board.castling_shredder_fen()`. + +* Use `Board.has_legal_en_passant()` to test if a position has a legal + en passant move. + +* Make `repr(board.legal_moves)` human readable. + +New in v0.10.1 (30th Aug 2015) +------------------------------ + +Bugfixes: + +* Fix use-after-free in Gaviota tablebase initialization. + +New in v0.10.0 (28th Aug 2015) +------------------------------ + +New dependencies: + +* If you are using Python < 3.2 you have to install `futures` in order to + use the `chess.uci` module. + +Changes: + +* There are big changes in the UCI module. Most notably in async mode multiple + commands can be executed at the same time (e.g. `go infinite` and then + `stop` or `go ponder` and then `ponderhit`). + + `go infinite` and `go ponder` will now wait for a result, i.e. you may have + to call `stop` or `ponderhit` from a different thread or run the commands + asynchronously. + + `stop` and `ponderhit` no longer have a result. + +* The values of the color constants `chess.WHITE` and `chess.BLACK` have been + changed. Previously `WHITE` was `0`, `BLACK` was `1`. Now `WHITE` is `True`, + `BLACK` is `False`. The recommended way to invert `color` is using + `not color`. + +* The pseudo piece type `chess.NONE` has been removed in favor of just using + `None`. + +* Changed the `Board(fen)` constructor. If the optional `fen` argument is not + given behavior did not change. However if `None` is passed explicitly an + empty board is created. Previously the starting position would have been + set up. + +* `Board.fen()` will now only show completely legal en passant squares. + +* `Board.set_piece_at()` and `Board.remove_piece_at()` will now clear the + move stack, because the old moves may not be valid in the changed position. + +* `Board.parse_uci()` and `Board.push_uci()` will now accept null moves. + +* Changed shebangs from `#!/usr/bin/python` to `#!/usr/bin/env python` for + better virtualenv support. + +* Removed unused game data files from repository. + +Bugfixes: + +* PGN: Prefer the game result from the game termination marker over `*` in the + header. These should be identical in standard compliant PGNs. Thanks to + Skyler Dawson for reporting this. + +* Polyglot: `minimum_weight` for `find()`, `find_all()` and `choice()` was + not respected. + +* Polyglot: Negative indexing of opening books was raising `IndexError`. + +* Various documentation fixes and improvements. + +New features: + +* Experimental probing of Gaviota tablebases via libgtb. + +* New methods to construct boards: + + .. code:: python + + >>> chess.Board.empty() + Board('8/8/8/8/8/8/8/8 w - - 0 1') + + >>> board, ops = chess.Board.from_epd("4k3/8/8/8/8/8/8/4K3 b - - fmvn 17; hmvc 13") + >>> board + Board('4k3/8/8/8/8/8/8/4K3 b - - 13 17') + >>> ops + {'fmvn': 17, 'hmvc': 13} + +* Added `Board.copy()` and hooks to let the copy module to the right thing. + +* Added `Board.has_castling_rights(color)`, + `Board.has_kingside_castling_rights(color)` and + `Board.has_queenside_castling_rights(color)`. + +* Added `Board.clear_stack()`. + +* Support common set operations on `chess.SquareSet()`. + +New in v0.9.1 (15th Jul 2015) +----------------------------- + +Bugfixes: + +* UCI module could not handle castling ponder moves. Thanks to Marco Belli for + reporting. +* The initial move number in PGNs was missing, if black was to move in the + starting position. Thanks to Jürgen Précour for reporting. +* Detect more impossible en passant squares in `Board.status()`. There already + was a requirement for a pawn on the fifth rank. Now the sixth and seventh + rank must be empty, additionally. We do not do further retrograde analysis, + because these are the only cases affecting move generation. + +New in v0.8.3 (15th Jul 2015) +----------------------------- + +Bugfixes: + +* The initial move number in PGNs was missing, if black was to move in the + starting position. Thanks to Jürgen Précour for reporting. +* Detect more impossible en passant squares in `Board.status()`. There already + was a requirement for a pawn on the fifth rank. Now the sixth and seventh + rank must be empty, additionally. We do not do further retrograde analysis, + because these are the only cases affecting move generation. + +New in v0.9.0 (24th Jun 2015) +----------------------------- + +**This is a big update with quite a few breaking changes. Carefully review +the changes before upgrading. It's no problem if you can not update right now. +The 0.8.x branch still gets bugfixes.** + +Incompatible changes: + +* Removed castling right constants. Castling rights are now represented as a + bitmask of the rook square. For example: + + .. code:: python + + >>> board = chess.Board() + + >>> # Standard castling rights. + >>> board.castling_rights == chess.BB_A1 | chess.BB_H1 | chess.BB_A8 | chess.BB_H8 + True + + >>> # Check for the presence of a specific castling right. + >>> can_white_castle_queenside = chess.BB_A1 & board.castling_rights + + Castling moves were previously encoded as the corresponding king movement in + UCI, e.g. `e1f1` for white kingside castling. **Now castling moves are + encoded as a move to the corresponding rook square** (`UCI_Chess960`-style), + e.g. `e1a1`. + + You may use the new methods `Board.uci(move, chess960=True)`, + `Board.parse_uci(uci)` and `Board.push_uci(uci)` to handle this + transparently. + + The `uci` module takes care of converting moves when communicating with an + engine that is not in `UCI_Chess960` mode. + +* The `get_entries_for_position(board)` method of polyglot opening book readers + has been changed to `find_all(board, minimum_weight=1)`. By default entries + with weight 0 are excluded. + +* The `Board.pieces` lookup list has been removed. + +* In 0.8.1 the spelling of repetition (was repitition) was fixed. + `can_claim_threefold_repetition()` and `is_fivefold_repetition()` are the + affected method names. Aliases are now removed. + +* `Board.set_epd()` will now interpret `bm`, `am` as a list of moves for the + current position and `pv` as a variation (represented by a list of moves). + Thanks to Jordan Bray for reporting this. + +* Removed `uci.InfoHandler.pre_bestmove()` and + `uci.InfoHandler.post_bestmove()`. + +* `uci.InfoHandler().info["score"]` is now relative to multipv. Use + + .. code:: python + + >>> with info_handler as info: + ... if 1 in info["score"]: + ... cp = info["score"][1].cp + + where you were previously using + + .. code:: python + + >>> with info_handler as info: + ... if "score" in info: + ... cp = info["score"].cp + +* Clear `uci.InfoHandler()` dictionary at the start of new searches + (new `on_go()`), not at the end of searches. + +* Renamed `PseudoLegalMoveGenerator.bitboard` and `LegalMoveGenerator.bitboard` + to `PseudoLegalMoveGenerator.board` and `LegalMoveGenerator.board`, + respectively. + +* Scripts removed. + +* Python 3.2 compatibility dropped. Use Python 3.3 or higher. Python 2.7 + support is not affected. + +New features: + +* **Introduced Chess960 support.** `Board(fen)` and `Board.set_fen(fen)` now + support X-FENs. Added `Board.shredder_fen()`. + `Board.status(allow_chess960=True)` has an optional argument allowing to + insist on standard chess castling rules. + Added `Board.is_valid(allow_chess960=True)`. + +* **Improved move generation using** `Shatranj-style direct lookup + `_. **Removed rotated bitboards. Perft + speed has been more than doubled.** + +* Added `choice(board)` and `weighted_choice(board)` for polyglot opening book + readers. + +* Added `Board.attacks(square)` to determine attacks *from* a given square. + There already was `Board.attackers(color, square)` returning attacks *to* + a square. + +* Added `Board.is_en_passant(move)`, `Board.is_capture(move)` and + `Board.is_castling(move)`. + +* Added `Board.pin(color, square)` and `Board.is_pinned(color, square)`. + +* There is a new method `Board.pieces(piece_type, color)` to get a set of + squares with the specified pieces. + +* Do expensive Syzygy table initialization on demand. + +* Allow promotions like `e8Q` (usually `e8=Q`) in `Board.parse_san()` and + PGN files. + +* Patch by Richard C. Gerkin: Added `Board.__unicode__()` just like + `Board.__str__()` but with unicode pieces. +* Patch by Richard C. Gerkin: Added `Board.__html__()`. + +New in v0.8.2 (21st Jun 2015) +----------------------------- + +Bugfixes: + +* `pgn.Game.setup()` with the standard starting position was failing when the + standard starting position was already set. Thanks to Jordan Bray for + reporting this. + +Optimizations: + +* Remove `bswap()` from Syzygy decompression hot path. Directly read integers + with the correct endianness. + +New in v0.8.1 (29th May 2015) +----------------------------- + +* Fixed pondering mode in uci module. For example `ponderhit()` was blocking + indefinitely. Thanks to Valeriy Huz for reporting this. + +* Patch by Richard C. Gerkin: Moved searchmoves to the end of the UCI go + command, where it will not cause other command parameters to be ignored. + +* Added missing check or checkmate suffix to castling SANs, e.g. `O-O-O#`. + +* Fixed off-by-one error in polyglot opening book binary search. This would + not have caused problems for real opening books. + +* Fixed Python 3 support for reverse polyglot opening book iteration. + +* Bestmoves may be literally `(none)` in UCI protocol, for example in + checkmate positions. Fix parser and return `None` as the bestmove in this + case. + +* Fixed spelling of repetition (was repitition). + `can_claim_threefold_repetition()` and `is_fivefold_repetition()` are the + affected method names. Aliases are there for now, but will be removed in the + next release. Thanks to Jimmy Patrick for reporting this. + +* Added `SquareSet.__reversed__()`. + +* Use containerized tests on Travis CI, test against Stockfish 6, improved + test coverage amd various minor clean-ups. + +New in v0.8.0 (25th Mar 2015) +----------------------------- + +* **Implement Syzygy endgame tablebase probing.** + `https://syzygy-tables.info `_ + is an example project that provides a public API using the new features. + +* The interface for aynchronous UCI command has changed to mimic + `concurrent.futures`. `is_done()` is now just `done()`. Callbacks will + receive the command object as a single argument instead of the result. + The `result` property and `wait()` have been removed in favor of a + synchronously waiting `result()` method. + +* The result of the `stop` and `go` UCI commands are now named tuples (instead + of just normal tuples). + +* Add alias `Board` for `Bitboard`. + +* Fixed race condition during UCI engine startup. Lines received during engine + startup sometimes needed to be processed before the Engine object was fully + initialized. + +New in v0.7.0 (21st Feb 2015) +----------------------------- + +* **Implement UCI engine communication.** + +* Patch by Matthew Lai: `Add caching for gameNode.board()`. + +New in v0.6.0 (8th Nov 2014) +---------------------------- + +* If there are comments in a game before the first move, these are now assigned + to `Game.comment` instead of `Game.starting_comment`. `Game.starting_comment` + is ignored from now on. `Game.starts_variation()` is no longer true. + The first child node of a game can no longer have a starting comment. + It is possible to have a game with `Game.comment` set, that is otherwise + completely empty. + +* Fix export of games with variations. Previously the moves were exported in + an unusual (i.e. wrong) order. + +* Install `gmpy2` or `gmpy` if you want to use slightly faster binary + operations. + +* Ignore superfluous variation opening brackets in PGN files. + +* Add `GameNode.san()`. + +* Remove `sparse_pop_count()`. Just use `pop_count()`. + +* Remove `next_bit()`. Now use `bit_scan()`. + +New in v0.5.0 (28th Oct 2014) +----------------------------- + +* PGN parsing is now more robust: `read_game()` ignores invalid tokens. + Still exceptions are going to be thrown on illegal or ambiguous moves, but + this behaviour can be changed by passing an `error_handler` argument. + + .. code:: python + + >>> # Raises ValueError: + >>> game = chess.pgn.read_game(file_with_illegal_moves) + + .. code:: python + + >>> # Silently ignores errors and continues parsing: + >>> game = chess.pgn.read_game(file_with_illegal_moves, None) + + .. code:: python + + >>> # Logs the error, continues parsing: + >>> game = chess.pgn.read_game(file_with_illegal_moves, logger.exception) + + If there are too many closing brackets this is now ignored. + + Castling moves like 0-0 (with zeros) are now accepted in PGNs. + The `Bitboard.parse_san()` method remains strict as always, though. + + Previously the parser was strictly following the PGN spefification in that + empty lines terminate a game. So a game like + + :: + + [Event "?"] + + { Starting comment block } + + 1. e4 e5 2. Nf3 Nf6 * + + would have ended directly after the starting comment. To avoid this, the + parser will now look ahead until it finds at least one move or a termination + marker like `*`, `1-0`, `1/2-1/2` or `0-1`. + +* Introduce a new function `scan_headers()` to quickly scan a PGN file for + headers without having to parse the full games. + +* Minor testcoverage improvements. + +New in v0.4.2 (11th Oct 2014) +----------------------------- + +* Fix bug where `pawn_moves_from()` and consequently `is_legal()` weren't + handling en passant correctly. Thanks to Norbert Naskov for reporting. + +New in v0.4.1 (26th Aug 2014) +----------------------------- + +* Fix `is_fivefold_repitition()`: The new fivefold repetition rule requires + the repetitions to occur on *alternating consecutive* moves. + +* Minor testing related improvements: Close PGN files, allow running via + setuptools. + +* Add recently introduced features to README. + +New in v0.4.0 (19th Aug 2014) +----------------------------- + +* Introduce `can_claim_draw()`, `can_claim_fifty_moves()` and + `can_claim_threefold_repitition()`. + +* Since the first of July 2014 a game is also over (even without claim by one + of the players) if there were 75 moves without a pawn move or capture or + a fivefold repetition. Let `is_game_over()` respect that. Introduce + `is_seventyfive_moves()` and `is_fivefold_repitition()`. Other means of + ending a game take precedence. + +* Threefold repetition checking requires efficient hashing of positions + to build the table. So performance improvements were needed there. The + default polyglot compatible zobrist hashes are now built incrementally. + +* Fix low level rotation operations `l90()`, `l45()` and `r45()`. There was + no problem in core because correct versions of the functions were inlined. + +* Fix equality and inequality operators for `Bitboard`, `Move` and `Piece`. + Also make them robust against comparisons with incompatible types. + +* Provide equality and inequality operators for `SquareSet` and + `polyglot.Entry`. + +* Fix return values of incremental arithmetical operations for `SquareSet`. + +* Make `polyglot.Entry` a `collections.namedtuple`. + +* Determine and improve test coverage. + +* Minor coding style fixes. + +New in v0.3.1 (15th Aug 2014) +----------------------------- + +* `Bitboard.status()` now correctly detects `STATUS_INVALID_EP_SQUARE`, + instead of errors or false reports. + +* Polyglot opening book reader now correctly handles knight underpromotions. + +* Minor coding style fixes, including removal of unused imports. + +New in v0.3.0 (13th Aug 2014) +----------------------------- + +* Rename property `half_moves` of `Bitboard` to `halfmove_clock`. + +* Rename property `ply` of `Bitboard` to `fullmove_number`. + +* Let PGN parser handle symbols like `!`, `?`, `!?` and so on by converting + them to NAGs. + +* Add a human readable string representation for Bitboards. + + .. code:: python + + >>> print(chess.Bitboard()) + r n b q k b n r + p p p p p p p p + . . . . . . . . + . . . . . . . . + . . . . . . . . + . . . . . . . . + P P P P P P P P + R N B Q K B N R + +* Various documentation improvements. + +New in v0.2.0 +------------- + +* **Implement PGN parsing and writing.** +* Hugely improve test coverage and use Travis CI for continuous integration and + testing. +* Create an API documentation. +* Improve Polyglot opening-book handling. + +New in v0.1.0 +------------- + +Apply the lessons learned from the previous releases, redesign the API and +implement it in pure Python. + +New in v0.0.4 +------------- + +Implement the basics in C++ and provide bindings for Python. Obviously +performance was a lot better - but at the expense of having to compile +code for the target platform. + +Pre v0.0.4 +---------- + +First experiments with a way too slow pure Python API, creating way too many +objects for basic operations. diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d1a8daae6..03a9555d1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,1419 +1,408 @@ Changelog for python-chess ========================== -New in v0.24.1, v0.23.11 ------------------------- +New in v1.11.2 (25th Feb 2025) +------------------------------ Bugfixes: -* Fix `chess.Board.set_epd()` and `chess.Board.from_epd()` with semicolon - in string operand. Thanks @jdart1. -* `chess.pgn.GameNode.uci()` was always raising an exception. - Also included in v0.24.0. +* Fix ``chess.gaviota.PythonTablebase`` does not properly resolve positions + where en passant captures are the best move. -New in v0.24.0 --------------- - -This release **drops support for Python 2**. The *0.23.x* branch will be -maintained for one more month. - -Changes: - -* **Require Python 3.4.** Thanks @hugovk. -* No longer using extra pip features: - `pip install python-chess[engine,gaviota]` is now `pip install python-chess`. -* Various keyword arguments can now be used as **keyword arguments only**. -* `chess.pgn.GameNode.accept()` now - **also visits the move leading to that node**. -* `chess.pgn.GameModelCreator` now requires that `begin_game()` be called. -* `chess.pgn.scan_headers()` and `chess.pgn.scan_offsets()` have been removed. - Instead the new functions `chess.pgn.read_headers()` and - `chess.pgn.skip_game()` can be used for a similar purpose. -* `chess.syzygy`: Invalid magic headers now raise `IOError`. Previously they - were only checked in an assertion. - `type(board).{tbw_magic,tbz_magic,pawnless_tbw_magic,pawnless_tbz_magic}` - are now byte literals. -* `board.status()` constants (`STATUS_`) are now typed using `enum.IntFlag`. - Values remain unchanged. -* `chess.svg.Arrow` is no longer a `namedtuple`. -* `chess.PIECE_SYMBOLS[0]` and `chess.PIECE_NAMES[0]` are now `None` instead - of empty strings. -* Performance optimizations: - - * `chess.pgn.Game.from_board()`, - * `chess.square_name()` - * Replace `collections.deque` with lists almost everywhere. - -* Renamed symbols (aliases will be removed in the next release): - - * `chess.BB_VOID` -> `BB_EMPTY` - * `chess.bswap()` -> `flip_vertical()` - * `chess.pgn.GameNode.main_line()` -> `mainline_moves()` - * `chess.pgn.GameNode.is_main_line()` -> `is_mainline()` - * `chess.variant.BB_HILL` -> `chess.BB_CENTER` - * `chess.syzygy.open_tablebases()` -> `open_tablebase()` - * `chess.syzygy.Tablebases` -> `Tablebase` - * `chess.syzygy.Tablebase.open_directory()` -> `add_directory()` - * `chess.gaviota.open_tablebases()` -> `open_tablebase()` - * `chess.gaviota.open_tablebases_native()` -> `open_tablebase_native()` - * `chess.gaviota.NativeTablebases` -> `NativeTablebase` - * `chess.gaviota.PythonTablebases` -> `PythonTablebase` - * `chess.gaviota.NativeTablebase.open_directory()` -> `add_directory()` - * `chess.gaviota.PythonTablebase.open_directory()` -> `add_directory()` - -Bugfixes: - -* The PGN parser now gives the visitor a chance to handle unknown chess - variants and continue parsing. -* `chess.pgn.GameNode.uci()` was always raising an exception. - -New features: - -* `chess.SquareSet` now extends `collections.abc.MutableSet` and can be - initialized from iterables. -* `board.apply_transform(f)` and `board.transform(f)` can apply bitboard - transformations to a position. Examples: - `chess.flip_{vertical,horizontal,diagonal,anti_diagonal}`. -* `chess.pgn.GameNode.mainline()` iterates over nodes of the mainline. - Can also be used with `reversed()`. Reversal is now also supported for - `chess.pgn.GameNode.mainline_moves()`. -* `chess.svg.Arrow(tail, head, color="#888")` gained an optional *color* - argument. -* `chess.pgn.BaseVisitor.parse_san(board, san)` is used by parsers and can - be overwritten to deal with non-standard input formats. -* `chess.pgn`: Visitors can advise the parser to skip games or variations by - returning the special value `chess.pgn.SKIP` from `begin_game()`, - `end_headers()` or `begin_variation()`. This is only a hint. - The corresponding `end_game()` or `end_variation()` will still be called. -* Added `chess.svg.MARGIN`. - -New in v0.23.10 ---------------- +New in v1.11.1 (9th Oct 2024) +----------------------------- Bugfixes: -* `chess.SquareSet` now correctly handles negative masks. Thanks @hasnul. -* `chess.pgn` now accepts `[Variant "chess 960"]` (with the space). +* ``chess.engine``: Fix parsing of UCI options containing containing ``name``, + ``type``, ``default``, ``min``, or ``max``, e.g., ``mini``. -New in v0.23.9 --------------- +New in v1.11.0 (4th Oct 2024) +----------------------------- Changes: -* Updated `Board.is_fivefold_repetition()`. FIDE rules have changed and the - repetition no longer needs to occur on consecutive alternating moves. - Thanks @LegionMammal978. - -New in v0.23.8 --------------- - -Bugfixes: - -* `chess.syzygy`: Correctly initialize wide DTZ map for experimental 7 piece - table KRBBPvKQ. - -New in v0.23.7 --------------- - -Bugfixes: - -* Fixed `ThreeCheckBoard.mirror()` and `CrazyhouseBoard.mirror()`, which - were previously resetting remaining checks and pockets respectively. - Thanks @QueensGambit. - -Changes: - -* `Board.move_stack` is now guaranteed to be UCI compatible with respect to - the representation of castling moves and `board.chess960`. -* Drop support for Python 3.3, which is long past end of life. -* `chess.uci`: The `position` command now manages `UCI_Chess960` and - `UCI_Variant` automatically. -* `chess.uci`: The `position` command will now always send the entire history - of moves from the root position. -* Various coding style fixes and improvements. Thanks @hugovk. +* Drop support for Python 3.7, which has reached its end of life. +* ``chess.engine.EventLoopPolicy`` is no longer needed and now merely an alias + for the default event loop policy. +* If available and requested via ``setpgrp``, use ``process_group`` support + from Python 3.11 for engine processes. +* No longer eagerly reject 8 piece positions in ``chess.syzygy``, so that + some 8 piece positions with decisive captures can be probed successfully. +* The string wrapper returned by ``chess.svg`` functions now also implements + ``_repr_html_``. +* Significant changes to ``chess.engine`` internals: + ``chess.engine.BaseCommand`` methods other than the constructor no longer + receive ``engine: Protocol``. +* Significant changes to board state internals: Subclasses of ``chess.Board`` + can no longer hook into board state recording/restoration and need to + override relevant methods instead (``clear_stack``, ``copy``, ``root``, + ``push``, ``pop``). New features: -* Added `Board.root()`. - -New in v0.23.6 --------------- +* Add ``chess.pgn.Game.time_control()`` and related data models. +* Add model ``sf16.1`` for ``chess.engine.Score.wdl()``, the new default. Bugfixes: -* Gaviota: Fix Python based Gaviota tablebase probing when there are multiple - en passant captures. Thanks @bjoernholzhauer. -* Syzygy: Fix DTZ for some mate in 1 positions. Similarly to the fix from - v0.23.1 this is mostly cosmetic. -* Syzygy: Fix DTZ off-by-one in some 6 piece antichess positions with moves - that threaten to force a capture. This is mostly cosmetic. - -Changes: +* Fix unsolicited engine output may cause assertion errors with regard to + command states. +* Fix handling of whitespace in UCI engine communication. +* For ``chess.Board.epd()`` and ``chess.Board.set_epd()``, require that EPD + opcodes start with a letter. -* Let `uci.Engine.position()` send history of at least 8 moves if available. - Previously it sent only moves that were relevant for repetition detection. - This is mostly useful for Lc0. Once performance issues are solved, a future - version will always send the entire history. Thanks @SashaMN and @Mk-Chan. -* Various documentation fixes and improvements. +New in v1.10.0 (27th Jul 2023) +------------------------------ New features: -* Added `polyglot.MemoryMappedReader.get(board, default=None)`. - -New in v0.23.5 --------------- +* Use ``chess.engine.Opponent`` to send opponent information to engines. +* Inform engines about the game result using + ``chess.engine.Protocol.send_game_result()``. +* Add ``chess.engine.Limit.clock_id``. +* Add ``chess.svg.board(..., borders=True)``. +* Avoid rendering background behind SVG boards to better support transparency. +* Add ``chess.pgn.BaseVisitor.begin_parse_san()``. +* Introduce new distance metrics ``chess.square_manhattan_distance()`` and + ``chess.square_knight_distance()``. Bugfixes: -* Atomic chess: KNvKN is not insufficient material. -* Crazyhouse: Detect insufficient material. This can not happen unless the - game was started with insufficient material. +* Fix ``chess.pgn.GameNode.eval()`` sometimes off by one centipawn. +* Fix handling of additional spaces between UCI option tokens. +* Handle implicit XBoard engine resignation via output like + ``0-1 {White resigns}``. Changes: -* Better error messages when parsing info from UCI engine fails. -* Better error message for `b.set_board_fen(b.fen())`. - -New in v0.23.4 --------------- +* Add model ``sf16`` for ``chess.engine.Score.wdl()``, the new default. +* Update ``lichess`` WDL model. +* Keep PGN headers that do not belong to the Seven Tag Roster in insertion + order. +* Halve the number of open file descriptors maintained by tablebases + and opening books. +* Reduce verbosity of logged ``chess.pgn`` errors. -New features: - -* XBoard: Support pondering. Thanks Manik Charan. -* UCI: Support unofficial `info ebf`. +New in v1.9.4 (22nd Dec 2022) +----------------------------- Bugfixes: -* Implement 16 bit DTZ mapping, which is required for some of the longest - 7 piece endgames. - -New in v0.23.3 --------------- +* Fix ``PovScore.wdl()`` ignored ``model`` and ``ply`` parameters. +* ``chess.syzygy``: Check that board matches tablebase variant. New features: -* XBoard: Support `variant`. Thanks gbtami. +* Add model ``sf15.1`` for ``chess.engine.Score.wdl()``. +* Raise more specific exceptions: ``chess.IllegalMoveError``, + ``chess.AmbiguousMoveError``, and ``chess.InvalidMoveError``. -New in v0.23.2 --------------- +New in v1.9.3 (16th Sep 2022) +----------------------------- Bugfixes: -* XBoard: Handle multiple features and features with spaces. Thanks gbtami. -* XBoard: Ignore debug output prefixed with `#`. Thanks Dan Ravensloft and - Manik Charan. +* Fix some valid characters were not accepted in PGN tag names. -New in v0.23.1 --------------- - -Bugfixes: - -* Fix DTZ in case of mate in 1. This is a cosmetic fix, as the previous - behavior was only off by one (which is allowed by design). +Changes: -New in v0.23.0 --------------- +* Skip over syntactically invalid PGN tags. +* Detect Antichess insufficient material with two opposing knights. New features: -* Experimental support for 7 piece Syzygy tablebases. - -Changes: +* Add ``chess.Board.unicode(..., orientation=chess.WHITE)``. -* `chess.syzygy.filenames()` was renamed to `tablenames()` and - gained an optional `piece_count=6` argument. -* `chess.syzygy.normalize_filename()` was renamed to `normalize_tablename()`. -* The undocumented constructors of `chess.syzygy.WdlTable` and - `chess.syzygy.DtzTable` have been changed. - -New in v0.22.2 --------------- +New in v1.9.2 (17th Jun 2022) +----------------------------- Bugfixes: -* In standard chess promoted pieces were incorrectly considered as - distinguishable from normal pieces with regard to position equality - and threefold repetition. Thanks to kn-sq-tb for reporting. - -Changes: - -* The PGN `game.headers` are now a custom mutable mapping that validates the - validity of tag names. -* Basic attack and pin methods moved to `BaseBoard`. -* Documentation fixes and improvements. +* Fix recursive Crazyhouse move generation sometimes failing with + with ``RuntimeError``. +* Fix rendering of black pawn SVG on dark background. New features: -* Added `Board.lan()` for long algebraic notation. - -New in v0.22.1 --------------- +* Add ``chess.engine.AnalysisResult.would_block()``. -New features: - -* Added `Board.mirror()`, `SquareSet.mirror()` and `bswap()`. -* Added `chess.pgn.GameNode.accept_subgame()`. -* XBoard: Added `resign`, `analyze`, `exit`, `name`, `rating`, `computer`, - `egtpath`, `pause`, `resume`. Completed option parsing. - -Changes: - -* `chess.pgn`: Accept FICS wilds without warning. -* XBoard: Inform engine about game results. +New in v1.9.1 (28th May 2022) +----------------------------- Bugfixes: -* `chess.pgn`: Allow games without movetext. -* XBoard: Fixed draw handling. - -New in v0.22.0 --------------- +* Reject pawn capture SAN if the original file is not specified, e.g., + ``d5`` will no longer match ``cxd5``. Changes: -* `len(board.legal_moves)` **replaced by** `board.legal_moves.count()`. - Previously `list(board.legal_moves)` was generating moves twice, resulting in - a considerable slowdown. Thanks to Martin C. Doege for reporting. -* **Dropped Python 2.6 support.** -* XBoard: `offer_draw` renamed to `draw`. +* Tweak handling of whitespace in PGN comments: When parsing, any leading + and trailing whitespace (beyond one space) is preserved. When joining + multiple PGN comments, they are now separated with a space instead of a + newline character. When removing annotations from comments, leftover + whitespace is avoided. New features: -* XBoard: Added `DrawHandler`. +* Add model ``sf15`` for ``chess.engine.Score.wdl()``. -New in v0.21.2 --------------- - -Changes: - -* `chess.svg` is now fully SVG Tiny 1.2 compatible. Removed - `chess.svg.DEFAULT_STYLE` which would from now on be always empty. - -New in v0.21.1 --------------- +New in v1.9.0 (18th Mar 2022) +----------------------------- Bugfixes: -* `Board.set_piece_at()` no longer shadows optional `promoted` - argument from `BaseBoard`. -* Fixed `ThreeCheckBoard.is_irreversible()` and - `ThreeCheckBoard._transposition_key()`. +* Expand position validation to detect check conflicting with en passant + square. New features: -* Added `Game.without_tag_roster()`. `chess.pgn.StringExporter()` can now - handle games without any headers. -* XBoard: `white`, `black`, `random`, `nps`, `otim`, `undo`, `remove`. Thanks - to Manik Charan. - -Changes: - -* Documentation fixes and tweaks by Boštjan Mejak. -* Changed unicode character for empty squares in `Board.unicode()`. - -New in v0.21.0 --------------- - -Release yanked. +* Add ``chess.svg.board(..., fill=...)``. +* Let ``chess.svg.board()`` add ASCII board as description of SVG. +* Add hint when engine process dies due to illegal instruction. -New in v0.20.1 --------------- +New in v1.8.0 (23rd Dec 2021) +----------------------------- Bugfixes: -* Fix arrow positioning on SVG boards. -* Documentation fixes and improvements, making most doctests runnable. - -New in v0.20.0 --------------- - -Bugfixes: - -* Some XBoard commands were not returning futures. -* Support semicolon comments in PGNs. - -Changes: - -* Changed FEN and EPD formatting options. It is now possible to include en - passant squares in FEN and X-FEN style, or to include only strictly relevant - en passant squares. -* Relax en passant square validation in `Board.set_fen()`. -* Ensure `is_en_passant()`, `is_capture()`, `is_zeroing()` and - `is_irreversible()` strictly return bools. -* Accept `Z0` as a null move in PGNs. +* Fix ``SquareSet.issuperset()`` and ``SquareSet.issubset()`` by swapping + their respective implementations. New features: -* XBoard: Add `memory`, `core`, `stop` and `movenow` commands. - Abstract `post`/`nopost`. Initial `FeatureMap` support. Support `usermove`. -* Added `Board.has_pseudo_legal_en_passant()`. -* Added `Board.piece_map()`. -* Added `SquareSet.carry_rippler()`. -* Factored out some (unstable) low level APIs: `BB_CORNERS`, - `_carry_rippler()`, `_edges()`. +* Read and write PGN comments like ``[%emt 0:05:21]``. -New in v0.19.0 --------------- +New in v1.7.0 (7th Oct 2021) +---------------------------- New features: -* **Experimental XBoard engine support.** Thanks to Manik Charan and - Cash Costello. Expect breaking changes in future releases. -* Added an undocumented `chess.polyglot.ZobristHasher` to make Zobrist hashing - easier to extend. +* Add new models for ``chess.engine.Score.wdl()``: ``sf`` (the new default) + and ``sf14``. +* Add ``chess.Board.piece_map()``. Bugfixes: -* Merely pseudo-legal en passant does no longer count for repetitions. -* Fixed repetition detection in Three-Check and Crazyhouse. (Previously - check counters and pockets were ignored.) -* Checking moves in Three-Check are now considered as irreversible by - `ThreeCheckBoard.is_irreversible()`. -* `chess.Move.from_uci("")` was raising `IndexError` instead of `ValueError`. - Thanks Jonny Balls. - -Changes: +* ``chess.pgn``: Fix skipping with nested variations. +* ``chess.svg``: Make check gradient compatible with QtSvg. -* `chess.syzygy.Tablebases` constructor no longer supports directly opening - a directory. Use `chess.syzygy.open_tablebases()`. -* `chess.gaviota.PythonTablebases` and `NativeTablebases` constructors - no longer support directly opening a directory. - Use `chess.gaviota.open_tablebases()`. -* `chess.Board` instances are now compared by the position they represent, - not by exact match of the internal data structures (or even move history). -* Relaxed castling right validation in Chess960: Kings/rooks of opposing sites - are no longer required to be on the same file. -* Removed misnamed `Piece.__unicode__()` and `BaseBoard.__unicode__()`. Use - `Piece.unicode_symbol()` and `BaseBoard.unicode()` instead. -* Changed `chess.SquareSet.__repr__()`. -* Support `[Variant "normal"]` in PGNs. -* `pip install python-chess[engine]` instead of `python-chess[uci]` (since - the extra dependencies are required for both UCI and XBoard engines). -* Mixed documentation fixes and improvements. - -New in v0.18.4 --------------- - -Changes: - -* Support `[Variant "fischerandom"]` in PGNs for Cutechess compability. - Thanks to Steve Maughan for reporting. - -New in v0.18.3 --------------- +New in v1.6.1 (12th Jun 2021) +----------------------------- Bugfixes: -* `chess.gaviota.NativeTablebases.get_dtm()` and `get_wdl()` were missing. - -New in v0.18.2 --------------- - -Bugfixes: - -* Fixed castling in atomic chess when there is a rank attack. -* The halfmove clock in Crazyhouse is no longer incremented unconditionally. - `CrazyhouseBoard.is_zeroing(move)` now considers pawn moves and captures as - zeroing. Added `Board.is_irreversible(move)` that can be used instead. -* Fixed an inconsistency where the `chess.pgn` tokenizer accepts long algebraic - notation but `Board.parse_san()` did not. - -Changes: - -* Added more NAG constants in `chess.pgn`. - -New in v0.18.1 --------------- - -Bugfixes: - -* Crazyhouse drops were accepted as pseudo legal (and legal) even if the - respective piece was not in the pocket. -* `CrazyhouseBoard.pop()` was failing to undo en passant moves. -* `CrazyhouseBoard.pop()` was always returning `None`. -* `Move.__copy__()` was failing to copy Crazyhouse drops. -* Fix ~ order (marker for promoted pieces) in FENs. -* Promoted pieces in Crazyhouse were not communicated with UCI engines. - -Changes: - -* `ThreeCheckBoard.uci_variant` changed from `threecheck` to `3check`. - -New in v0.18.0 --------------- - -Bugfixes: - -* Fixed `Board.parse_uci()` for crazyhouse drops. Thanks to Ryan Delaney. -* Fixed `AtomicBoard.is_insufficient_material()`. -* Fixed signature of `SuicideBoard.was_into_check()`. -* Explicitly close input and output streams when a `chess.uci.PopenProcess` - terminates. -* The documentation of `Board.attackers()` was wrongly stating that en passant - capturable pawns are considered attacked. - -Changes: - -* `chess.SquareSet` is no longer hashable (since it is mutable). -* Removed functions and constants deprecated in v0.17.0. -* Dropped `gmpy2` and `gmpy` as optional dependencies. They were no longer - improving performance. -* Various tweaks and optimizations for 5% improvement in PGN parsing and perft - speed. (Signature of `_is_safe` and `_ep_skewered` changed). -* Rewritten `chess.svg.board()` using `xml.etree`. No longer supports *pre* and - *post*. Use an XML parser if you need to modify the SVG. Now only inserts - actually used piece defintions. -* Untangled UCI process and engine instanciation, changing signatures of - constructors and allowing arbitrary arguments to `subprocess.Popen`. -* Coding style and documentation improvements. - -New features: - -* `chess.svg.board()` now supports arrows. Thanks to @rheber for implementing - this feature. -* Let `chess.uci.PopenEngine` consistently handle Ctrl+C across platforms - and Python versions. `chess.uci.popen_engine()` now supports a `setpgrp` - keyword argument to start the engine process in a new process group. - Thanks to @dubiousjim. -* Added `board.king(color)` to find the (royal) king of a given side. -* SVGs now have `viewBox` and `chess.svg.board(size=None)` supports and - defaults to `None` (i.e. scaling to the size of the container). - -New in v0.17.0 --------------- - -Changes: - -* Rewritten move generator, various performance tweaks, code simplications - (500 lines removed) amounting to **doubled PGN parsing and perft speed**. -* Removed `board.generate_evasions()` and `board.generate_non_evasions()`. -* Removed `board.transpositions`. Transpositions are now counted on demand. -* `file_index()`, `rank_index()`, and `pop_count()` have been renamed to - `square_file()`, `square_rank()` and `popcount()` respectively. Aliases will - be removed in some future release. -* `STATUS_ILLEGAL_CHECK` has been renamed to `STATUS_RACE_CHECK`. The alias - will be removed in a future release. -* Removed `DIAG_ATTACKS_NE`, `DIAG_ATTACKS_NW`, `RANK_ATTACKS` and - `FILE_ATTACKS` as well as the corresponding masks. New attack tables - `BB_DIAG_ATTACKS` (combined both diagonal tables), `BB_RANK_ATTACKS` and - `BB_FILE_ATTACKS` are indexed by square instead of mask. -* `board.push()` no longer requires pseudo-legality. -* Documentation improvements. - -Bugfixes: +* Make ``chess.engine.SimpleEngine.play(..., draw_offered=True)`` available. + Previously only added for ``chess.engine.Protocol``. -* **Positions in variant end are now guaranteed to have no legal moves.** - `board.is_variant_end()` has been added to test for special variant end - conditions. Thanks to salvador-dali. -* `chess.svg`: Fixed a typo in the class names of black queens. Fixed fill - color for black rooks and queens. Added SVG Tiny support. These combined - changes fix display in a number of applications, including - Jupyter Qt Console. Thanks to Alexander Meshcheryakov. -* `board.ep_square` was not consistently `None` instead of `0`. -* Detect invalid racing kings positions: `STATUS_RACE_OVER`, - `STATUS_RACE_MATERIAL`. -* `SAN_REGEX`, `FEN_CASTLING_REGEX` and `TAG_REGEX` now try to match the - entire string and no longer accept newlines. -* Fixed `Move.__hash__()` for drops. +New in v1.6.0 (11th Jun 2021) +----------------------------- New features: -* `board.remove_piece_at()` now returns the removed piece. -* Added `square_distance()` and `square_mirror()`. -* Added `msb()`, `lsb()`, `scan_reversed()` and `scan_forward()`. -* Added `BB_RAYS` and `BB_BETWEEN`. - -New in v0.16.2 --------------- +* Allow offering a draw to XBoard engines using + ``chess.engine.Protocol.play(..., draw_offered=True)``. +* Now detects insufficient material in Horde. Thanks @stevepapazis! Changes: -* `board.move_stack` now contains the exact move objects added with - `Board.push()` (instead of normalized copies for castling moves). - This ensures they can be used with `Board.variation_san()` amongst others. -* `board.ep_square` is now `None` instead of `0` for no en passant square. -* `chess.svg`: Better vector graphics for knights. Thanks to ProgramFox. -* Documentation improvements. - -New in v0.16.1 --------------- +* ``chess.engine.popen_engine(..., setpgrp=True)`` on Windows now merges + ``CREATE_NEW_PROCESS_GROUP`` into ``creationflags`` instead of overriding. + On Unix it now uses ``start_new_session`` instead of calling ``setpgrp`` in + ``preexec_fn``. +* Declare that ``chess.svg`` produces SVG Tiny 1.2, and prepare SVG 2 forwards + compatibility. Bugfixes: -* Explosions in atomic chess were not destroying castling rights. Thanks to - ProgramFOX for finding this issue. +* Fix slightly off-center pawns in ``chess.svg``. +* Fix typing error in Python 3.10 (due to added ``int.bit_count``). -New in v0.16.0 --------------- +New in v1.5.0 (7th Apr 2021) +---------------------------- Bugfixes: -* `pin_mask()`, `pin()` and `is_pinned()` make more sense when already - in check. Thanks to Ferdinand Mosca. +* Fixed typing of ``chess.pgn.Mainline.__reversed__()``. It is now a generator, + and ``chess.pgn.ReverseMainline`` has been **removed**. + This is a breaking change but a required bugfix. +* Implement UCI **ponderhit** for consecutive calls to + ``chess.engine.Protocol.play(..., ponder=True)``. Previously, the pondering + search was always stopped and restarted. +* Provide the full move stack, not just the position, for UCI pondering. +* Fixed XBoard level in sudden death games. +* Ignore trailing space after ponder move sent by UCI engine. + Previously, such a move would be rejected. +* Prevent cancelling engine commands after they have already been cancelled or + completed. Some internals (``chess.engine.BaseCommand``) have been changed to + accomplish this. New features: -* **Variant support: Suicide, Giveaway, Atomic, King of the Hill, Racing Kings, - Horde, Three-check, Crazyhouse.** `chess.Move` now supports drops. -* More fine grained dependencies. Use *pip install python-chess[uci,gaviota]* to - install dependencies for the full feature set. -* Added `chess.STATUS_EMPTY` and `chess.STATUS_ILLEGAL_CHECK`. -* The `board.promoted` mask keeps track of promoted pieces. -* Optionally copy boards without the move stack: `board.copy(stack=False)`. -* `examples/bratko_kopec` now supports avoid move (am), variants and - displays fractional scores immidiately. Thanks to Daniel Dugovic. -* `perft.py` rewritten with multi-threading support and moved to - `examples/perft`. -* `chess.syzygy.dependencies()`, `chess.syzygy.all_dependencies()` to generate - Syzygy tablebase dependencies. - -Changes: - -* **Endgame tablebase probing (Syzygy, Gaviota):** `probe_wdl()` **,** - `probe_dtz()` **and** `probe_dtm()` **now raise** `KeyError` **or** - `MissingTableError` **instead of returning** *None*. If you prefer getting - `None` in case of an error use `get_wdl()`, `get_dtz()` and `get_dtm()`. -* `chess.pgn.BaseVisitor.result()` returns `True` by default and is no longer - used by `chess.pgn.read_game()` if no game was found. -* Non-fast-forward update of the Git repository to reduce size (old binary - test assets removed). -* `board.pop()` now uses a boardstate stack to undo moves. -* `uci.engine.position()` will send the move history only until the latest - zeroing move. -* Optimize `board.clean_castling_rights()` and micro-optimizations improving - PGN parser performance by around 20%. -* Syzygy tables now directly use the endgame name as hash keys. -* Improve test performance (especially on Travis CI). -* Documentation updates and improvements. - -New in v0.15.4 --------------- - -New features: - -* Highlight last move and checks when rendering board SVGs. - -New in v0.15.3 --------------- - -Bugfixes: - -* `pgn.Game.errors` was not populated as documented. Thanks to Ryan Delaney - for reporting. - -New features: - -* Added `pgn.GameNode.add_line()` and `pgn.GameNode.main_line()` which make - it easier to work with lists of moves as variations. - -New in v0.15.2 --------------- +* Added ``chess.Board.outcome()``. +* Implement and accept usermove feature for XBoard engines. -Bugfixes: - -* Fix a bug where `shift_right()` and `shift_2_right()` were producing - integers larger than 64bit when shifting squares off the board. This is - very similar to the bug fixed in v0.15.1. Thanks to piccoloprogrammatore - for reporting. - -New in v0.15.1 --------------- +Special thanks to @MarkZH for many of the engine related changes in this +release! -Bugfixes: - -* Fix a bug where `shift_up_right()` and `shift_up_left()` were producing - integers larger than 64bit when shifting squares off the board. +New in v1.4.0 (25th Jan 2021) +----------------------------- New features: -* Replaced __html__ with experimental SVG rendering for IPython. - -New in v0.15.0 --------------- +* Let ``chess.pgn.GameNode.eval()`` accept PGN comments like + ``[%eval 2.5,11]``, meaning 250 centipawns at depth 11. + Use ``chess.pgn.GameNode.eval_depth()`` and + ``chess.pgn.GameNode.set_eval(..., depth)`` to get and set the depth. +* Read and write PGN comments with millisecond precision like + ``[%clk 1:23:45.678]``. Changes: -* `chess.uci.Score` **no longer has** `upperbound` **and** `lowerbound` - **attributes**. Previously these were always *False*. +* Recover from invalid UTF-8 sent by an UCI engine, by ignoring that + (and only that) line. -* Significant improvements of move generation speed, around **2.3x faster - PGN parsing**. Removed the following internal attributes and methods of - the `Board` class: `attacks_valid`, `attacks_to`, `attacks_from`, - `_pinned()`, `attacks_valid_stack`, `attacks_from_stack`, `attacks_to_stack`, - `generate_attacks()`. - -* UCI: Do not send *isready* directly after *go*. Though allowed by the UCI - protocol specification it is just not nescessary and many engines were having - trouble with this. - -* Polyglot: Use less memory for uniform random choices from big opening books - (reservoir sampling). - -* Documentation improvements. +New in v1.3.3 (27th Dec 2020) +----------------------------- Bugfixes: -* Allow underscores in PGN header tags. Found and fixed by Bajusz Tamás. - -New features: - -* Added `Board.chess960_pos()` to identify the Chess960 starting position - number of positions. - -* Added `chess.BB_BACKRANKS` and `chess.BB_PAWN_ATTACKS`. - -New in v0.14.1 --------------- - -Bugfixes: - -* Backport Bugfix for Syzygy DTZ related to en-passant. - See official-stockfish/Stockfish@6e2ca97d93812b2. +* Fixed unintended collisions and optimized ``chess.Piece.__hash__()``. +* Fixed false-positive ``chess.STATUS_IMPOSSIBLE_CHECK`` if checkers are + aligned with other king. Changes: -* Added optional argument *max_fds=128* to `chess.syzygy.open_tablebases()`. - An LRU cache is used to keep at most *max_fds* files open. This allows using - many tables without running out of file descriptors. - Previously all tables were opened at once. - -* Syzygy and Gaviota now store absolute tablebase paths, in case you change - the working directory of the process. - -* The default implementation of `chess.uci.InfoHandler.score()` will no longer - store score bounds in `info["score"]`, only real scores. - -* Added `Board.set_chess960_pos()`. - -* Documentation improvements. - -New in v0.14.0 --------------- - -Changes: - -* `Board.attacker_mask()` **has been renamed to** `Board.attackers_mask()` for - consistency. - -* **The signature of** `Board.generate_legal_moves()` **and** - `Board.generate_pseudo_legal_moves()` **has been changed.** Previously it - was possible to select piece types for selective move generation: - - `Board.generate_legal_moves(castling=True, pawns=True, knights=True, bishops=True, rooks=True, queens=True, king=True)` - - Now it is possible to select arbitrary sets of origin and target squares. - `to_mask` uses the corresponding rook squares for castling moves. - - `Board.generate_legal_moves(from_mask=BB_ALL, to_mask=BB)` - - To generate all knight and queen moves do: - - `board.generate_legal_moves(board.knights | board.queens)` - - To generate only castling moves use: - - `Board.generate_castling_moves(from_mask=BB_ALL, to_mask=BB_ALL)` - -* Additional hardening has been added on top of the bugfix from v0.13.3. - Diagonal skewers on the last double pawn move are now handled correctly, - even though such positions can not be reached with a sequence of legal moves. - -* `chess.syzygy` now uses the more efficient selective move generation. +* Also detect ``chess.STATUS_IMPOSSIBLE_CHECK`` if checker is aligned with + en passant square and king. New features: -* The following move generation methods have been added: - `Board.generate_pseudo_legal_ep(from_mask=BB_ALL, to_mask=BB_ALL)`, - `Board.generate_legal_ep(from_mask=BB_ALL, to_mask=BB_ALL)`, - `Board.generate_pseudo_legal_captures(from_mask=BB_ALL, to_mask=BB_ALL)`, - `Board.generate_legal_captures(from_mask=BB_ALL, to_mask=BB_ALL)`. +* Implemented Lichess winning chance model for ``chess.engine.Score``: + ``score.wdl(model="lichess")``. - -New in v0.13.3 --------------- - -**This is a bugfix release for a move generation bug.** Other than the bugfix -itself there are only minimal fully backwardscompatible changes. -You should update immediately. +New in v1.3.2 (12th Dec 2020) +----------------------------- Bugfixes: -* When capturing en passant, both the capturer and the captured pawn disappear - from the fourth or fifth rank. If those pawns were covering a horizontal - attack on the king, then capturing en passant should not have been legal. - - `Board.generate_legal_moves()` and `Board.is_into_check()` have been fixed. +* Added a new reason for ``board.status()`` to be invalid: + ``chess.STATUS_IMPOSSIBLE_CHECK``. This detects positions where two sliding + pieces are giving check while also being aligned with the king + on the same rank, file, or diagonal. Such positions are impossible to reach, + break Stockfish, and maybe other engines. - The same principle applies for diagonal skewers, but nothing has been done - in this release: If the last double pawn move covers a diagonal attack, then - the king would have already been in check. - - v0.14.0 adds additional hardening for all cases. It is recommended you - upgrade to v0.14.0 as soon as you can deal with the - non-backwards compatible changes. - -Changes: - -* `chess.uci` now uses `subprocess32` if applicable (and available). - Additionally a lock is used to work around a race condition in Python 2, that - can occur when spawning engines from multiple threads at the same time. - -* Consistently handle tabs in UCI engine output. - -New in v0.13.2 --------------- - -Changes: - -* `chess.syzygy.open_tablebases()` now raises if the given directory - does not exist. - -* Allow visitors to handle invalid `FEN` tags in PGNs. - -* Gaviota tablebase probing fails faster for piece counts > 5. - -Minor new features: - -* Added `chess.pgn.Game.from_board()`. - -New in v0.13.1 --------------- - -Changes: - -* Missing *SetUp* tags in PGNs are ignored. - -* Incompatible comparisons on `chess.Piece`, `chess.Move`, `chess.Board` - and `chess.SquareSet` now return *NotImplemented* instead of *False*. - -Minor new features: - -* Factored out basic board operations to `chess.BaseBoard`. This is inherited - by `chess.Board` and extended with the usual move generation features. - -* Added optional *claim_draw* argument to `chess.Base.is_game_over()`. - -* Added `chess.Board.result(claim_draw=False)`. - -* Allow `chess.Board.set_piece_at(square, None)`. - -* Added `chess.SquareSet.from_square(square)`. - -New in v0.13.0 --------------- - -* `chess.pgn.Game.export()` and `chess.pgn.GameNode.export()` have been - removed and replaced with a new visitor concept. - -* `chess.pgn.read_game()` no longer takes an `error_handler` argument. Errors - are now logged. Use the new visitor concept to change this behaviour. - -New in v0.12.5 --------------- +New in v1.3.1 (6th Dec 2020) +---------------------------- Bugfixes: -* Context manager support for pure Python Gaviota probing code. Various - documentation fixes for Gaviota probing. Thanks to Jürgen Précour for - reporting. +* ``chess.pgn.read_game()`` now properly detects variant games with Chess960 + castling rights (as well as mislabeled Standard Chess960 games). Previously, + all castling moves in such games were rejected. -* PGN variation start comments for variations on the very first move were - assigned to the game. Thanks to Norbert Räcke for reporting. - -New in v0.12.4 --------------- - -Bugfixes: - -* Another en passant related Bugfix for pure Python Gaviota tablebase probing. - -New features: - -* Added `pgn.GameNode.is_end()`. +New in v1.3.0 (6th Nov 2020) +---------------------------- Changes: -* Big speedup for `pgn` module. Boards are cached less agressively. Board - move stacks are copied faster. - -* Added tox.ini to specify test suite and flake8 options. +* Introduced ``chess.pgn.ChildNode``, a subclass of ``chess.pgn.GameNode`` + for all nodes other than the root node, and converted ``chess.pgn.GameNode`` + to an abstract base class. This improves ergonomics in typed code. -New in v0.12.3 --------------- + The change is backwards compatible if using only documented features. + However, a notable undocumented feature is the ability to create dangling + nodes. This is no longer possible. If you have been using this for + subclassing, override ``GameNode.add_variation()`` instead of + ``GameNode.dangling_node()``. It is now the only method that creates child + nodes. Bugfixes: -* Some invalid castling rights were silently ignored by `Board.set_fen()`. Now - it is ensured information is stored for retrieval using `Board.status()`. - -New in v0.12.2 --------------- - -Bugfixes: - -* Some Gaviota probe results were incorrect for positions where black could - capture en passant. - -New in v0.12.1 --------------- - -Changes: - -* Robust handling of invalid castling rights. You can also use the new - method `Board.clean_castling_rights()` to get the subset of strictly valid - castling rights. - -New in v0.12.0 --------------- +* Removed broken ``weakref``-based caching in ``chess.pgn.GameNode.board()``. New features: -* Python 2.6 support. Patch by vdbergh. +* Added ``chess.pgn.GameNode.next()``. -* Pure Python Gaviota tablebase probing. Thanks to Jean-Noël Avila. - -New in v0.11.1 --------------- +New in v1.2.2 (29th Oct 2020) +----------------------------- Bugfixes: -* `syzygy.Tablebases.probe_dtz()` has was giving wrong results for some - positions with possible en passant capturing. This was found and fixed - upstream: https://github.com/official-stockfish/Stockfish/issues/394. - -* Ignore extra spaces in UCI `info` lines, as for example sent by the - Hakkapeliitta engine. Thanks to Jürgen Précour for reporting. +* Fixed regression where releases were uploaded without the ``py.typed`` + marker. -New in v0.11.0 --------------- +New in v1.2.1 (26th Oct 2020) +----------------------------- Changes: -* **Chess960** support and the **representation of castling moves** has been - changed. - - The constructor of board has a new `chess960` argument, defaulting to - `False`: `Board(fen=STARTING_FEN, chess960=False)`. That property is - available as `Board.chess960`. +* The primary location for the published package is now + https://pypi.org/project/chess/. Thanks to + `Kristian Glass `_ for transferring the + namespace. - In Chess960 mode the behaviour is as in the previous release. Castling moves - are represented as a king move to the corresponding rook square. + The old https://pypi.org/project/python-chess/ will remain an alias that + installs the package from the new location as a dependency (as recommended by + `PEP423 `_). - In the default standard chess mode castling moves are represented with - the standard UCI notation, e.g. `e1g1` for king-side castling. + ``ModuleNotFoundError: No module named 'chess'`` after upgrading from + previous versions? Run ``pip install --force-reinstall chess`` + (due to https://github.com/niklasf/python-chess/issues/680). - `Board.uci(move, chess960=None)` creates UCI representations for moves. - Unlike `Move.uci()` it can convert them in the context of the current - position. - - `Board.has_chess960_castling_rights()` has been added to test for castling - rights that are impossible in standard chess. - - The modules `chess.polyglot`, `chess.pgn` and `chess.uci` will transparently - handle both modes. - -* In a previous release `Board.fen()` has been changed to only display an - en passant square if a legal en passant move is indeed possible. This has - now also been adapted for `Board.shredder_fen()` and `Board.epd()`. +New in v1.2.0 (22nd Oct 2020) +----------------------------- New features: -* Get individual FEN components: `Board.board_fen()`, `Board.castling_xfen()`, - `Board.castling_shredder_fen()`. - -* Use `Board.has_legal_en_passant()` to test if a position has a legal - en passant move. - -* Make `repr(board.legal_moves)` human readable. - -New in v0.10.1 --------------- - -Bugfixes: - -* Fix use-after-free in Gaviota tablebase initialization. - -New in v0.10.0 --------------- - -New dependencies: - -* If you are using Python < 3.2 you have to install `futures` in order to - use the `chess.uci` module. +* Added ``chess.Board.ply()``. +* Added ``chess.pgn.GameNode.ply()`` and ``chess.pgn.GameNode.turn()``. +* Added ``chess.engine.PovWdl``, ``chess.engine.Wdl``, and conversions from + scores: ``chess.engine.PovScore.wdl()``, ``chess.engine.Score.wdl()``. +* Added ``chess.engine.Score.score(*, mate_score: int) -> int`` overload. Changes: -* There are big changes in the UCI module. Most notably in async mode multiple - commands can be executed at the same time (e.g. `go infinite` and then - `stop` or `go ponder` and then `ponderhit`). - - `go infinite` and `go ponder` will now wait for a result, i.e. you may have - to call `stop` or `ponderhit` from a different thread or run the commands - asynchronously. - - `stop` and `ponderhit` no longer have a result. - -* The values of the color constants `chess.WHITE` and `chess.BLACK` have been - changed. Previously `WHITE` was `0`, `BLACK` was `1`. Now `WHITE` is `True`, - `BLACK` is `False`. The recommended way to invert `color` is using - `not color`. - -* The pseudo piece type `chess.NONE` has been removed in favor of just using - `None`. - -* Changed the `Board(fen)` constructor. If the optional `fen` argument is not - given behavior did not change. However if `None` is passed explicitly an - empty board is created. Previously the starting position would have been - set up. - -* `Board.fen()` will now only show completely legal en passant squares. - -* `Board.set_piece_at()` and `Board.remove_piece_at()` will now clear the - move stack, because the old moves may not be valid in the changed position. - -* `Board.parse_uci()` and `Board.push_uci()` will now accept null moves. - -* Changed shebangs from `#!/usr/bin/python` to `#!/usr/bin/env python` for - better virtualenv support. - -* Removed unused game data files from repository. - -Bugfixes: - -* PGN: Prefer the game result from the game termination marker over `*` in the - header. These should be identical in standard compliant PGNs. Thanks to - Skyler Dawson for reporting this. - -* Polyglot: `minimum_weight` for `find()`, `find_all()` and `choice()` was - not respected. - -* Polyglot: Negative indexing of opening books was raising `IndexError`. - -* Various documentation fixes and improvements. - -New features: - -* Experimental probing of Gaviota tablebases via libgtb. - -* New methods to construct boards: - - .. code:: python - - >>> chess.Board.empty() - Board('8/8/8/8/8/8/8/8 w - - 0 1') - - >>> board, ops = chess.Board.from_epd("4k3/8/8/8/8/8/8/4K3 b - - fmvn 17; hmvc 13") - >>> board - Board('4k3/8/8/8/8/8/8/4K3 b - - 13 17') - >>> ops - {'fmvn': 17, 'hmvc': 13} - -* Added `Board.copy()` and hooks to let the copy module to the right thing. - -* Added `Board.has_castling_rights(color)`, - `Board.has_kingside_castling_rights(color)` and - `Board.has_queenside_castling_rights(color)`. - -* Added `Board.clear_stack()`. - -* Support common set operations on `chess.SquareSet()`. - -New in v0.9.1 -------------- - -Bugfixes: - -* UCI module could not handle castling ponder moves. Thanks to Marco Belli for - reporting. -* The initial move number in PGNs was missing, if black was to move in the - starting position. Thanks to Jürgen Précour for reporting. -* Detect more impossible en passant squares in `Board.status()`. There already - was a requirement for a pawn on the fifth rank. Now the sixth and seventh - rank must be empty, additionally. We do not do further retrograde analysis, - because these are the only cases affecting move generation. - -New in v0.8.3 -------------- - -Bugfixes: - -* The initial move number in PGNs was missing, if black was to move in the - starting position. Thanks to Jürgen Précour for reporting. -* Detect more impossible en passant squares in `Board.status()`. There already - was a requirement for a pawn on the fifth rank. Now the sixth and seventh - rank must be empty, additionally. We do not do further retrograde analysis, - because these are the only cases affecting move generation. - -New in v0.9.0 -------------- - -**This is a big update with quite a few breaking changes. Carefully review -the changes before upgrading. It's no problem if you can not update right now. -The 0.8.x branch still gets bugfixes.** - -Incompatible changes: - -* Removed castling right constants. Castling rights are now represented as a - bitmask of the rook square. For example: - - .. code:: python - - >>> board = chess.Board() - - >>> # Standard castling rights. - >>> board.castling_rights == chess.BB_A1 | chess.BB_H1 | chess.BB_A8 | chess.BB_H8 - True - - >>> # Check for the presence of a specific castling right. - >>> can_white_castle_queenside = chess.BB_A1 & board.castling_rights - - Castling moves were previously encoded as the corresponding king movement in - UCI, e.g. `e1f1` for white kingside castling. **Now castling moves are - encoded as a move to the corresponding rook square** (`UCI_Chess960`-style), - e.g. `e1a1`. - - You may use the new methods `Board.uci(move, chess960=True)`, - `Board.parse_uci(uci)` and `Board.push_uci(uci)` to handle this - transparently. - - The `uci` module takes care of converting moves when communicating with an - engine that is not in `UCI_Chess960` mode. - -* The `get_entries_for_position(board)` method of polyglot opening book readers - has been changed to `find_all(board, minimum_weight=1)`. By default entries - with weight 0 are excluded. - -* The `Board.pieces` lookup list has been removed. - -* In 0.8.1 the spelling of repetition (was repitition) was fixed. - `can_claim_threefold_repetition()` and `is_fivefold_repetition()` are the - affected method names. Aliases are now removed. - -* `Board.set_epd()` will now interpret `bm`, `am` as a list of moves for the - current position and `pv` as a variation (represented by a list of moves). - Thanks to Jordan Bray for reporting this. - -* Removed `uci.InfoHandler.pre_bestmove()` and - `uci.InfoHandler.post_bestmove()`. - -* `uci.InfoHandler().info["score"]` is now relative to multipv. Use - - .. code:: python - - >>> with info_handler as info: - ... if 1 in info["score"]: - ... cp = info["score"][1].cp - - where you were previously using - - .. code:: python - - >>> with info_handler as info: - ... if "score" in info: - ... cp = info["score"].cp - -* Clear `uci.InfoHandler()` dictionary at the start of new searches - (new `on_go()`), not at the end of searches. - -* Renamed `PseudoLegalMoveGenerator.bitboard` and `LegalMoveGenerator.bitboard` - to `PseudoLegalMoveGenerator.board` and `LegalMoveGenerator.board`, - respectively. - -* Scripts removed. - -* Python 3.2 compability dropped. Use Python 3.3 or higher. Python 2.7 support - is not affected. +* The ``PovScore`` returned by ``chess.pgn.GameNode.eval()`` is now always + relative to the side to move. The ambiguity around ``[%eval #0]`` has been + resolved to ``Mate(-0)``. This makes sense, given that the authors of the + specification probably had standard chess in mind (where a game-ending move + is always a loss for the opponent). Previously, this would be parsed as + ``None``. +* Typed ``chess.engine.InfoDict["wdl"]`` as the new ``chess.engine.PovWdl``, + rather than ``Tuple[int, int, int]``. The new type is backwards compatible, + but it is recommended to use its documented fields and methods instead. +* Removed ``chess.engine.PovScore.__str__()``. String representation falls back + to ``__repr__``. +* The ``en_passant`` parameter of ``chess.Board.fen()`` and + ``chess.Board.epd()`` is now typed as ``Literal["legal", "fen", "xfen"]`` + rather than ``str``. + +New in v1.1.0 (4th Oct 2020) +---------------------------- New features: -* **Introduced Chess960 support.** `Board(fen)` and `Board.set_fen(fen)` now - support X-FENs. Added `Board.shredder_fen()`. - `Board.status(allow_chess960=True)` has an optional argument allowing to - insist on standard chess castling rules. - Added `Board.is_valid(allow_chess960=True)`. +* Added ``chess.svg.board(..., orientation)``. This is a more idiomatic way to + set the board orientation than ``flipped``. +* Added ``chess.svg.Arrow.pgn()`` and ``chess.svg.Arrow.from_pgn()``. -* **Improved move generation using** `Shatranj-style direct lookup - `_. **Removed rotated bitboards. Perft - speed has been more than doubled.** - -* Added `choice(board)` and `weighted_choice(board)` for polyglot opening book - readers. - -* Added `Board.attacks(square)` to determine attacks *from* a given square. - There already was `Board.attackers(color, square)` returning attacks *to* - a square. - -* Added `Board.is_en_passant(move)`, `Board.is_capture(move)` and - `Board.is_castling(move)`. - -* Added `Board.pin(color, square)` and `Board.is_pinned(color, square)`. - -* There is a new method `Board.pieces(piece_type, color)` to get a set of - squares with the specified pieces. - -* Do expensive Syzygy table initialization on demand. - -* Allow promotions like `e8Q` (usually `e8=Q`) in `Board.parse_san()` and - PGN files. +Changes: -* Patch by Richard C. Gerkin: Added `Board.__unicode__()` just like - `Board.__str__()` but with unicode pieces. -* Patch by Richard C. Gerkin: Added `Board.__html__()`. +* Further relaxed ``chess.Board.parse_san()``. Now accepts fully specified moves + like ``e2e4``, even if that is not a pawn move, castling notation with zeros, + null moves in UCI notation, and null moves in XBoard notation. -New in v0.8.2 -------------- +New in v1.0.1 (24th Sep 2020) +----------------------------- Bugfixes: -* `pgn.Game.setup()` with the standard starting position was failing when the - standard starting position was already set. Thanks to Jordan Bray for - reporting this. - -Optimizations: - -* Remove `bswap()` from Syzygy decompression hot path. Directly read integers - with the correct endianness. - -New in v0.8.1 -------------- - -* Fixed pondering mode in uci module. For example `ponderhit()` was blocking - indefinitely. Thanks to Valeriy Huz for reporting this. - -* Patch by Richard C. Gerkin: Moved searchmoves to the end of the UCI go - command, where it will not cause other command parameters to be ignored. - -* Added missing check or checkmate suffix to castling SANs, e.g. `O-O-O#`. - -* Fixed off-by-one error in polyglot opening book binary search. This would - not have caused problems for real opening books. - -* Fixed Python 3 support for reverse polyglot opening book iteration. - -* Bestmoves may be literally `(none)` in UCI protocol, for example in - checkmate positions. Fix parser and return `None` as the bestmove in this - case. - -* Fixed spelling of repetition (was repitition). - `can_claim_threefold_repetition()` and `is_fivefold_repetition()` are the - affected method names. Aliases are there for now, but will be removed in the - next release. Thanks to Jimmy Patrick for reporting this. - -* Added `SquareSet.__reversed__()`. - -* Use containerized tests on Travis CI, test against Stockfish 6, improved - test coverage amd various minor clean-ups. - -New in v0.8.0 -------------- - -* **Implement Syzygy endgame tablebase probing.** - `https://syzygy-tables.info `_ - is an example project that provides a public API using the new features. - -* The interface for aynchronous UCI command has changed to mimic - `concurrent.futures`. `is_done()` is now just `done()`. Callbacks will - receive the command object as a single argument instead of the result. - The `result` property and `wait()` have been removed in favor of a - synchronously waiting `result()` method. - -* The result of the `stop` and `go` UCI commands are now named tuples (instead - of just normal tuples). - -* Add alias `Board` for `Bitboard`. - -* Fixed race condition during UCI engine startup. Lines received during engine - startup sometimes needed to be processed before the Engine object was fully - initialized. - -New in v0.7.0 -------------- - -* **Implement UCI engine communication.** - -* Patch by Matthew Lai: `Add caching for gameNode.board()`. - -New in v0.6.0 -------------- - -* If there are comments in a game before the first move, these are now assigned - to `Game.comment` instead of `Game.starting_comment`. `Game.starting_comment` - is ignored from now on. `Game.starts_variation()` is no longer true. - The first child node of a game can no longer have a starting comment. - It is possible to have a game with `Game.comment` set, that is otherwise - completely empty. - -* Fix export of games with variations. Previously the moves were exported in - an unusual (i.e. wrong) order. - -* Install `gmpy2` or `gmpy` if you want to use slightly faster binary - operations. - -* Ignore superfluous variation opening brackets in PGN files. - -* Add `GameNode.san()`. - -* Remove `sparse_pop_count()`. Just use `pop_count()`. - -* Remove `next_bit()`. Now use `bit_scan()`. - -New in v0.5.0 -------------- - -* PGN parsing is now more robust: `read_game()` ignores invalid tokens. - Still exceptions are going to be thrown on illegal or ambiguous moves, but - this behaviour can be changed by passing an `error_handler` argument. - - .. code:: python - - >>> # Raises ValueError: - >>> game = chess.pgn.read_game(file_with_illegal_moves) - - .. code:: python - - >>> # Silently ignores errors and continues parsing: - >>> game = chess.pgn.read_game(file_with_illegal_moves, None) - - .. code:: python - - >>> # Logs the error, continues parsing: - >>> game = chess.pgn.read_game(file_with_illegal_moves, logger.exception) - - If there are too many closing brackets this is now ignored. - - Castling moves like 0-0 (with zeros) are now accepted in PGNs. - The `Bitboard.parse_san()` method remains strict as always, though. - - Previously the parser was strictly following the PGN spefification in that - empty lines terminate a game. So a game like - - :: - - [Event "?"] - - { Starting comment block } - - 1. e4 e5 2. Nf3 Nf6 * - - would have ended directly after the starting comment. To avoid this, the - parser will now look ahead until it finds at least one move or a termination - marker like `*`, `1-0`, `1/2-1/2` or `0-1`. - -* Introduce a new function `scan_headers()` to quickly scan a PGN file for - headers without having to parse the full games. - -* Minor testcoverage improvements. - -New in v0.4.2 -------------- - -* Fix bug where `pawn_moves_from()` and consequently `is_legal()` weren't - handling en passant correctly. Thanks to Norbert Naskov for reporting. - -New in v0.4.1 -------------- - -* Fix `is_fivefold_repitition()`: The new fivefold repetition rule requires - the repetitions to occur on *alternating consecutive* moves. - -* Minor testing related improvements: Close PGN files, allow running via - setuptools. - -* Add recently introduced features to README. - -New in v0.4.0 -------------- - -* Introduce `can_claim_draw()`, `can_claim_fifty_moves()` and - `can_claim_threefold_repitition()`. - -* Since the first of July 2014 a game is also over (even without claim by one - of the players) if there were 75 moves without a pawn move or capture or - a fivefold repetition. Let `is_game_over()` respect that. Introduce - `is_seventyfive_moves()` and `is_fivefold_repitition()`. Other means of - ending a game take precedence. - -* Threefold repetition checking requires efficient hashing of positions - to build the table. So performance improvements were needed there. The - default polyglot compatible zobrist hashes are now built incrementally. - -* Fix low level rotation operations `l90()`, `l45()` and `r45()`. There was - no problem in core because correct versions of the functions were inlined. - -* Fix equality and inequality operators for `Bitboard`, `Move` and `Piece`. - Also make them robust against comparisons with incompatible types. - -* Provide equality and inequality operators for `SquareSet` and - `polyglot.Entry`. - -* Fix return values of incremental arithmetical operations for `SquareSet`. - -* Make `polyglot.Entry` a `collections.namedtuple`. - -* Determine and improve test coverage. - -* Minor coding style fixes. - -New in v0.3.1 -------------- - -* `Bitboard.status()` now correctly detects `STATUS_INVALID_EP_SQUARE`, - instead of errors or false reports. - -* Polyglot opening book reader now correctly handles knight underpromotions. - -* Minor coding style fixes, including removal of unused imports. - -New in v0.3.0 -------------- - -* Rename property `half_moves` of `Bitboard` to `halfmove_clock`. - -* Rename property `ply` of `Bitboard` to `fullmove_number`. - -* Let PGN parser handle symbols like `!`, `?`, `!?` and so on by converting - them to NAGs. - -* Add a human readable string representation for Bitboards. - - .. code:: python - - >>> print(chess.Bitboard()) - r n b q k b n r - p p p p p p p p - . . . . . . . . - . . . . . . . . - . . . . . . . . - . . . . . . . . - P P P P P P P P - R N B Q K B N R - -* Various documentation improvements. - -New in v0.2.0 -------------- - -* **Implement PGN parsing and writing.** -* Hugely improve test coverage and use Travis CI for continuous integration and - testing. -* Create an API documentation. -* Improve Polyglot opening-book handling. - -New in v0.1.0 -------------- - -Apply the lessons learned from the previous releases, redesign the API and -implement it in pure Python. - -New in v0.0.4 -------------- - -Implement the basics in C++ and provide bindings for Python. Obviously -performance was a lot better - but at the expense of having to compile -code for the target platform. +* ``chess.svg``: Restored SVG Tiny compatibility by splitting colors like + ``#rrggbbaa`` into a solid color and opacity. -Pre v0.0.4 ----------- +New in v1.0.0 (24th Sep 2020) +----------------------------- -First experiments with a way too slow pure Python API, creating way too many -objects for basic operations. +See ``CHANGELOG-OLD.rst`` for changes up to v1.0.0. diff --git a/MANIFEST.in b/MANIFEST.in index 0265f5ea9..77ac05205 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,2 @@ -include README.rst include CHANGELOG.rst include LICENSE.txt diff --git a/README.rst b/README.rst index 879d898c4..d4d51a128 100644 --- a/README.rst +++ b/README.rst @@ -1,23 +1,27 @@ -python-chess: a pure Python chess library -========================================= +python-chess: a chess library for Python +======================================== -.. image:: https://travis-ci.org/niklasf/python-chess.svg?branch=master - :target: https://travis-ci.org/niklasf/python-chess +.. image:: https://github.com/niklasf/python-chess/workflows/Test/badge.svg + :target: https://github.com/niklasf/python-chess/actions + :alt: Test status -.. image:: https://coveralls.io/repos/github/niklasf/python-chess/badge.svg?branch=master - :target: https://coveralls.io/github/niklasf/python-chess?branch=master - -.. image:: https://badge.fury.io/py/python-chess.svg - :target: https://pypi.python.org/pypi/python-chess +.. image:: https://badge.fury.io/py/chess.svg + :target: https://pypi.python.org/pypi/chess + :alt: PyPI package .. image:: https://readthedocs.org/projects/python-chess/badge/?version=latest :target: https://python-chess.readthedocs.io/en/latest/ + :alt: Docs + +.. image:: https://badges.gitter.im/python-chess/community.svg + :target: https://gitter.im/python-chess/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge + :alt: Chat on Gitter Introduction ------------ -python-chess is a pure Python chess library with move generation, move -validation and support for common formats. This is the Scholar's mate in +python-chess is a chess library for Python, with move generation, +move validation, and support for common formats. This is the Scholar's mate in python-chess: .. code:: python @@ -52,6 +56,16 @@ python-chess: >>> board Board('r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4') +Installing +---------- + +Requires Python 3.8+. Download and install the latest release: + +:: + + pip install chess + + `Documentation `__ -------------------------------------------------------------------- @@ -60,14 +74,14 @@ python-chess: * `Polyglot opening book reading `_ * `Gaviota endgame tablebase probing `_ * `Syzygy endgame tablebase probing `_ -* `UCI engine communication `_ +* `UCI/XBoard engine communication `_ * `Variants `_ * `Changelog `_ Features -------- -* Supports Python 3.4+ and PyPy3. +* Includes mypy typings. * IPython/Jupyter Notebook integration. `SVG rendering docs `_. @@ -77,6 +91,7 @@ Features >>> board # doctest: +SKIP .. image:: https://backscattering.de/web-boardimage/board.png?fen=r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR&lastmove=h5f7&check=e8 + :alt: r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR * Chess variants: Standard, Chess960, Suicide, Giveaway, Atomic, King of the Hill, Racing Kings, Horde, Three-check, Crazyhouse. @@ -115,8 +130,8 @@ Features False >>> board.is_insufficient_material() False - >>> board.is_game_over() - True + >>> board.outcome() + Outcome(termination=, winner=True) * Detects repetitions. Has a half-move clock. @@ -153,7 +168,7 @@ Features >>> attackers = board.attackers(chess.WHITE, chess.F3) >>> attackers - SquareSet(0x0000000000004040) + SquareSet(0x0000_0000_0000_4040) >>> chess.G2 in attackers True >>> print(attackers) @@ -215,12 +230,10 @@ Features >>> board = chess.Board() >>> main_entry = book.find(board) - >>> main_entry.move() + >>> main_entry.move Move.from_uci('e2e4') >>> main_entry.weight 1 - >>> main_entry.learn - 0 >>> book.close() @@ -266,68 +279,82 @@ Features >>> tablebase.close() -* Communicate with an UCI engine. - `Docs `__. +* Communicate with UCI/XBoard engines. Based on ``asyncio``. + `Docs `__. .. code:: python - >>> import chess.uci + >>> import chess.engine - >>> engine = chess.uci.popen_engine("stockfish") - >>> engine.uci() - >>> engine.author # doctest: +SKIP - 'Tord Romstad, Marco Costalba and Joona Kiiski' + >>> engine = chess.engine.SimpleEngine.popen_uci("stockfish") - >>> # Synchronous mode. >>> board = chess.Board("1k1r4/pp1b1R2/3q2pp/4p3/2B5/4Q3/PPP2B2/2K5 b - - 0 1") - >>> engine.position(board) - >>> engine.go(movetime=2000) # Gets a tuple of bestmove and ponder move - BestMove(bestmove=Move.from_uci('d6d1'), ponder=Move.from_uci('c1d1')) - - >>> # Asynchronous mode. - >>> def callback(command): - ... bestmove, ponder = command.result() - ... assert bestmove == chess.Move.from_uci('d6d1') - ... - >>> command = engine.go(movetime=2000, async_callback=callback) - >>> command.done() - False - >>> command.result() - BestMove(bestmove=Move.from_uci('d6d1'), ponder=Move.from_uci('c1d1')) - >>> command.done() - True + >>> limit = chess.engine.Limit(time=2.0) + >>> engine.play(board, limit) # doctest: +ELLIPSIS + - >>> # Quit. >>> engine.quit() - 0 - -Installing ----------- - -Download and install the latest release: -:: - - pip install python-chess - -Selected use cases ------------------- - -If you like, let me know if you are creating something intresting with -python-chess, for example: - -* a stand-alone chess computer based on DGT board – http://www.picochess.org/ -* a website to probe Syzygy endgame tablebases – https://syzygy-tables.info/ -* a GUI to play against UCI chess engines – http://johncheetham.com/projects/jcchess/ -* deep learning for Crazyhouse – https://github.com/QueensGambit/CrazyAra +Selected projects +----------------- + +If you like, share interesting things you are using python-chess for, for example: + ++------------------------------------------------------------------------------------------------------+----------------------------------------------------------------------------------------------+ +| .. image:: https://github.com/niklasf/python-chess/blob/master/docs/images/syzygy.png?raw=true | https://syzygy-tables.info/ | +| :height: 64 | | +| :width: 64 | | +| :target: https://syzygy-tables.info/ | A website to probe Syzygy endgame tablebases | ++------------------------------------------------------------------------------------------------------+----------------------------------------------------------------------------------------------+ +| .. image:: https://github.com/niklasf/python-chess/blob/master/docs/images/maia.png?raw=true | https://maiachess.com/ | +| :height: 64 | | +| :width: 64 | | +| :target: https://maiachess.com/ | A human-like neural network chess engine | ++------------------------------------------------------------------------------------------------------+----------------------------------------------------------------------------------------------+ +| .. 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 | 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 | | +| :width: 64 | | +| :target: https://crazyara.org/ | Deep learning for Crazyhouse | ++------------------------------------------------------------------------------------------------------+----------------------------------------------------------------------------------------------+ +| .. image:: https://github.com/niklasf/python-chess/blob/master/docs/images/jcchess.png?raw=true | `http://johncheetham.com `_ | +| :height: 64 | | +| :width: 64 | | +| :target: http://johncheetham.com/projects/jcchess/ | A GUI to play against UCI chess engines | ++------------------------------------------------------------------------------------------------------+----------------------------------------------------------------------------------------------+ +| .. image:: https://github.com/niklasf/python-chess/blob/master/docs/images/pettingzoo.png?raw=true | `https://pettingzoo.farama.org `_ | +| :width: 64 | | +| :height: 64 | | +| :target: https://pettingzoo.farama.org/environments/classic/chess/ | A multi-agent reinforcement learning environment | ++------------------------------------------------------------------------------------------------------+----------------------------------------------------------------------------------------------+ +| .. image:: https://github.com/niklasf/python-chess/blob/master/docs/images/cli-chess.png?raw=true | `cli-chess `_ | +| :width: 64 | | +| :height: 64 | | +| :target: https://github.com/trevorbayless/cli-chess | A highly customizable way to play chess in your terminal | ++------------------------------------------------------------------------------------------------------+----------------------------------------------------------------------------------------------+ + +* extensions to build engines (search and evaluation) – https://github.com/Mk-Chan/python-chess-engine-extensions +* a stand-alone chess computer based on DGT board – https://picochess.com/ +* a bridge between Lichess API and chess engines – https://github.com/lichess-bot-devs/lichess-bot * a command-line PGN annotator – https://github.com/rpdelaney/python-chess-annotator -* a bot to play chess on Telegram – https://github.com/cxjdavin/tgchessbot * an HTTP microservice to render board images – https://github.com/niklasf/web-boardimage -* a bridge between Lichess API and chess engines – https://github.com/careless25/lichess-bot +* building a toy chess engine with alpha-beta pruning, piece-square tables, and move ordering – https://healeycodes.com/building-my-own-chess-engine/ * a JIT compiled chess engine – https://github.com/SamRagusa/Batch-First +* teaching Cognitive Science – `https://jupyter.brynmawr.edu `_ +* an `Alexa skill to play blindfold chess `_ – https://github.com/laynr/blindfold-chess +* a chessboard widget for PySide2 – https://github.com/H-a-y-k/hichesslib +* Django Rest Framework API for multiplayer chess – https://github.com/WorkShoft/capablanca-api +* a `browser based PGN viewer `_ written in PyScript – https://github.com/nmstoker/ChessMatchViewer +* an accessible chessboard that allows blind and visually impaired players to play chess against Stockfish – https://github.com/blindpandas/chessmart +* a web-based chess vision exercise – https://github.com/3d12/rookognition + -Acknowledgements ----------------- +Prior art +--------- Thanks to the Stockfish authors and thanks to Sam Tannous for publishing his approach to `avoid rotated bitboards with direct lookup (PDF) `_ @@ -338,12 +365,11 @@ Thanks to Ronald de Man for his `Syzygy endgame tablebases `_. The probing code in python-chess is very directly ported from his C probing code. -Thanks to Miguel A. Ballicora for his -`Gaviota tablebases `_. -(I wish the generating code was free software.) +Thanks to `Kristian Glass `_ for +transferring the namespace ``chess`` on PyPI. License ------- python-chess is licensed under the GPL 3 (or any later version at your option). -Check out LICENSE.txt for the full text. +Check out ``LICENSE.txt`` for the full text. diff --git a/chess/__init__.py b/chess/__init__.py index 64f3c95d7..9ea44f36e 100644 --- a/chess/__init__.py +++ b/chess/__init__.py @@ -1,62 +1,91 @@ -# -*- coding: utf-8 -*- -# -# This file is part of the python-chess library. -# Copyright (C) 2012-2018 Niklas Fiekas -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - """ -A pure Python chess library with move generation and validation, Polyglot -opening book probing, PGN reading and writing, Gaviota tablebase probing, -Syzygy tablebase probing and XBoard/UCI engine communication. +A chess library with move generation and validation, +Polyglot opening book probing, PGN reading and writing, +Gaviota tablebase probing, +Syzygy tablebase probing, and XBoard/UCI engine communication. """ +from __future__ import annotations + __author__ = "Niklas Fiekas" __email__ = "niklas.fiekas@backscattering.de" -__version__ = "0.24.1" +__version__ = "1.11.2" import collections -import collections.abc import copy +import dataclasses import enum +import math import re import itertools -import struct +import typing + +from typing import ClassVar, Callable, Counter, Dict, Hashable, Iterable, Iterator, List, Literal, Mapping, Optional, SupportsInt, Tuple, Type, TypeVar, Union + +if typing.TYPE_CHECKING: + from typing_extensions import Self, TypeAlias + +EnPassantSpec = Literal["legal", "fen", "xfen"] -COLORS = [WHITE, BLACK] = [True, False] -COLOR_NAMES = ["black", "white"] -Color = bool -PIECE_TYPES = [PAWN, KNIGHT, BISHOP, ROOK, QUEEN, KING] = range(1, 7) +Color: TypeAlias = bool +WHITE: Color = True +BLACK: Color = False +COLORS: List[Color] = [WHITE, BLACK] +ColorName = Literal["white", "black"] +COLOR_NAMES: List[ColorName] = ["black", "white"] + +PieceType: TypeAlias = int +PAWN: PieceType = 1 +KNIGHT: PieceType = 2 +BISHOP: PieceType = 3 +ROOK: PieceType = 4 +QUEEN: PieceType = 5 +KING: PieceType = 6 +PIECE_TYPES: List[PieceType] = [PAWN, KNIGHT, BISHOP, ROOK, QUEEN, KING] PIECE_SYMBOLS = [None, "p", "n", "b", "r", "q", "k"] PIECE_NAMES = [None, "pawn", "knight", "bishop", "rook", "queen", "king"] -PieceType = int + +def piece_symbol(piece_type: PieceType) -> str: + return typing.cast(str, PIECE_SYMBOLS[piece_type]) + +def piece_name(piece_type: PieceType) -> str: + return typing.cast(str, PIECE_NAMES[piece_type]) UNICODE_PIECE_SYMBOLS = { - "R": u"♖", "r": u"♜", - "N": u"♘", "n": u"♞", - "B": u"♗", "b": u"♝", - "Q": u"♕", "q": u"♛", - "K": u"♔", "k": u"♚", - "P": u"♙", "p": u"♟", + "R": "♖", "r": "♜", + "N": "♘", "n": "♞", + "B": "♗", "b": "♝", + "Q": "♕", "q": "♛", + "K": "♔", "k": "♚", + "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" @@ -66,28 +95,25 @@ """The board part of the FEN for the standard chess starting position.""" -try: - _IntFlag = enum.IntFlag # Since Python 3.6 -except AttributeError: - _IntFlag = enum.IntEnum - -class Status(_IntFlag): +class Status(enum.IntFlag): VALID = 0 - NO_WHITE_KING = 1 - NO_BLACK_KING = 2 - TOO_MANY_KINGS = 4 - TOO_MANY_WHITE_PAWNS = 8 - TOO_MANY_BLACK_PAWNS = 16 - PAWNS_ON_BACKRANK = 32 - TOO_MANY_WHITE_PIECES = 64 - TOO_MANY_BLACK_PIECES = 128 - BAD_CASTLING_RIGHTS = 256 - INVALID_EP_SQUARE = 512 - OPPOSITE_CHECK = 1024 - EMPTY = 2048 - RACE_CHECK = 4096 - RACE_OVER = 8192 - RACE_MATERIAL = 16384 + NO_WHITE_KING = 1 << 0 + NO_BLACK_KING = 1 << 1 + TOO_MANY_KINGS = 1 << 2 + TOO_MANY_WHITE_PAWNS = 1 << 3 + TOO_MANY_BLACK_PAWNS = 1 << 4 + PAWNS_ON_BACKRANK = 1 << 5 + TOO_MANY_WHITE_PIECES = 1 << 6 + TOO_MANY_BLACK_PIECES = 1 << 7 + BAD_CASTLING_RIGHTS = 1 << 8 + INVALID_EP_SQUARE = 1 << 9 + OPPOSITE_CHECK = 1 << 10 + EMPTY = 1 << 11 + RACE_CHECK = 1 << 12 + RACE_OVER = 1 << 13 + RACE_MATERIAL = 1 << 14 + TOO_MANY_CHECKERS = 1 << 15 + IMPOSSIBLE_CHECK = 1 << 16 STATUS_VALID = Status.VALID STATUS_NO_WHITE_KING = Status.NO_WHITE_KING @@ -105,190 +131,415 @@ class Status(_IntFlag): STATUS_RACE_CHECK = Status.RACE_CHECK STATUS_RACE_OVER = Status.RACE_OVER STATUS_RACE_MATERIAL = Status.RACE_MATERIAL +STATUS_TOO_MANY_CHECKERS = Status.TOO_MANY_CHECKERS +STATUS_IMPOSSIBLE_CHECK = Status.IMPOSSIBLE_CHECK + + +class Termination(enum.Enum): + """Enum with reasons for a game to be over.""" + + CHECKMATE = enum.auto() + """See :func:`chess.Board.is_checkmate()`.""" + STALEMATE = enum.auto() + """See :func:`chess.Board.is_stalemate()`.""" + INSUFFICIENT_MATERIAL = enum.auto() + """See :func:`chess.Board.is_insufficient_material()`.""" + SEVENTYFIVE_MOVES = enum.auto() + """See :func:`chess.Board.is_seventyfive_moves()`.""" + FIVEFOLD_REPETITION = enum.auto() + """See :func:`chess.Board.is_fivefold_repetition()`.""" + FIFTY_MOVES = enum.auto() + """See :func:`chess.Board.can_claim_fifty_moves()`.""" + THREEFOLD_REPETITION = enum.auto() + """See :func:`chess.Board.can_claim_threefold_repetition()`.""" + VARIANT_WIN = enum.auto() + """See :func:`chess.Board.is_variant_win()`.""" + VARIANT_LOSS = enum.auto() + """See :func:`chess.Board.is_variant_loss()`.""" + VARIANT_DRAW = enum.auto() + """See :func:`chess.Board.is_variant_draw()`.""" + +@dataclasses.dataclass +class Outcome: + """ + Information about the outcome of an ended game, usually obtained from + :func:`chess.Board.outcome()`. + """ - -SQUARES = [ - A1, B1, C1, D1, E1, F1, G1, H1, - A2, B2, C2, D2, E2, F2, G2, H2, - A3, B3, C3, D3, E3, F3, G3, H3, - A4, B4, C4, D4, E4, F4, G4, H4, - A5, B5, C5, D5, E5, F5, G5, H5, - A6, B6, C6, D6, E6, F6, G6, H6, - A7, B7, C7, D7, E7, F7, G7, H7, - A8, B8, C8, D8, E8, F8, G8, H8] = range(64) -Square = int + termination: Termination + """The reason for the game to have ended.""" + + winner: Optional[Color] + """The winning color or ``None`` if drawn.""" + + def result(self) -> str: + """Returns ``1-0``, ``0-1`` or ``1/2-1/2``.""" + return "1/2-1/2" if self.winner is None else ("1-0" if self.winner else "0-1") + + +class InvalidMoveError(ValueError): + """Raised when move notation is not syntactically valid""" + + +class IllegalMoveError(ValueError): + """Raised when the attempted move is illegal in the current position""" + + +class AmbiguousMoveError(ValueError): + """Raised when the attempted move is ambiguous in the current position""" + + +Square: TypeAlias = int +A1: Square = 0 +B1: Square = 1 +C1: Square = 2 +D1: Square = 3 +E1: Square = 4 +F1: Square = 5 +G1: Square = 6 +H1: Square = 7 +A2: Square = 8 +B2: Square = 9 +C2: Square = 10 +D2: Square = 11 +E2: Square = 12 +F2: Square = 13 +G2: Square = 14 +H2: Square = 15 +A3: Square = 16 +B3: Square = 17 +C3: Square = 18 +D3: Square = 19 +E3: Square = 20 +F3: Square = 21 +G3: Square = 22 +H3: Square = 23 +A4: Square = 24 +B4: Square = 25 +C4: Square = 26 +D4: Square = 27 +E4: Square = 28 +F4: Square = 29 +G4: Square = 30 +H4: Square = 31 +A5: Square = 32 +B5: Square = 33 +C5: Square = 34 +D5: Square = 35 +E5: Square = 36 +F5: Square = 37 +G5: Square = 38 +H5: Square = 39 +A6: Square = 40 +B6: Square = 41 +C6: Square = 42 +D6: Square = 43 +E6: Square = 44 +F6: Square = 45 +G6: Square = 46 +H6: Square = 47 +A7: Square = 48 +B7: Square = 49 +C7: Square = 50 +D7: Square = 51 +E7: Square = 52 +F7: Square = 53 +G7: Square = 54 +H7: Square = 55 +A8: Square = 56 +B8: Square = 57 +C8: Square = 58 +D8: Square = 59 +E8: Square = 60 +F8: Square = 61 +G8: Square = 62 +H8: Square = 63 +SQUARES: List[Square] = list(range(64)) SQUARE_NAMES = [f + r for r in RANK_NAMES for f in FILE_NAMES] -def square(file_index, rank_index): +def parse_square(name: str) -> Square: + """ + Gets the square index for the given square *name* + (e.g., ``a1`` returns ``0``). + + :raises: :exc:`ValueError` if the square name is invalid. + """ + return SQUARE_NAMES.index(name) + +def square_name(square: Square) -> str: + """Gets the name of the square, like ``a3``.""" + return SQUARE_NAMES[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): +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 -def square_rank(square): +def square_rank(square: Square) -> Rank: """Gets the rank index of the square where ``0`` is the first rank.""" return square >> 3 -def square_name(square): - """Gets the name of the square, like ``a3``.""" - return SQUARE_NAMES[square] - -def square_distance(a, b): +def square_distance(a: Square, b: Square) -> int: """ - Gets the distance (i.e., the number of king steps) from square *a* to *b*. + Gets the Chebyshev distance (i.e., the number of king steps) from square *a* to *b*. """ return max(abs(square_file(a) - square_file(b)), abs(square_rank(a) - square_rank(b))) -def square_mirror(square): +def square_manhattan_distance(a: Square, b: Square) -> int: + """ + Gets the Manhattan/Taxicab distance (i.e., the number of orthogonal king steps) from square *a* to *b*. + """ + return abs(square_file(a) - square_file(b)) + abs(square_rank(a) - square_rank(b)) + +def square_knight_distance(a: Square, b: Square) -> int: + """ + Gets the Knight distance (i.e., the number of knight moves) from square *a* to *b*. + """ + dx = abs(square_file(a) - square_file(b)) + dy = abs(square_rank(a) - square_rank(b)) + + if dx + dy == 1: + return 3 + elif dx == dy == 2: + return 4 + elif dx == dy == 1: + if BB_SQUARES[a] & BB_CORNERS or BB_SQUARES[b] & BB_CORNERS: # Special case only for corner squares + return 4 + + m = math.ceil(max(dx / 2, dy / 2, (dx + dy) / 3)) + return m + ((m + dx + dy) % 2) + +def square_mirror(square: Square) -> Square: """Mirrors the square vertically.""" return square ^ 0x38 -SQUARES_180 = [square_mirror(sq) for sq in SQUARES] - - -BB_EMPTY = 0 -BB_ALL = 0xffffffffffffffff - -BB_SQUARES = [ - BB_A1, BB_B1, BB_C1, BB_D1, BB_E1, BB_F1, BB_G1, BB_H1, - BB_A2, BB_B2, BB_C2, BB_D2, BB_E2, BB_F2, BB_G2, BB_H2, - BB_A3, BB_B3, BB_C3, BB_D3, BB_E3, BB_F3, BB_G3, BB_H3, - BB_A4, BB_B4, BB_C4, BB_D4, BB_E4, BB_F4, BB_G4, BB_H4, - BB_A5, BB_B5, BB_C5, BB_D5, BB_E5, BB_F5, BB_G5, BB_H5, - BB_A6, BB_B6, BB_C6, BB_D6, BB_E6, BB_F6, BB_G6, BB_H6, - BB_A7, BB_B7, BB_C7, BB_D7, BB_E7, BB_F7, BB_G7, BB_H7, - BB_A8, BB_B8, BB_C8, BB_D8, BB_E8, BB_F8, BB_G8, BB_H8 -] = [1 << sq for sq in SQUARES] - -BB_CORNERS = BB_A1 | BB_H1 | BB_A8 | BB_H8 -BB_CENTER = BB_D4 | BB_E4 | BB_D5 | BB_E5 - -BB_LIGHT_SQUARES = 0x55aa55aa55aa55aa -BB_DARK_SQUARES = 0xaa55aa55aa55aa55 - -BB_FILES = [ - BB_FILE_A, - BB_FILE_B, - BB_FILE_C, - BB_FILE_D, - BB_FILE_E, - BB_FILE_F, - BB_FILE_G, - BB_FILE_H -] = [0x0101010101010101 << i for i in range(8)] - -BB_RANKS = [ - BB_RANK_1, - BB_RANK_2, - BB_RANK_3, - BB_RANK_4, - BB_RANK_5, - BB_RANK_6, - BB_RANK_7, - BB_RANK_8 -] = [0xff << (8 * i) for i in range(8)] - -BB_BACKRANKS = BB_RANK_1 | BB_RANK_8 - - -def lsb(bb): +SQUARES_180: List[Square] = [square_mirror(sq) for sq in SQUARES] + + +Bitboard: TypeAlias = int +BB_EMPTY: Bitboard = 0 +BB_ALL: Bitboard = 0xffff_ffff_ffff_ffff + +BB_A1: Bitboard = 1 << A1 +BB_B1: Bitboard = 1 << B1 +BB_C1: Bitboard = 1 << C1 +BB_D1: Bitboard = 1 << D1 +BB_E1: Bitboard = 1 << E1 +BB_F1: Bitboard = 1 << F1 +BB_G1: Bitboard = 1 << G1 +BB_H1: Bitboard = 1 << H1 +BB_A2: Bitboard = 1 << A2 +BB_B2: Bitboard = 1 << B2 +BB_C2: Bitboard = 1 << C2 +BB_D2: Bitboard = 1 << D2 +BB_E2: Bitboard = 1 << E2 +BB_F2: Bitboard = 1 << F2 +BB_G2: Bitboard = 1 << G2 +BB_H2: Bitboard = 1 << H2 +BB_A3: Bitboard = 1 << A3 +BB_B3: Bitboard = 1 << B3 +BB_C3: Bitboard = 1 << C3 +BB_D3: Bitboard = 1 << D3 +BB_E3: Bitboard = 1 << E3 +BB_F3: Bitboard = 1 << F3 +BB_G3: Bitboard = 1 << G3 +BB_H3: Bitboard = 1 << H3 +BB_A4: Bitboard = 1 << A4 +BB_B4: Bitboard = 1 << B4 +BB_C4: Bitboard = 1 << C4 +BB_D4: Bitboard = 1 << D4 +BB_E4: Bitboard = 1 << E4 +BB_F4: Bitboard = 1 << F4 +BB_G4: Bitboard = 1 << G4 +BB_H4: Bitboard = 1 << H4 +BB_A5: Bitboard = 1 << A5 +BB_B5: Bitboard = 1 << B5 +BB_C5: Bitboard = 1 << C5 +BB_D5: Bitboard = 1 << D5 +BB_E5: Bitboard = 1 << E5 +BB_F5: Bitboard = 1 << F5 +BB_G5: Bitboard = 1 << G5 +BB_H5: Bitboard = 1 << H5 +BB_A6: Bitboard = 1 << A6 +BB_B6: Bitboard = 1 << B6 +BB_C6: Bitboard = 1 << C6 +BB_D6: Bitboard = 1 << D6 +BB_E6: Bitboard = 1 << E6 +BB_F6: Bitboard = 1 << F6 +BB_G6: Bitboard = 1 << G6 +BB_H6: Bitboard = 1 << H6 +BB_A7: Bitboard = 1 << A7 +BB_B7: Bitboard = 1 << B7 +BB_C7: Bitboard = 1 << C7 +BB_D7: Bitboard = 1 << D7 +BB_E7: Bitboard = 1 << E7 +BB_F7: Bitboard = 1 << F7 +BB_G7: Bitboard = 1 << G7 +BB_H7: Bitboard = 1 << H7 +BB_A8: Bitboard = 1 << A8 +BB_B8: Bitboard = 1 << B8 +BB_C8: Bitboard = 1 << C8 +BB_D8: Bitboard = 1 << D8 +BB_E8: Bitboard = 1 << E8 +BB_F8: Bitboard = 1 << F8 +BB_G8: Bitboard = 1 << G8 +BB_H8: Bitboard = 1 << H8 +BB_SQUARES: List[Bitboard] = [1 << sq for sq in SQUARES] + +BB_CORNERS: Bitboard = BB_A1 | BB_H1 | BB_A8 | BB_H8 +BB_CENTER: Bitboard = BB_D4 | BB_E4 | BB_D5 | BB_E5 + +BB_LIGHT_SQUARES: Bitboard = 0x55aa_55aa_55aa_55aa +BB_DARK_SQUARES: Bitboard = 0xaa55_aa55_aa55_aa55 + +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 * 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 + + +def lsb(bb: Bitboard) -> int: return (bb & -bb).bit_length() - 1 -def scan_forward(bb): +def scan_forward(bb: Bitboard) -> Iterator[Square]: while bb: r = bb & -bb yield r.bit_length() - 1 bb ^= r -def msb(bb): +def msb(bb: Bitboard) -> int: return bb.bit_length() - 1 -def scan_reversed(bb, *, _BB_SQUARES=BB_SQUARES): +def scan_reversed(bb: Bitboard) -> Iterator[Square]: while bb: r = bb.bit_length() - 1 yield r - bb ^= _BB_SQUARES[r] + bb ^= BB_SQUARES[r] -def popcount(bb, *, _bin=bin): - return _bin(bb).count("1") +# Python 3.10 or fallback. +popcount: Callable[[Bitboard], int] = getattr(int, "bit_count", lambda bb: bin(bb).count("1")) -def flip_vertical(bb): +def flip_vertical(bb: Bitboard) -> Bitboard: # https://www.chessprogramming.org/Flipping_Mirroring_and_Rotating#FlipVertically - bb = ((bb >> 8) & 0x00ff00ff00ff00ff) | ((bb & 0x00ff00ff00ff00ff) << 8) - bb = ((bb >> 16) & 0x0000ffff0000ffff) | ((bb & 0x0000ffff0000ffff) << 16) - bb = (bb >> 32) | ((bb & 0x00000000ffffffff) << 32) + bb = ((bb >> 8) & 0x00ff_00ff_00ff_00ff) | ((bb & 0x00ff_00ff_00ff_00ff) << 8) + bb = ((bb >> 16) & 0x0000_ffff_0000_ffff) | ((bb & 0x0000_ffff_0000_ffff) << 16) + bb = (bb >> 32) | ((bb & 0x0000_0000_ffff_ffff) << 32) return bb -def flip_horizontal(bb): +def flip_horizontal(bb: Bitboard) -> Bitboard: # https://www.chessprogramming.org/Flipping_Mirroring_and_Rotating#MirrorHorizontally - bb = ((bb >> 1) & 0x5555555555555555) | ((bb & 0x5555555555555555) << 1) - bb = ((bb >> 2) & 0x3333333333333333) | ((bb & 0x3333333333333333) << 2) - bb = ((bb >> 4) & 0x0f0f0f0f0f0f0f0f) | ((bb & 0x0f0f0f0f0f0f0f0f) << 4) + bb = ((bb >> 1) & 0x5555_5555_5555_5555) | ((bb & 0x5555_5555_5555_5555) << 1) + bb = ((bb >> 2) & 0x3333_3333_3333_3333) | ((bb & 0x3333_3333_3333_3333) << 2) + bb = ((bb >> 4) & 0x0f0f_0f0f_0f0f_0f0f) | ((bb & 0x0f0f_0f0f_0f0f_0f0f) << 4) return bb -def flip_diagonal(bb): +def flip_diagonal(bb: Bitboard) -> Bitboard: # https://www.chessprogramming.org/Flipping_Mirroring_and_Rotating#FlipabouttheDiagonal - t = (bb ^ (bb << 28)) & 0x0f0f0f0f00000000 - bb = bb ^ (t ^ (t >> 28)) - t = (bb ^ (bb << 14)) & 0x3333000033330000 - bb = bb ^ (t ^ (t >> 14)) - t = (bb ^ (bb << 7)) & 0x5500550055005500 - bb = bb ^ (t ^ (t >> 7)) + t = (bb ^ (bb << 28)) & 0x0f0f_0f0f_0000_0000 + bb = bb ^ t ^ (t >> 28) + t = (bb ^ (bb << 14)) & 0x3333_0000_3333_0000 + bb = bb ^ t ^ (t >> 14) + t = (bb ^ (bb << 7)) & 0x5500_5500_5500_5500 + bb = bb ^ t ^ (t >> 7) return bb -def flip_anti_diagonal(bb): +def flip_anti_diagonal(bb: Bitboard) -> Bitboard: # https://www.chessprogramming.org/Flipping_Mirroring_and_Rotating#FlipabouttheAntidiagonal t = bb ^ (bb << 36) - bb = bb ^ ((t ^ (bb >> 36)) & 0xf0f0f0f00f0f0f0f) - t = (bb ^ (bb << 18)) & 0xcccc0000cccc0000 - bb = bb ^ (t ^ (t >> 18)) - t = (bb ^ (bb << 9)) & 0xaa00aa00aa00aa00 - bb = bb ^ (t ^ (t >> 9)) + bb = bb ^ ((t ^ (bb >> 36)) & 0xf0f0_f0f0_0f0f_0f0f) + t = (bb ^ (bb << 18)) & 0xcccc_0000_cccc_0000 + bb = bb ^ t ^ (t >> 18) + t = (bb ^ (bb << 9)) & 0xaa00_aa00_aa00_aa00 + bb = bb ^ t ^ (t >> 9) return bb -def shift_down(b): +def shift_down(b: Bitboard) -> Bitboard: return b >> 8 -def shift_2_down(b): +def shift_2_down(b: Bitboard) -> Bitboard: return b >> 16 -def shift_up(b): +def shift_up(b: Bitboard) -> Bitboard: return (b << 8) & BB_ALL -def shift_2_up(b): +def shift_2_up(b: Bitboard) -> Bitboard: return (b << 16) & BB_ALL -def shift_right(b): +def shift_right(b: Bitboard) -> Bitboard: return (b << 1) & ~BB_FILE_A & BB_ALL -def shift_2_right(b): +def shift_2_right(b: Bitboard) -> Bitboard: return (b << 2) & ~BB_FILE_A & ~BB_FILE_B & BB_ALL -def shift_left(b): +def shift_left(b: Bitboard) -> Bitboard: return (b >> 1) & ~BB_FILE_H -def shift_2_left(b): +def shift_2_left(b: Bitboard) -> Bitboard: return (b >> 2) & ~BB_FILE_G & ~BB_FILE_H -def shift_up_left(b): +def shift_up_left(b: Bitboard) -> Bitboard: return (b << 7) & ~BB_FILE_H & BB_ALL -def shift_up_right(b): +def shift_up_right(b: Bitboard) -> Bitboard: return (b << 9) & ~BB_FILE_A & BB_ALL -def shift_down_left(b): +def shift_down_left(b: Bitboard) -> Bitboard: return (b >> 9) & ~BB_FILE_H -def shift_down_right(b): +def shift_down_right(b: Bitboard) -> Bitboard: return (b >> 7) & ~BB_FILE_A -def _sliding_attacks(square, occupied, deltas): - attacks = 0 +def _sliding_attacks(square: Square, occupied: Bitboard, deltas: Iterable[int]) -> Bitboard: + attacks = BB_EMPTY for delta in deltas: sq = square @@ -305,30 +556,30 @@ def _sliding_attacks(square, occupied, deltas): return attacks -def _step_attacks(square, deltas): +def _step_attacks(square: Square, deltas: Iterable[int]) -> Bitboard: return _sliding_attacks(square, BB_ALL, deltas) -BB_KNIGHT_ATTACKS = [_step_attacks(sq, [17, 15, 10, 6, -17, -15, -10, -6]) for sq in SQUARES] -BB_KING_ATTACKS = [_step_attacks(sq, [9, 8, 7, 1, -9, -8, -7, -1]) for sq in SQUARES] -BB_PAWN_ATTACKS = [[_step_attacks(sq, deltas) for sq in SQUARES] for deltas in [[-7, -9], [7, 9]]] +BB_KNIGHT_ATTACKS: List[Bitboard] = [_step_attacks(sq, [17, 15, 10, 6, -17, -15, -10, -6]) for sq in SQUARES] +BB_KING_ATTACKS: List[Bitboard] = [_step_attacks(sq, [9, 8, 7, 1, -9, -8, -7, -1]) for sq in SQUARES] +BB_PAWN_ATTACKS: List[List[Bitboard]] = [[_step_attacks(sq, deltas) for sq in SQUARES] for deltas in [[-7, -9], [7, 9]]] -def _edges(square): +def _edges(square: Square) -> Bitboard: return (((BB_RANK_1 | BB_RANK_8) & ~BB_RANKS[square_rank(square)]) | ((BB_FILE_A | BB_FILE_H) & ~BB_FILES[square_file(square)])) -def _carry_rippler(mask): +def _carry_rippler(mask: Bitboard) -> Iterator[Bitboard]: # Carry-Rippler trick to iterate subsets of mask. - subset = 0 + subset = BB_EMPTY while True: yield subset subset = (subset - mask) & mask if not subset: break -def _attack_table(deltas): - mask_table = [] - attack_table = [] +def _attack_table(deltas: List[int]) -> Tuple[List[Bitboard], List[Dict[Bitboard, Bitboard]]]: + mask_table: List[Bitboard] = [] + attack_table: List[Dict[Bitboard, Bitboard]] = [] for square in SQUARES: attacks = {} @@ -347,104 +598,86 @@ def _attack_table(deltas): BB_RANK_MASKS, BB_RANK_ATTACKS = _attack_table([-1, 1]) -def _rays(): - rays = [] - between = [] +def _rays() -> List[List[Bitboard]]: + rays: List[List[Bitboard]] = [] for a, bb_a in enumerate(BB_SQUARES): - rays_row = [] - between_row = [] + rays_row: List[Bitboard] = [] for b, bb_b in enumerate(BB_SQUARES): if BB_DIAG_ATTACKS[a][0] & bb_b: rays_row.append((BB_DIAG_ATTACKS[a][0] & BB_DIAG_ATTACKS[b][0]) | bb_a | bb_b) - between_row.append(BB_DIAG_ATTACKS[a][BB_DIAG_MASKS[a] & bb_b] & BB_DIAG_ATTACKS[b][BB_DIAG_MASKS[b] & bb_a]) elif BB_RANK_ATTACKS[a][0] & bb_b: rays_row.append(BB_RANK_ATTACKS[a][0] | bb_a) - between_row.append(BB_RANK_ATTACKS[a][BB_RANK_MASKS[a] & bb_b] & BB_RANK_ATTACKS[b][BB_RANK_MASKS[b] & bb_a]) elif BB_FILE_ATTACKS[a][0] & bb_b: rays_row.append(BB_FILE_ATTACKS[a][0] | bb_a) - between_row.append(BB_FILE_ATTACKS[a][BB_FILE_MASKS[a] & bb_b] & BB_FILE_ATTACKS[b][BB_FILE_MASKS[b] & bb_a]) else: - rays_row.append(0) - between_row.append(0) + rays_row.append(BB_EMPTY) rays.append(rays_row) - between.append(between_row) - return rays, between + return rays + +BB_RAYS = _rays() -BB_RAYS, BB_BETWEEN = _rays() +def ray(a: Square, b: Square) -> Bitboard: + return BB_RAYS[a][b] +def between(a: Square, b: Square) -> Bitboard: + bb = BB_RAYS[a][b] & ((BB_ALL << a) ^ (BB_ALL << b)) + return bb & (bb - 1) -SAN_REGEX = re.compile(r"^([NBKRQ])?([a-h])?([1-8])?[\-x]?([a-h][1-8])(=?[nbrqkNBRQK])?(\+|#)?\Z") + +SAN_REGEX = re.compile(r"^([NBKRQ])?([a-h])?([1-8])?[\-x]?([a-h][1-8])(=?[nbrqkNBRQK])?[\+#]?\Z") FEN_CASTLING_REGEX = re.compile(r"^(?:-|[KQABCDEFGH]{0,2}[kqabcdefgh]{0,2})\Z") +@dataclasses.dataclass class Piece: """A piece with type and color.""" - def __init__(self, piece_type, color): - self.piece_type = piece_type - self.color = color + piece_type: PieceType + """The piece type.""" + + color: Color + """The piece color.""" - def symbol(self): + def symbol(self) -> str: """ Gets the symbol ``P``, ``N``, ``B``, ``R``, ``Q`` or ``K`` for white pieces or the lower-case variants for the black pieces. """ - if self.color == WHITE: - return PIECE_SYMBOLS[self.piece_type].upper() - else: - return PIECE_SYMBOLS[self.piece_type] + symbol = piece_symbol(self.piece_type) + return symbol.upper() if self.color else symbol - def unicode_symbol(self, *, invert_color=False): + def unicode_symbol(self, *, invert_color: bool = False) -> str: """ Gets the Unicode character for the piece. """ - if not invert_color: - return UNICODE_PIECE_SYMBOLS[self.symbol()] - else: - return UNICODE_PIECE_SYMBOLS[self.symbol().swapcase()] + symbol = self.symbol().swapcase() if invert_color else self.symbol() + return UNICODE_PIECE_SYMBOLS[symbol] - def __hash__(self): - return hash(self.piece_type * (self.color + 1)) + def __hash__(self) -> int: + return self.piece_type + (-1 if self.color else 5) - def __repr__(self): - return "Piece.from_symbol('{}')".format(self.symbol()) + def __repr__(self) -> str: + return f"Piece.from_symbol({self.symbol()!r})" - def __str__(self): + def __str__(self) -> str: return self.symbol() - def _repr_svg_(self): + def _repr_svg_(self) -> str: import chess.svg return chess.svg.piece(self, size=45) - def __eq__(self, other): - ne = self.__ne__(other) - return NotImplemented if ne is NotImplemented else not ne - - def __ne__(self, other): - try: - if self.piece_type != other.piece_type: - return True - elif self.color != other.color: - return True - else: - return False - except AttributeError: - return NotImplemented - @classmethod - def from_symbol(cls, symbol): + def from_symbol(cls, symbol: str) -> Piece: """ Creates a :class:`~chess.Piece` instance from a piece symbol. :raises: :exc:`ValueError` if the symbol is invalid. """ - if symbol.islower(): - return cls(PIECE_SYMBOLS.index(symbol), BLACK) - else: - return cls(PIECE_SYMBOLS.index(symbol.lower()), WHITE) + return cls(PIECE_SYMBOLS.index(symbol.lower()), symbol.isupper()) +@dataclasses.dataclass(unsafe_hash=True) class Move: """ Represents a move from a square to a square and possibly the promotion @@ -453,15 +686,21 @@ class Move: Drops and null moves are supported. """ - def __init__(self, from_square, to_square, promotion=None, drop=None): - self.from_square = from_square - self.to_square = to_square - self.promotion = promotion - self.drop = drop + from_square: Square + """The source square.""" - def uci(self): + to_square: Square + """The target square.""" + + promotion: Optional[PieceType] = None + """The promotion piece type or ``None``.""" + + drop: Optional[PieceType] = None + """The drop piece type or ``None``.""" + + def uci(self) -> str: """ - Gets an UCI string for the move. + Gets a UCI string for the move. For example, a move from a7 to a8 would be ``a7a8`` or ``a7a8q`` (if the latter is a promotion to a queen). @@ -469,76 +708,57 @@ def uci(self): The UCI representation of a null move is ``0000``. """ if self.drop: - return PIECE_SYMBOLS[self.drop].upper() + "@" + SQUARE_NAMES[self.to_square] + return piece_symbol(self.drop).upper() + "@" + SQUARE_NAMES[self.to_square] elif self.promotion: - return SQUARE_NAMES[self.from_square] + SQUARE_NAMES[self.to_square] + PIECE_SYMBOLS[self.promotion] + return SQUARE_NAMES[self.from_square] + SQUARE_NAMES[self.to_square] + piece_symbol(self.promotion) elif self: return SQUARE_NAMES[self.from_square] + SQUARE_NAMES[self.to_square] else: return "0000" - def __bool__(self): - return bool(self.from_square or self.to_square or self.promotion or self.drop) - - def __eq__(self, other): - ne = self.__ne__(other) - return NotImplemented if ne is NotImplemented else not ne + def xboard(self) -> str: + return self.uci() if self else "@@@@" - def __ne__(self, other): - try: - if self.from_square != other.from_square: - return True - elif self.to_square != other.to_square: - return True - elif self.promotion != other.promotion: - return True - elif self.drop != other.drop: - return True - else: - return False - except AttributeError: - return NotImplemented + def __bool__(self) -> bool: + return bool(self.from_square or self.to_square or self.promotion or self.drop) - def __repr__(self): - return "Move.from_uci('{}')".format(self.uci()) + def __repr__(self) -> str: + return f"Move.from_uci({self.uci()!r})" - def __str__(self): + def __str__(self) -> str: return self.uci() - def __hash__(self): - return hash((self.to_square, self.from_square, self.promotion, self.drop)) - - def __copy__(self): - return type(self)(self.from_square, self.to_square, self.promotion, self.drop) - - def __deepcopy__(self, memo): - move = self.__copy__() - memo[id(self)] = move - return move - @classmethod - def from_uci(cls, uci): + def from_uci(cls, uci: str) -> Move: """ - Parses an UCI string. + Parses a UCI string. - :raises: :exc:`ValueError` if the UCI string is invalid. + :raises: :exc:`InvalidMoveError` if the UCI string is invalid. """ if uci == "0000": return cls.null() elif len(uci) == 4 and "@" == uci[1]: - drop = PIECE_SYMBOLS.index(uci[0].lower()) - square = SQUARE_NAMES.index(uci[2:]) + try: + drop = PIECE_SYMBOLS.index(uci[0].lower()) + square = SQUARE_NAMES.index(uci[2:]) + except ValueError: + raise InvalidMoveError(f"invalid uci: {uci!r}") return cls(square, square, drop=drop) - elif len(uci) == 4: - return cls(SQUARE_NAMES.index(uci[0:2]), SQUARE_NAMES.index(uci[2:4])) - elif len(uci) == 5: - promotion = PIECE_SYMBOLS.index(uci[4]) - return cls(SQUARE_NAMES.index(uci[0:2]), SQUARE_NAMES.index(uci[2:4]), promotion=promotion) + elif 4 <= len(uci) <= 5: + try: + from_square = SQUARE_NAMES.index(uci[0:2]) + to_square = SQUARE_NAMES.index(uci[2:4]) + promotion = PIECE_SYMBOLS.index(uci[4]) if len(uci) == 5 else None + except ValueError: + raise InvalidMoveError(f"invalid uci: {uci!r}") + if from_square == to_square and from_square != A1: + raise InvalidMoveError(f"invalid uci (use 0000 for null moves): {uci!r}") + return cls(from_square, to_square, promotion=promotion) else: - raise ValueError("expected uci string to be of length 4 or 5: {}".format(repr(uci))) + raise InvalidMoveError(f"expected uci string to be of length 4 or 5: {uci!r}") @classmethod - def null(cls): + def null(cls) -> Move: """ Gets a null move. @@ -554,6 +774,8 @@ def null(cls): return cls(0, 0) +BaseBoardT = TypeVar("BaseBoardT", bound="BaseBoard") + class BaseBoard: """ A board representing the position of chess pieces. See @@ -564,7 +786,7 @@ class BaseBoard: is ``None``, an empty board is created. """ - def __init__(self, board_fen=STARTING_BOARD_FEN): + def __init__(self, board_fen: Optional[str] = STARTING_BOARD_FEN) -> None: self.occupied_co = [BB_EMPTY, BB_EMPTY] if board_fen is None: @@ -574,7 +796,7 @@ def __init__(self, board_fen=STARTING_BOARD_FEN): else: self._set_board_fen(board_fen) - def _reset_board(self): + def _reset_board(self) -> None: self.pawns = BB_RANK_2 | BB_RANK_7 self.knights = BB_B1 | BB_G1 | BB_B8 | BB_G8 self.bishops = BB_C1 | BB_F1 | BB_C8 | BB_F8 @@ -588,10 +810,17 @@ def _reset_board(self): self.occupied_co[BLACK] = BB_RANK_7 | BB_RANK_8 self.occupied = BB_RANK_1 | BB_RANK_2 | BB_RANK_7 | BB_RANK_8 - def reset_board(self): + def reset_board(self) -> None: + """ + Resets pieces to the starting position. + + :class:`~chess.Board` also resets the move stack, but not turn, + castling rights and move counters. Use :func:`chess.Board.reset()` to + fully restore the starting position. + """ self._reset_board() - def _clear_board(self): + def _clear_board(self) -> None: self.pawns = BB_EMPTY self.knights = BB_EMPTY self.bishops = BB_EMPTY @@ -605,11 +834,23 @@ def _clear_board(self): self.occupied_co[BLACK] = BB_EMPTY self.occupied = BB_EMPTY - def clear_board(self): - """Clears the board.""" + def clear_board(self) -> None: + """ + Clears the board. + + :class:`~chess.Board` also clears the move stack. + """ self._clear_board() - def pieces_mask(self, piece_type, color): + 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: if piece_type == PAWN: bb = self.pawns elif piece_type == KNIGHT: @@ -622,10 +863,12 @@ def pieces_mask(self, piece_type, color): bb = self.queens elif piece_type == KING: bb = self.kings + else: + assert False, f"expected PieceType, got {piece_type!r}" return bb & self.occupied_co[color] - def pieces(self, piece_type, color): + def pieces(self, piece_type: PieceType, color: Color) -> SquareSet: """ Gets pieces of the given type and color. @@ -633,20 +876,22 @@ def pieces(self, piece_type, color): """ return SquareSet(self.pieces_mask(piece_type, color)) - def piece_at(self, square): + def piece_at(self, square: Square) -> Optional[Piece]: """Gets the :class:`piece ` at the given square.""" piece_type = self.piece_type_at(square) if piece_type: mask = BB_SQUARES[square] color = bool(self.occupied_co[WHITE] & mask) return Piece(piece_type, color) + else: + return None - def piece_type_at(self, square): + def piece_type_at(self, square: Square) -> Optional[PieceType]: """Gets the piece type at the given square.""" mask = BB_SQUARES[square] if not self.occupied & mask: - return None + return None # Early return elif self.pawns & mask: return PAWN elif self.knights & mask: @@ -657,29 +902,39 @@ def piece_type_at(self, square): return ROOK elif self.queens & mask: return QUEEN - elif self.kings & mask: + else: return KING - def king(self, color): + def color_at(self, square: Square) -> Optional[Color]: + """Gets the color of the piece at the given square.""" + mask = BB_SQUARES[square] + if self.occupied_co[WHITE] & mask: + return WHITE + elif self.occupied_co[BLACK] & mask: + return BLACK + 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 - if king_mask: - return msb(king_mask) + 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): + def attacks_mask(self, square: Square) -> Bitboard: bb_square = BB_SQUARES[square] if bb_square & self.pawns: - if bb_square & self.occupied_co[WHITE]: - return BB_PAWN_ATTACKS[WHITE][square] - else: - return BB_PAWN_ATTACKS[BLACK][square] + color = bool(bb_square & self.occupied_co[WHITE]) + return BB_PAWN_ATTACKS[color][square] elif bb_square & self.knights: return BB_KNIGHT_ATTACKS[square] elif bb_square & self.kings: @@ -693,9 +948,9 @@ def attacks_mask(self, square): BB_FILE_ATTACKS[square][BB_FILE_MASKS[square] & self.occupied]) return attacks - def attacks(self, square): + def attacks(self, square: Square) -> SquareSet: """ - Gets a set of attacked squares from a given square. + Gets the set of attacked squares from the given square. There will be no attacks if the square is empty. Pinned pieces are still attacking other squares. @@ -704,7 +959,9 @@ def attacks(self, square): """ return SquareSet(self.attacks_mask(square)) - def _attackers_mask(self, color, square, occupied): + def attackers_mask(self, color: Color, square: Square, occupied: Optional[Bitboard] = None) -> Bitboard: + occupied = self.occupied if occupied is None else occupied + rank_pieces = BB_RANK_MASKS[square] & occupied file_pieces = BB_FILE_MASKS[square] & occupied diag_pieces = BB_DIAG_MASKS[square] & occupied @@ -722,29 +979,40 @@ def _attackers_mask(self, color, square, occupied): return attackers & self.occupied_co[color] - def attackers_mask(self, color, square): - return self._attackers_mask(color, square, self.occupied) - - def is_attacked_by(self, color, square): + def is_attacked_by(self, color: Color, square: Square, occupied: Optional[IntoSquareSet] = None) -> bool: """ Checks if the given side attacks the given square. Pinned pieces still count as attackers. Pawns that can be captured en passant are **not** considered attacked. + + *occupied* determines which squares are considered to block attacks. + For example, + ``board.occupied ^ board.pieces_mask(chess.KING, board.turn)`` can be + used to consider X-ray attacks through the king. + Defaults to ``board.occupied`` (all pieces including the king, + no X-ray attacks). """ - return bool(self.attackers_mask(color, square)) + return bool(self.attackers_mask(color, square, None if occupied is None else SquareSet(occupied).mask)) - def attackers(self, color, square): + def attackers(self, color: Color, square: Square, occupied: Optional[IntoSquareSet] = None) -> SquareSet: """ - Gets a set of attackers of the given color for the given square. + Gets the set of attackers of the given color for the given square. Pinned pieces still count as attackers. + *occupied* determines which squares are considered to block attacks. + For example, + ``board.occupied ^ board.pieces_mask(chess.KING, board.turn)`` can be + used to consider X-ray attacks through the king. + Defaults to ``board.occupied`` (all pieces including the king, + no X-ray attacks). + Returns a :class:`set of squares `. """ - return SquareSet(self.attackers_mask(color, square)) + return SquareSet(self.attackers_mask(color, square, None if occupied is None else SquareSet(occupied).mask)) - def pin_mask(self, color, square): + def pin_mask(self, color: Color, square: Square) -> Bitboard: king = self.king(color) if king is None: return BB_ALL @@ -758,14 +1026,14 @@ def pin_mask(self, color, square): if rays & square_mask: snipers = rays & sliders & self.occupied_co[not color] for sniper in scan_reversed(snipers): - if BB_BETWEEN[sniper][king] & (self.occupied | square_mask) == square_mask: - return BB_RAYS[king][sniper] + if between(sniper, king) & (self.occupied | square_mask) == square_mask: + return ray(king, sniper) break return BB_ALL - def pin(self, color, square): + def pin(self, color: Color, square: Square) -> SquareSet: """ Detects an absolute pin (and its direction) of the given square to the king of the given color. @@ -777,7 +1045,7 @@ def pin(self, color, square): True >>> direction = board.pin(chess.WHITE, chess.C3) >>> direction - SquareSet(0x0000000102040810) + SquareSet(0x0000_0001_0204_0810) >>> print(direction) . . . . . . . . . . . . . . . . @@ -794,13 +1062,13 @@ def pin(self, color, square): """ return SquareSet(self.pin_mask(color, square)) - def is_pinned(self, color, square): + def is_pinned(self, color: Color, square: Square) -> bool: """ Detects if the given square is pinned to the king of the given color. """ return self.pin_mask(color, square) != BB_ALL - def _remove_piece_at(self, square): + def _remove_piece_at(self, square: Square) -> Optional[PieceType]: piece_type = self.piece_type_at(square) mask = BB_SQUARES[square] @@ -817,7 +1085,7 @@ def _remove_piece_at(self, square): elif piece_type == KING: self.kings ^= mask else: - return + return None self.occupied ^= mask self.occupied_co[WHITE] &= ~mask @@ -827,17 +1095,18 @@ def _remove_piece_at(self, square): return piece_type - def remove_piece_at(self, square): + def remove_piece_at(self, square: Square) -> Optional[Piece]: """ Removes the piece from the given square. Returns the :class:`~chess.Piece` or ``None`` if the square was already empty. + + :class:`~chess.Board` also clears the move stack. """ color = bool(self.occupied_co[WHITE] & BB_SQUARES[square]) piece_type = self._remove_piece_at(square) - if piece_type: - return Piece(piece_type, color) + return Piece(piece_type, color) if piece_type else None - def _set_piece_at(self, square, piece_type, color, promoted=False): + def _set_piece_at(self, square: Square, piece_type: PieceType, color: Color, promoted: bool = False) -> None: self._remove_piece_at(square) mask = BB_SQUARES[square] @@ -854,6 +1123,8 @@ def _set_piece_at(self, square, piece_type, color, promoted=False): self.queens |= mask elif piece_type == KING: self.kings |= mask + else: + return self.occupied ^= mask self.occupied_co[color] ^= mask @@ -861,23 +1132,26 @@ def _set_piece_at(self, square, piece_type, color, promoted=False): if promoted: self.promoted ^= mask - def set_piece_at(self, square, piece, promoted=False): + def set_piece_at(self, square: Square, piece: Optional[Piece], promoted: bool = False) -> None: """ Sets a piece at the given square. An existing piece is replaced. Setting *piece* to ``None`` is equivalent to :func:`~chess.Board.remove_piece_at()`. + + :class:`~chess.Board` also clears the move stack. """ if piece is None: self._remove_piece_at(square) else: self._set_piece_at(square, piece.piece_type, piece.color, promoted) - def board_fen(self, *, promoted=False): + def board_fen(self, *, promoted: Optional[bool] = None) -> str: """ - Gets the board FEN. + Gets the board FEN (e.g., + ``rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR``). """ - builder = [] + builder: List[str] = [] empty = 0 for square in SQUARES_180: @@ -890,7 +1164,14 @@ def board_fen(self, *, promoted=False): 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: @@ -903,16 +1184,16 @@ def board_fen(self, *, promoted=False): return "".join(builder) - def _set_board_fen(self, fen): - # Compability with set_fen(). + def _set_board_fen(self, fen: str) -> None: + # Compatibility with set_fen(). fen = fen.strip() if " " in fen: - raise ValueError("expected position part of fen, got multiple parts: {}".format(repr(fen))) + raise ValueError(f"expected position part of fen, got multiple parts: {fen!r}") # Ensure the FEN is valid. rows = fen.split("/") if len(rows) != 8: - raise ValueError("expected 8 rows in position part of fen: {}".format(repr(fen))) + raise ValueError(f"expected 8 rows in position part of fen: {fen!r}") # Validate each row. for row in rows: @@ -923,24 +1204,24 @@ def _set_board_fen(self, fen): for c in row: if c in ["1", "2", "3", "4", "5", "6", "7", "8"]: if previous_was_digit: - raise ValueError("two subsequent digits in position part of fen: {}".format(repr(fen))) + raise ValueError(f"two subsequent digits in position part of fen: {fen!r}") field_sum += int(c) previous_was_digit = True previous_was_piece = False elif c == "~": if not previous_was_piece: - raise ValueError("~ not after piece in position part of fen: {}".format(repr(fen))) + raise ValueError(f"'~' not after piece in position part of fen: {fen!r}") previous_was_digit = False previous_was_piece = False - elif c.lower() in ["p", "n", "b", "r", "q", "k"]: + elif c.lower() in PIECE_SYMBOLS: field_sum += 1 previous_was_digit = False previous_was_piece = True else: - raise ValueError("invalid character in position part of fen: {}".format(repr(fen))) + raise ValueError(f"invalid character in position part of fen: {fen!r}") if field_sum != 8: - raise ValueError("expected 8 columns per row in position part of fen: {}".format(repr(fen))) + raise ValueError(f"expected 8 columns per row in position part of fen: {fen!r}") # Clear the board. self._clear_board() @@ -950,52 +1231,59 @@ def _set_board_fen(self, fen): for c in fen: if c in ["1", "2", "3", "4", "5", "6", "7", "8"]: square_index += int(c) - elif c.lower() in ["p", "n", "b", "r", "q", "k"]: + elif c.lower() in PIECE_SYMBOLS: piece = Piece.from_symbol(c) self._set_piece_at(SQUARES_180[square_index], piece.piece_type, piece.color) square_index += 1 elif c == "~": self.promoted |= BB_SQUARES[SQUARES_180[square_index - 1]] - def set_board_fen(self, fen): + def set_board_fen(self, fen: str) -> None: """ - Parses a FEN and sets the board from it. + Parses *fen* and sets up the board, where *fen* is the board part of + a FEN. + + :class:`~chess.Board` also clears the move stack. - :raises: :exc:`ValueError` if the FEN string is invalid. + :raises: :exc:`ValueError` if syntactically invalid. """ self._set_board_fen(fen) - def piece_map(self): + def piece_map(self, *, mask: Bitboard = BB_ALL) -> Dict[Square, Piece]: """ Gets a dictionary of :class:`pieces ` by square index. """ - result = {} - for square in scan_reversed(self.occupied): - result[square] = self.piece_at(square) + result: Dict[Square, Piece] = {} + for square in scan_reversed(self.occupied & mask): + result[square] = typing.cast(Piece, self.piece_at(square)) return result - def _set_piece_map(self, pieces): + def _set_piece_map(self, pieces: Mapping[Square, Piece]) -> None: self._clear_board() for square, piece in pieces.items(): self._set_piece_at(square, piece.piece_type, piece.color) - def set_piece_map(self, pieces): + def set_piece_map(self, pieces: Mapping[Square, Piece]) -> None: """ Sets up the board from a dictionary of :class:`pieces ` by square index. + + :class:`~chess.Board` also clears the move stack. """ self._set_piece_map(pieces) - def _set_chess960_pos(self, sharnagl): - if not 0 <= sharnagl <= 959: - raise ValueError("chess960 position index not 0 <= {} <= 959".format(repr(sharnagl))) + def _set_chess960_pos(self, scharnagl: int) -> None: + if not 0 <= scharnagl <= 959: + raise ValueError(f"chess960 position index not 0 <= {scharnagl!r} <= 959") # See http://www.russellcottrell.com/Chess/Chess960.htm for # a description of the algorithm. - n, bw = divmod(sharnagl, 4) + n, bw = divmod(scharnagl, 4) n, bb = divmod(n, 4) n, q = divmod(n, 6) + n1 = 0 + n2 = 0 for n1 in range(0, 4): n2 = n + (3 - n1) * (4 - n1) // 2 - 5 if n1 < n2 and 1 <= n2 <= 4: @@ -1047,16 +1335,16 @@ def _set_chess960_pos(self, sharnagl): self.occupied = BB_RANK_1 | BB_RANK_2 | BB_RANK_7 | BB_RANK_8 self.promoted = BB_EMPTY - def set_chess960_pos(self, sharnagl): + def set_chess960_pos(self, scharnagl: int) -> None: """ Sets up a Chess960 starting position given its index between 0 and 959. Also see :func:`~chess.BaseBoard.from_chess960_pos()`. """ - self._set_chess960_pos(sharnagl) + self._set_chess960_pos(scharnagl) - def chess960_pos(self): + def chess960_pos(self) -> Optional[int]: """ - Gets the Chess960 starting position index between 0 and 959 + Gets the Chess960 starting position index between 0 and 959, or ``None``. """ if self.occupied_co[WHITE] != BB_RANK_1 | BB_RANK_2: @@ -1065,31 +1353,19 @@ def chess960_pos(self): return None if self.pawns != BB_RANK_2 | BB_RANK_7: return None - if self.promoted: + if self._effective_promoted(): return None - if popcount(self.bishops) != 4: - return None - if popcount(self.rooks) != 4: - return None - if popcount(self.knights) != 4: - return None - if popcount(self.queens) != 2: - return None - if popcount(self.kings) != 2: + # Piece counts. + brnqk = [self.bishops, self.rooks, self.knights, self.queens, self.kings] + if [popcount(pieces) for pieces in brnqk] != [4, 4, 4, 2, 2]: return None - if (BB_RANK_1 & self.knights) << 56 != BB_RANK_8 & self.knights: - return None - if (BB_RANK_1 & self.bishops) << 56 != BB_RANK_8 & self.bishops: - return None - if (BB_RANK_1 & self.rooks) << 56 != BB_RANK_8 & self.rooks: - return None - if (BB_RANK_1 & self.queens) << 56 != BB_RANK_8 & self.queens: - return None - if (BB_RANK_1 & self.kings) << 56 != BB_RANK_8 & self.kings: + # Symmetry. + if any((BB_RANK_1 & pieces) << 56 != BB_RANK_8 & pieces for pieces in brnqk): return None + # Algorithm from ChessX, src/database/bitboard.cpp, r2254. x = self.bishops & (2 + 8 + 32 + 128) if not x: return None @@ -1101,7 +1377,6 @@ def chess960_pos(self): bs2 = lsb(x) * 2 cc_pos += bs2 - # Algorithm from ChessX, src/database/bitboard.cpp, r2254. q = 0 qf = False n0 = 0 @@ -1145,11 +1420,11 @@ def chess960_pos(self): else: return None - def __repr__(self): - return "{}('{}')".format(type(self).__name__, self.board_fen()) + def __repr__(self) -> str: + return f"{type(self).__name__}({self.board_fen()!r})" - def __str__(self): - builder = [] + def __str__(self) -> str: + builder: List[str] = [] for square in SQUARES_180: piece = self.piece_at(square) @@ -1167,7 +1442,7 @@ def __str__(self): return "".join(builder) - def unicode(self, *, invert_color=False, borders=False): + def unicode(self, *, invert_color: bool = False, borders: bool = False, empty_square: str = "⭘", orientation: Color = WHITE) -> str: """ Returns a string representation of the board with Unicode pieces. Useful for pretty-printing to a terminal. @@ -1175,8 +1450,8 @@ def unicode(self, *, invert_color=False, borders=False): :param invert_color: Invert color of the Unicode pieces. :param borders: Show borders and a coordinate margin. """ - builder = [] - for rank_index in range(7, -1, -1): + builder: List[str] = [] + for rank_index in (range(7, -1, -1) if orientation else range(8)): if borders: builder.append(" ") builder.append("-" * 17) @@ -1185,12 +1460,12 @@ def unicode(self, *, invert_color=False, borders=False): builder.append(RANK_NAMES[rank_index]) builder.append(" ") - for file_index in range(8): + for i, file_index in enumerate(range(8) if orientation else range(7, -1, -1)): square_index = square(file_index, rank_index) if borders: builder.append("|") - elif file_index > 0: + elif i > 0: builder.append(" ") piece = self.piece_at(square_index) @@ -1198,54 +1473,42 @@ def unicode(self, *, invert_color=False, borders=False): if piece: builder.append(piece.unicode_symbol(invert_color=invert_color)) else: - builder.append(u"·") + builder.append(empty_square) if borders: builder.append("|") - if borders or rank_index > 0: + if borders or (rank_index > 0 if orientation else rank_index < 7): builder.append("\n") if borders: builder.append(" ") builder.append("-" * 17) builder.append("\n") - builder.append(" a b c d e f g h") + letters = "a b c d e f g h" if orientation else "h g f e d c b a" + builder.append(" " + letters) return "".join(builder) - def _repr_svg_(self): + def _repr_svg_(self) -> str: import chess.svg return chess.svg.board(board=self, size=400) - def __eq__(self, board): - ne = self.__ne__(board) - return NotImplemented if ne is NotImplemented else not ne - - def __ne__(self, board): - try: - if self.occupied != board.occupied: - return True - elif self.occupied_co[WHITE] != board.occupied_co[WHITE]: - return True - elif self.pawns != board.pawns: - return True - elif self.knights != board.knights: - return True - elif self.bishops != board.bishops: - return True - elif self.rooks != board.rooks: - return True - elif self.queens != board.queens: - return True - elif self.kings != board.kings: - return True - else: - return False - except AttributeError: + def __eq__(self, board: object) -> bool: + if isinstance(board, BaseBoard): + return ( + self.occupied == board.occupied and + self.occupied_co[WHITE] == board.occupied_co[WHITE] and + self.pawns == board.pawns and + self.knights == board.knights and + self.bishops == board.bishops and + self.rooks == board.rooks and + self.queens == board.queens and + self.kings == board.kings) + else: return NotImplemented - def apply_transform(self, f): + def apply_transform(self, f: Callable[[Bitboard], Bitboard]) -> None: self.pawns = f(self.pawns) self.knights = f(self.knights) self.bishops = f(self.bishops) @@ -1258,23 +1521,43 @@ def apply_transform(self, f): self.occupied = f(self.occupied) self.promoted = f(self.promoted) - def transform(self, f): + def transform(self, f: Callable[[Bitboard], Bitboard]) -> Self: + """ + Returns a transformed copy of the board (without move stack) + by applying a bitboard transformation function. + + Available transformations include :func:`chess.flip_vertical()`, + :func:`chess.flip_horizontal()`, :func:`chess.flip_diagonal()`, + :func:`chess.flip_anti_diagonal()`, :func:`chess.shift_down()`, + :func:`chess.shift_up()`, :func:`chess.shift_left()`, and + :func:`chess.shift_right()`. + + Alternatively, :func:`~chess.BaseBoard.apply_transform()` can be used + to apply the transformation on the board. + """ board = self.copy() board.apply_transform(f) return board - def mirror(self): + def apply_mirror(self) -> None: + self.apply_transform(flip_vertical) + self.occupied_co[WHITE], self.occupied_co[BLACK] = self.occupied_co[BLACK], self.occupied_co[WHITE] + + def mirror(self) -> Self: """ - Returns a mirrored copy of the board. + Returns a mirrored copy of the board (without move stack). The board is mirrored vertically and piece colors are swapped, so that the position is equivalent modulo color. + + Alternatively, :func:`~chess.BaseBoard.apply_mirror()` can be used + to mirror the board. """ - board = self.transform(flip_vertical) - board.occupied_co[WHITE], board.occupied_co[BLACK] = board.occupied_co[BLACK], board.occupied_co[WHITE] + board = self.copy() + board.apply_mirror() return board - def copy(self): + def copy(self) -> Self: """Creates a copy of the board.""" board = type(self)(None) @@ -1292,16 +1575,16 @@ def copy(self): return board - def __copy__(self): + def __copy__(self) -> Self: return self.copy() - def __deepcopy__(self, memo): + def __deepcopy__(self, memo: Dict[int, object]) -> Self: board = self.copy() memo[id(self)] = board return board @classmethod - def empty(cls): + def empty(cls: Type[BaseBoardT]) -> BaseBoardT: """ Creates a new empty board. Also see :func:`~chess.BaseBoard.clear_board()`. @@ -1309,7 +1592,7 @@ def empty(cls): return cls(None) @classmethod - def from_chess960_pos(cls, sharnagl): + def from_chess960_pos(cls: Type[BaseBoardT], scharnagl: int) -> BaseBoardT: """ Creates a new board, initialized with a Chess960 starting position. @@ -1319,13 +1602,15 @@ def from_chess960_pos(cls, sharnagl): >>> board = chess.Board.from_chess960_pos(random.randint(0, 959)) """ board = cls.empty() - board.set_chess960_pos(sharnagl) + board.set_chess960_pos(scharnagl) return board +BoardT = TypeVar("BoardT", bound="Board") + class _BoardState: - def __init__(self, board): + def __init__(self, board: Board) -> None: self.pawns = board.pawns self.knights = board.knights self.bishops = board.bishops @@ -1345,7 +1630,7 @@ def __init__(self, board): self.halfmove_clock = board.halfmove_clock self.fullmove_number = board.fullmove_number - def restore(self, board): + def restore(self, board: Board) -> None: board.pawns = self.pawns board.knights = self.knights board.bishops = self.bishops @@ -1365,21 +1650,22 @@ def restore(self, board): board.halfmove_clock = self.halfmove_clock board.fullmove_number = self.fullmove_number - class Board(BaseBoard): """ - A :class:`~chess.BaseBoard` and additional information representing - a chess position. + A :class:`~chess.BaseBoard`, additional information representing + a chess position, and a :data:`move stack `. - Provides move generation, validation, parsing, attack generation, - game end detection, move counters and the capability to make and unmake - moves. + Provides :data:`move generation `, validation, + :func:`parsing `, attack generation, + :func:`game end detection `, + and the capability to :func:`make ` and + :func:`unmake ` moves. The board is initialized to the standard chess starting position, unless otherwise specified in the optional *fen* argument. If *fen* is ``None``, an empty board is created. - Optionally supports *chess960*. In Chess960 castling moves are encoded + Optionally supports *chess960*. In Chess960, castling moves are encoded by a king move to the corresponding rook square. Use :func:`chess.Board.from_chess960_pos()` to create a board with one of the Chess960 starting positions. @@ -1387,32 +1673,101 @@ class Board(BaseBoard): It's safe to set :data:`~Board.turn`, :data:`~Board.castling_rights`, :data:`~Board.ep_square`, :data:`~Board.halfmove_clock` and :data:`~Board.fullmove_number` directly. + + .. warning:: + It is possible to set up and work with invalid positions. In this + case, :class:`~chess.Board` implements a kind of "pseudo-chess" + (useful to gracefully handle errors or to implement chess variants). + Use :func:`~chess.Board.is_valid()` to detect invalid positions. + """ + + aliases: ClassVar[List[str]] = ["Standard", "Chess", "Classical", "Normal", "Illegal", "From Position"] + uci_variant: ClassVar[Optional[str]] = "chess" + xboard_variant: ClassVar[Optional[str]] = "normal" + starting_fen: ClassVar[str] = STARTING_FEN + + tbw_suffix: ClassVar[Optional[str]] = ".rtbw" + tbz_suffix: ClassVar[Optional[str]] = ".rtbz" + tbw_magic: ClassVar[Optional[bytes]] = b"\x71\xe8\x23\x5d" + tbz_magic: ClassVar[Optional[bytes]] = b"\xd7\x66\x0c\xa5" + pawnless_tbw_suffix: ClassVar[Optional[str]] = None + pawnless_tbz_suffix: ClassVar[Optional[str]] = None + pawnless_tbw_magic: ClassVar[Optional[bytes]] = None + pawnless_tbz_magic: ClassVar[Optional[bytes]] = None + connected_kings: ClassVar[bool] = False + one_king: ClassVar[bool] = True + captures_compulsory: ClassVar[bool] = False + + turn: Color + """The side to move (``chess.WHITE`` or ``chess.BLACK``).""" + + castling_rights: Bitboard + """ + Bitmask of the rooks with castling rights. + + To test for specific squares: + + >>> import chess + >>> + >>> board = chess.Board() + >>> bool(board.castling_rights & chess.BB_H1) # White can castle with the h1 rook + True + + To add a specific square: + + >>> board.castling_rights |= chess.BB_A1 + + Use :func:`~chess.Board.set_castling_fen()` to set multiple castling + rights. Also see :func:`~chess.Board.has_castling_rights()`, + :func:`~chess.Board.has_kingside_castling_rights()`, + :func:`~chess.Board.has_queenside_castling_rights()`, + :func:`~chess.Board.has_chess960_castling_rights()`, + :func:`~chess.Board.clean_castling_rights()`. + """ + + ep_square: Optional[Square] + """ + The potential en passant square on the third or sixth rank or ``None``. + + Use :func:`~chess.Board.has_legal_en_passant()` to test if en passant + capturing would actually be possible on the next move. + """ + + fullmove_number: int + """ + Counts move pairs. Starts at `1` and is incremented after every move + of the black side. """ - aliases = ["Standard", "Chess", "Classical", "Normal"] - uci_variant = "chess" - starting_fen = STARTING_FEN - - tbw_suffix = ".rtbw" - tbz_suffix = ".rtbz" - tbw_magic = b"\x71\xe8\x23\x5d" - tbz_magic = b"\xd7\x66\x0c\xa5" - pawnless_tbw_suffix = pawnless_tbz_suffix = None - pawnless_tbw_magic = pawnless_tbz_magic = None - connected_kings = False - one_king = True - captures_compulsory = False - - def __init__(self, fen=STARTING_FEN, *, chess960=False): + halfmove_clock: int + """The number of half-moves since the last capture or pawn move.""" + + promoted: Bitboard + """A bitmask of pieces that have been promoted.""" + + chess960: bool + """ + Whether the board is in Chess960 mode. In Chess960 castling moves are + represented as king moves to the corresponding rook square. + """ + + move_stack: List[Move] + """ + The move stack. Use :func:`Board.push() `, + :func:`Board.pop() `, + :func:`Board.peek() ` and + :func:`Board.clear_stack() ` for + manipulation. + """ + + def __init__(self, fen: Optional[str] = STARTING_FEN, *, chess960: bool = False) -> None: BaseBoard.__init__(self, None) self.chess960 = chess960 - self.pseudo_legal_moves = PseudoLegalMoveGenerator(self) - self.legal_moves = LegalMoveGenerator(self) - + self.ep_square = None self.move_stack = [] - self.stack = [] + self._stack: List[_BoardState] = [] if fen is None: self.clear() @@ -1421,7 +1776,42 @@ def __init__(self, fen=STARTING_FEN, *, chess960=False): else: self.set_fen(fen) - def reset(self): + @property + def legal_moves(self) -> LegalMoveGenerator: + """ + A dynamic list of legal moves. + + >>> import chess + >>> + >>> board = chess.Board() + >>> board.legal_moves.count() + 20 + >>> bool(board.legal_moves) + True + >>> move = chess.Move.from_uci("g1f3") + >>> move in board.legal_moves + True + + Wraps :func:`~chess.Board.generate_legal_moves()` and + :func:`~chess.Board.is_legal()`. + """ + return LegalMoveGenerator(self) + + @property + def pseudo_legal_moves(self) -> PseudoLegalMoveGenerator: + """ + A dynamic list of pseudo-legal moves, much like the legal move list. + + Pseudo-legal moves might leave or put the king in check, but are + otherwise valid. Null moves are not pseudo-legal. Castling moves are + only included if they are completely legal. + + Wraps :func:`~chess.Board.generate_pseudo_legal_moves()` and + :func:`~chess.Board.is_pseudo_legal()`. + """ + return PseudoLegalMoveGenerator(self) + + def reset(self) -> None: """Restores the starting position.""" self.turn = WHITE self.castling_rights = BB_CORNERS @@ -1431,18 +1821,18 @@ def reset(self): self.reset_board() - def reset_board(self): + def reset_board(self) -> None: super().reset_board() self.clear_stack() - def clear(self): + def clear(self) -> None: """ Clears the board. - Resets move stacks and move counters. The side to move is white. There + Resets move stack and move counters. The side to move is white. There are no rooks or kings, so castling rights are removed. - In order to be in a valid :func:`~chess.Board.status()` at least kings + In order to be in a valid :func:`~chess.Board.status()`, at least kings need to be put on the board. """ self.turn = WHITE @@ -1453,34 +1843,48 @@ def clear(self): self.clear_board() - def clear_board(self): + def clear_board(self) -> None: super().clear_board() self.clear_stack() - def clear_stack(self): + def clear_stack(self) -> None: """Clears the move stack.""" - del self.move_stack[:] - del self.stack[:] + self.move_stack.clear() + self._stack.clear() - def root(self): + def root(self) -> Self: """Returns a copy of the root position.""" - if self.stack: + if self._stack: board = type(self)(None, chess960=self.chess960) - self.stack[0].restore(board) + self._stack[0].restore(board) return board else: return self.copy(stack=False) - def remove_piece_at(self, square): + def ply(self) -> int: + """ + Returns the number of half-moves since the start of the game, as + indicated by :data:`~chess.Board.fullmove_number` and + :data:`~chess.Board.turn`. + + If moves have been pushed from the beginning, this is usually equal to + ``len(board.move_stack)``. But note that a board can be set up with + arbitrary starting positions, and the stack can be cleared. + """ + return 2 * (self.fullmove_number - 1) + (self.turn == BLACK) + + def remove_piece_at(self, square: Square) -> Optional[Piece]: + """Remove a piece, if any, from the given square and return the removed piece.""" piece = super().remove_piece_at(square) self.clear_stack() return piece - def set_piece_at(self, square, piece, promoted=False): + def set_piece_at(self, square: Square, piece: Optional[Piece], promoted: bool = False) -> None: + """Place a piece on a square.""" super().set_piece_at(square, piece, promoted=promoted) self.clear_stack() - def generate_pseudo_legal_moves(self, from_mask=BB_ALL, to_mask=BB_ALL): + def generate_pseudo_legal_moves(self, from_mask: Bitboard = BB_ALL, to_mask: Bitboard = BB_ALL) -> Iterator[Move]: our_pieces = self.occupied_co[self.turn] # Generate piece moves. @@ -1507,7 +1911,7 @@ def generate_pseudo_legal_moves(self, from_mask=BB_ALL, to_mask=BB_ALL): 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) @@ -1530,7 +1934,7 @@ def generate_pseudo_legal_moves(self, from_mask=BB_ALL, to_mask=BB_ALL): 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) @@ -1547,7 +1951,7 @@ def generate_pseudo_legal_moves(self, from_mask=BB_ALL, to_mask=BB_ALL): if self.ep_square: yield from self.generate_pseudo_legal_ep(from_mask, to_mask) - def generate_pseudo_legal_ep(self, from_mask=BB_ALL, to_mask=BB_ALL): + def generate_pseudo_legal_ep(self, from_mask: Bitboard = BB_ALL, to_mask: Bitboard = BB_ALL) -> Iterator[Move]: if not self.ep_square or not BB_SQUARES[self.ep_square] & to_mask: return @@ -1557,52 +1961,76 @@ def generate_pseudo_legal_ep(self, from_mask=BB_ALL, to_mask=BB_ALL): 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) - def generate_pseudo_legal_captures(self, from_mask=BB_ALL, to_mask=BB_ALL): + def generate_pseudo_legal_captures(self, from_mask: Bitboard = BB_ALL, to_mask: Bitboard = BB_ALL) -> Iterator[Move]: return itertools.chain( self.generate_pseudo_legal_moves(from_mask, to_mask & self.occupied_co[not self.turn]), self.generate_pseudo_legal_ep(from_mask, to_mask)) - def is_check(self): - """Returns if the current side to move is in check.""" + def checkers_mask(self) -> Bitboard: king = self.king(self.turn) - return king is not None and self.is_attacked_by(not self.turn, king) + return BB_EMPTY if king is None else self.attackers_mask(not self.turn, king) + + def checkers(self) -> SquareSet: + """ + Gets the pieces currently giving check. - def is_into_check(self, move): + Returns a :class:`set of squares `. + """ + return SquareSet(self.checkers_mask()) + + def is_check(self) -> bool: + """Tests if the current side to move is in check.""" + return bool(self.checkers_mask()) + + def gives_check(self, move: Move) -> bool: + """ + Probes if the given move would put the opponent in check. The move + must be at least pseudo-legal. """ - Checks if the given move would leave the king in check or put it into - check. The move must be at least pseudo legal. + self.push(move) + try: + return self.is_check() + 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: return False + # If already in check, look if it is an evasion. checkers = self.attackers_mask(not self.turn, king) - if checkers: - # If already in check, look if it is an evasion. - if move not in self._generate_evasions(king, checkers, BB_SQUARES[move.from_square], BB_SQUARES[move.to_square]): - return True + if checkers and move not in self._generate_evasions(king, checkers, BB_SQUARES[move.from_square], BB_SQUARES[move.to_square]): + return True return not self._is_safe(king, self._slider_blockers(king), move) - def was_into_check(self): - """ - Checks if the king of the other side is attacked. Such a position is not - valid and could only be reached by an illegal move. - """ + def was_into_check(self) -> bool: king = self.king(not self.turn) return king is not None and self.is_attacked_by(self.turn, king) - def is_pseudo_legal(self, move): - # Null moves are not pseudo legal. + def is_pseudo_legal(self, move: Move) -> bool: + # Null moves are not pseudo-legal. if not move: return False - # Drops are not pseudo legal. + # Drops are not pseudo-legal. if move.drop: return False @@ -1619,18 +2047,19 @@ def is_pseudo_legal(self, move): if not self.occupied_co[self.turn] & from_mask: return False - # Only pawns can promote and only on the back rank. + # Only pawns can promote and only on the backrank. if move.promotion: 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. if piece == KING: + move = self._from_chess960(self.chess960, move.from_square, move.to_square) if move in self.generate_castling_moves(): return True @@ -1645,10 +2074,11 @@ def is_pseudo_legal(self, move): # Handle all other pieces. return bool(self.attacks_mask(move.from_square) & to_mask) - def is_legal(self, move): + def is_legal(self, move: Move) -> bool: + """Check if a move is legal in the current position.""" return not self.is_variant_end() and self.is_pseudo_legal(move) and not self.is_into_check(move) - def is_variant_end(self): + def is_variant_end(self) -> bool: """ Checks if the game is over due to a special variant end condition. @@ -1661,104 +2091,111 @@ def is_variant_end(self): """ return False - def is_variant_loss(self): - """Checks if a special variant-specific loss condition is fulfilled.""" + def is_variant_loss(self) -> bool: + """ + Checks if the current side to move lost due to a variant-specific + condition. + """ return False - def is_variant_win(self): - """Checks if a special variant-specific win condition is fulfilled.""" + def is_variant_win(self) -> bool: + """ + Checks if the current side to move won due to a variant-specific + condition. + """ return False - def is_variant_draw(self): + def is_variant_draw(self) -> bool: """ - Checks if a special variant-specific drawing condition is fulfilled. + Checks if a variant-specific drawing condition is fulfilled. """ return False - def is_game_over(self, *, claim_draw=False): + def is_game_over(self, *, claim_draw: bool = False) -> bool: + """ + Check if the game is over by any rule. + + The game is not considered to be over by the + :func:`fifty-move rule ` or + :func:`threefold repetition `, + unless *claim_draw* is given. Note that checking the latter can be + slow. + """ + return self.outcome(claim_draw=claim_draw) is not None + + def result(self, *, claim_draw: bool = False) -> str: + """ + Return the result of a game: 1-0, 0-1, 1/2-1/2, or *. + + The game is not considered to be over by the + :func:`fifty-move rule ` or + :func:`threefold repetition `, + unless *claim_draw* is given. Note that checking the latter can be + slow. + """ + outcome = self.outcome(claim_draw=claim_draw) + return outcome.result() if outcome else "*" + + def outcome(self, *, claim_draw: bool = False) -> Optional[Outcome]: """ Checks if the game is over due to :func:`checkmate `, :func:`stalemate `, :func:`insufficient material `, the :func:`seventyfive-move rule `, - :func:`fivefold repetition ` + :func:`fivefold repetition `, or a :func:`variant end condition `. + Returns the :class:`chess.Outcome` if the game has ended, otherwise + ``None``. - The game is not considered to be over by - :func:`threefold repetition ` - or the :func:`fifty-move rule `, - unless *claim_draw* is given. - """ - # Seventyfive-move rule. - if self.is_seventyfive_moves(): - return True - - # Insufficient material. - if self.is_insufficient_material(): - return True + Alternatively, use :func:`~chess.Board.is_game_over()` if you are not + interested in who won the game and why. - # Stalemate or checkmate. - if not any(self.generate_legal_moves()): - return True - - # Fivefold repetition. - if self.is_fivefold_repetition(): - return True - - # Claim draw. - if claim_draw and self.can_claim_draw(): - return True - - return False - - def result(self, *, claim_draw=False): - """ - Gets the game result. - - ``1-0``, ``0-1`` or ``1/2-1/2`` if the - :func:`game is over `. Otherwise, the - result is undetermined: ``*``. + The game is not considered to be over by the + :func:`fifty-move rule ` or + :func:`threefold repetition `, + unless *claim_draw* is given. Note that checking the latter can be + slow. """ - # Chess variant support. + # Variant support. if self.is_variant_loss(): - return "0-1" if self.turn == WHITE else "1-0" - elif self.is_variant_win(): - return "1-0" if self.turn == WHITE else "0-1" - elif self.is_variant_draw(): - return "1/2-1/2" + return Outcome(Termination.VARIANT_LOSS, not self.turn) + if self.is_variant_win(): + return Outcome(Termination.VARIANT_WIN, self.turn) + if self.is_variant_draw(): + return Outcome(Termination.VARIANT_DRAW, None) - # Checkmate. + # Normal game end. if self.is_checkmate(): - return "0-1" if self.turn == WHITE else "1-0" - - # Draw claimed. - if claim_draw and self.can_claim_draw(): - return "1/2-1/2" - - # Seventyfive-move rule or fivefold repetition. - if self.is_seventyfive_moves() or self.is_fivefold_repetition(): - return "1/2-1/2" - - # Insufficient material. + return Outcome(Termination.CHECKMATE, not self.turn) if self.is_insufficient_material(): - return "1/2-1/2" - - # Stalemate. + return Outcome(Termination.INSUFFICIENT_MATERIAL, None) if not any(self.generate_legal_moves()): - return "1/2-1/2" + return Outcome(Termination.STALEMATE, None) + + # Automatic draws. + if self.is_seventyfive_moves(): + return Outcome(Termination.SEVENTYFIVE_MOVES, None) + if self.is_fivefold_repetition(): + return Outcome(Termination.FIVEFOLD_REPETITION, None) - # Undetermined. - return "*" + # Claimable draws. + if claim_draw: + if self.can_claim_fifty_moves(): + return Outcome(Termination.FIFTY_MOVES, None) + if self.can_claim_threefold_repetition(): + return Outcome(Termination.THREEFOLD_REPETITION, None) - def is_checkmate(self): + return None + + def is_checkmate(self) -> bool: """Checks if the current position is a checkmate.""" if not self.is_check(): return False return not any(self.generate_legal_moves()) - def is_stalemate(self): + def is_stalemate(self) -> bool: """Checks if the current position is a stalemate.""" if self.is_check(): return False @@ -1768,99 +2205,127 @@ def is_stalemate(self): return not any(self.generate_legal_moves()) - def is_insufficient_material(self): - """Checks for a draw due to insufficient mating material.""" - # Enough material to mate. - if self.pawns or self.rooks or self.queens: + def is_insufficient_material(self) -> bool: + """ + Checks if neither side has sufficient winning material + (:func:`~chess.Board.has_insufficient_material()`). + """ + return all(self.has_insufficient_material(color) for color in COLORS) + + def has_insufficient_material(self, color: Color) -> bool: + """ + Checks if *color* has insufficient winning material. + + This is guaranteed to return ``False`` if *color* can still win the + game. + + The converse does not necessarily hold: + The implementation only looks at the material, including the colors + of bishops, but not considering piece positions. So fortress + positions or positions with forced lines may return ``False``, even + though there is no possible winning line. + """ + if self.occupied_co[color] & (self.pawns | self.rooks | self.queens): return False - # A single knight or a single bishop. - if popcount(self.occupied) <= 3: - return True + # Knights are only insufficient material if: + # (1) We do not have any other pieces, including more than one knight. + # (2) The opponent does not have pawns, knights, bishops or rooks. + # These would allow selfmate. + if self.occupied_co[color] & self.knights: + return (popcount(self.occupied_co[color]) <= 2 and + not (self.occupied_co[not color] & ~self.kings & ~self.queens)) + + # Bishops are only insufficient material if: + # (1) We do not have any other pieces, including bishops of the + # opposite color. + # (2) The opponent does not have bishops of the opposite color, + # pawns or knights. These would allow selfmate. + if self.occupied_co[color] & self.bishops: + same_color = (not self.bishops & BB_DARK_SQUARES) or (not self.bishops & BB_LIGHT_SQUARES) + return same_color and not self.pawns and not self.knights - # More than a single knight. - if self.knights: - return False + return True - # All bishops on the same color. - if self.bishops & BB_DARK_SQUARES == 0: - return True - elif self.bishops & BB_LIGHT_SQUARES == 0: - return True - else: - return False + def _is_halfmoves(self, n: int) -> bool: + return self.halfmove_clock >= n and any(self.generate_legal_moves()) - def is_seventyfive_moves(self): + def is_seventyfive_moves(self) -> bool: """ Since the 1st of July 2014, a game is automatically drawn (without a claim by one of the players) if the half-move clock since a capture - or pawn move is equal to or grather than 150. Other means to end a game + or pawn move is equal to or greater than 150. Other means to end a game take precedence. """ - if self.halfmove_clock >= 150: - if any(self.generate_legal_moves()): - return True - - return False + return self._is_halfmoves(150) - def is_fivefold_repetition(self): + def is_fivefold_repetition(self) -> bool: """ Since the 1st of July 2014 a game is automatically drawn (without a claim by one of the players) if a position occurs for the fifth time. Originally this had to occur on consecutive alternating moves, but this has since been revised. """ - transposition_key = self._transposition_key() - repetitions = 1 - switchyard = [] - - while self.move_stack and repetitions < 5: - move = self.pop() - switchyard.append(move) - - if self.is_irreversible(move): - break - - if self._transposition_key() == transposition_key: - repetitions += 1 + return self.is_repetition(5) - while switchyard: - self.push(switchyard.pop()) - - return repetitions >= 5 - - def can_claim_draw(self): + def can_claim_draw(self) -> bool: """ - Checks if the side to move can claim a draw by the fifty-move rule or + Checks if the player to move can claim a draw by the fifty-move rule or by threefold repetition. + + Note that checking the latter can be slow. """ return self.can_claim_fifty_moves() or self.can_claim_threefold_repetition() - def can_claim_fifty_moves(self): + def is_fifty_moves(self) -> bool: """ - Draw by the fifty-move rule can be claimed once the clock of halfmoves - since the last capture or pawn move becomes equal or greater to 100 - and the side to move still has a legal move they can make. + Checks that the clock of halfmoves since the last capture or pawn move + is greater or equal to 100, and that no other means of ending the game + (like checkmate) take precedence. """ - # Fifty-move rule. - if self.halfmove_clock >= 100: - if any(self.generate_legal_moves()): - return True + return self._is_halfmoves(100) + + def can_claim_fifty_moves(self) -> bool: + """ + Checks if the player to move can claim a draw by the fifty-move rule. + + In addition to :func:`~chess.Board.is_fifty_moves()`, the fifty-move + rule can also be claimed if there is a legal move that achieves this + condition. + """ + if self.is_fifty_moves(): + return True + + if self.halfmove_clock >= 99: + for move in self.generate_legal_moves(): + if not self.is_zeroing(move): + self.push(move) + try: + if self.is_fifty_moves(): + return True + finally: + self.pop() return False - def can_claim_threefold_repetition(self): + def can_claim_threefold_repetition(self) -> bool: """ + Checks if the player to move can claim a draw by threefold repetition. + Draw by threefold repetition can be claimed if the position on the - board occured for the third time or if such a repetition is reached + board occurred for the third time or if such a repetition is reached with one of the possible legal moves. + + Note that checking this can be slow: In the worst case + scenario, every legal move has to be tested and the entire game has to + be replayed because there is no incremental transposition table. """ transposition_key = self._transposition_key() - transpositions = collections.Counter() + transpositions: Counter[Hashable] = collections.Counter() transpositions.update((transposition_key, )) # Count positions. - switchyard = [] + switchyard: List[Move] = [] while self.move_stack: move = self.pop() switchyard.append(move) @@ -1873,28 +2338,76 @@ def can_claim_threefold_repetition(self): while switchyard: self.push(switchyard.pop()) - # Threefold repetition occured. + # Threefold repetition occurred. if transpositions[transposition_key] >= 3: return True # The next legal move is a threefold repetition. for move in self.generate_legal_moves(): self.push(move) - - if transpositions[self._transposition_key()] >= 2: + try: + if transpositions[self._transposition_key()] >= 2: + return True + finally: self.pop() - return True - self.pop() + return False + + def is_repetition(self, count: int = 3) -> bool: + """ + Checks if the current position has repeated 3 (or a given number of) + times. + + Unlike :func:`~chess.Board.can_claim_threefold_repetition()`, + this does not consider a repetition that can be played on the next + move. + + Note that checking this can be slow: In the worst case, the entire + game has to be replayed because there is no incremental transposition + table. + """ + # Fast check, based on occupancy only. + maybe_repetitions = 1 + for state in reversed(self._stack): + if state.occupied == self.occupied: + maybe_repetitions += 1 + if maybe_repetitions >= count: + break + if maybe_repetitions < count: + return False + + # Check full replay. + transposition_key = self._transposition_key() + switchyard: List[Move] = [] + + try: + while True: + if count <= 1: + return True + + if len(self.move_stack) < count - 1: + break + + move = self.pop() + switchyard.append(move) + + if self.is_irreversible(move): + break + + if self._transposition_key() == transposition_key: + count -= 1 + finally: + while switchyard: + self.push(switchyard.pop()) return False - def _push_capture(self, move, capture_square, piece_type, was_promoted): + def _push_capture(self, move: Move, capture_square: Square, piece_type: PieceType, was_promoted: bool) -> None: pass - def push(self, move): + def push(self, move: Move) -> None: """ - Updates the position with the given move and puts it onto the + Updates the position with the given *move* and puts it onto the move stack. >>> import chess @@ -1910,12 +2423,17 @@ def push(self, move): Null moves just increment the move counters, switch turns and forfeit en passant capturing. - :warning: Moves are not checked for legality. + .. warning:: + Moves are not checked for legality. It is the caller's + responsibility to ensure that the move is at least pseudo-legal or + a null move. """ # Push move and remember board state. move = self._to_chess960(move) + board_state = _BoardState(self) + self.castling_rights = self.clean_castling_rights() # Before pushing stack self.move_stack.append(self._from_chess960(self.chess960, move.from_square, move.to_square, move.promotion, move.drop)) - self.stack.append(_BoardState(self)) + self._stack.append(board_state) # Reset en passant square. ep_square = self.ep_square @@ -1944,36 +2462,37 @@ def push(self, move): from_bb = BB_SQUARES[move.from_square] to_bb = BB_SQUARES[move.to_square] - promoted = self.promoted & from_bb + promoted = bool(self.promoted & from_bb) piece_type = self._remove_piece_at(move.from_square) + assert piece_type is not None, f"push() expects move to be pseudo-legal, but got {move} in {self.board_fen()}" capture_square = move.to_square captured_piece_type = self.piece_type_at(capture_square) # Update castling rights. - self.castling_rights = self.clean_castling_rights() & ~to_bb & ~from_bb - if piece_type == KING and not promoted: + self.castling_rights &= ~to_bb & ~from_bb + 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: - if self.turn == WHITE and square_rank(move.to_square) == 7: + 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) == 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. down = -8 if self.turn == WHITE else 8 - capture_square = ep_square + down + capture_square = move.to_square + down captured_piece_type = self._remove_piece_at(capture_square) # Promotion. @@ -1997,8 +2516,8 @@ def push(self, move): self._set_piece_at(F1 if self.turn == WHITE else F8, ROOK, self.turn) # Put the piece on the target square. - if not castling and piece_type: - was_promoted = self.promoted & to_bb + if not castling: + was_promoted = bool(self.promoted & to_bb) self._set_piece_at(move.to_square, piece_type, self.turn, promoted) if captured_piece_type: @@ -2007,17 +2526,17 @@ def push(self, move): # Swap turn. self.turn = not self.turn - def pop(self): + def pop(self) -> Move: """ Restores the previous position and returns the last move from the stack. - :raises: :exc:`IndexError` if the stack is empty. + :raises: :exc:`IndexError` if the move stack is empty. """ move = self.move_stack.pop() - self.stack.pop().restore(self) + self._stack.pop().restore(self) return move - def peek(self): + def peek(self) -> Move: """ Gets the last move from the move stack. @@ -2025,12 +2544,34 @@ def peek(self): """ return self.move_stack[-1] - def castling_shredder_fen(self): + def find_move(self, from_square: Square, to_square: Square, promotion: Optional[PieceType] = None) -> Move: + """ + Finds a matching legal move for an origin square, a target square, and + an optional promotion piece type. + + For pawn moves to the backrank, the promotion piece type defaults to + :data:`chess.QUEEN`, unless otherwise specified. + + Castling moves are normalized to king moves by two steps, except in + Chess960. + + :raises: :exc:`IllegalMoveError` if no matching legal move is found. + """ + if promotion is None and self.pawns & BB_SQUARES[from_square] and BB_SQUARES[to_square] & BB_BACKRANKS: + promotion = QUEEN + + move = self._from_chess960(self.chess960, from_square, to_square, promotion) + if not self.is_legal(move): + raise IllegalMoveError(f"no matching legal move for {move.uci()} ({SQUARE_NAMES[from_square]} -> {SQUARE_NAMES[to_square]}) in {self.fen()}") + + return move + + def castling_shredder_fen(self) -> str: castling_rights = self.clean_castling_rights() if not castling_rights: return "-" - builder = [] + builder: List[str] = [] for square in scan_reversed(castling_rights & BB_RANK_1): builder.append(FILE_NAMES[square_file(square)].upper()) @@ -2040,8 +2581,8 @@ def castling_shredder_fen(self): return "".join(builder) - def castling_xfen(self): - builder = [] + def castling_xfen(self) -> str: + builder: List[str] = [] for color in COLORS: king = self.king(color) @@ -2069,21 +2610,21 @@ def castling_xfen(self): else: return "-" - def has_pseudo_legal_en_passant(self): + def has_pseudo_legal_en_passant(self) -> bool: """Checks if there is a pseudo-legal en passant capture.""" - return self.ep_square and any(self.generate_pseudo_legal_ep()) + return self.ep_square is not None and any(self.generate_pseudo_legal_ep()) - def has_legal_en_passant(self): + def has_legal_en_passant(self) -> bool: """Checks if there is a legal en passant capture.""" - return self.ep_square and any(self.generate_legal_ep()) + return self.ep_square is not None and any(self.generate_legal_ep()) - def fen(self, *, shredder=False, en_passant="legal", promoted=None): + def fen(self, *, shredder: bool = False, en_passant: EnPassantSpec = "legal", promoted: Optional[bool] = None) -> str: """ Gets a FEN representation of the position. A FEN string (e.g., ``rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1``) consists - of the position part :func:`~chess.Board.board_fen()`, the + of the board part :func:`~chess.Board.board_fen()`, the :data:`~chess.Board.turn`, the castling part (:data:`~chess.Board.castling_rights`), the en passant square (:data:`~chess.Board.ep_square`), @@ -2109,78 +2650,114 @@ def fen(self, *, shredder=False, en_passant="legal", promoted=None): str(self.fullmove_number) ]) - def shredder_fen(self, *, en_passant="legal", promoted=None): + def shredder_fen(self, *, en_passant: EnPassantSpec = "legal", promoted: Optional[bool] = None) -> str: return " ".join([ self.epd(shredder=True, en_passant=en_passant, promoted=promoted), str(self.halfmove_clock), str(self.fullmove_number) ]) - def set_fen(self, fen): + def set_fen(self, fen: str) -> None: """ Parses a FEN and sets the position from it. - :raises: :exc:`ValueError` if the FEN string is invalid. + :raises: :exc:`ValueError` if syntactically invalid. Use + :func:`~chess.Board.is_valid()` to detect invalid positions. """ - # Ensure there are six parts. parts = fen.split() - if len(parts) != 6: - raise ValueError("fen string should consist of 6 parts: {}".format(repr(fen))) - # Check that the turn part is valid. - if not parts[1] in ["w", "b"]: - raise ValueError("expected 'w' or 'b' for turn part of fen: {}".format(repr(fen))) + # Board part. + try: + board_part = parts.pop(0) + except IndexError: + raise ValueError("empty fen") + + # Turn. + try: + turn_part = parts.pop(0) + except IndexError: + turn = WHITE + else: + if turn_part == "w": + turn = WHITE + elif turn_part == "b": + turn = BLACK + else: + raise ValueError(f"expected 'w' or 'b' for turn part of fen: {fen!r}") - # Check that the castling part is valid. - if not FEN_CASTLING_REGEX.match(parts[2]): - raise ValueError("invalid castling part in fen: {}".format(repr(fen))) + # Validate castling part. + try: + castling_part = parts.pop(0) + except IndexError: + castling_part = "-" + else: + if not FEN_CASTLING_REGEX.match(castling_part): + raise ValueError(f"invalid castling part in fen: {fen!r}") - # Check that the en passant part is valid. - if parts[3] != "-": - if parts[3] not in SQUARE_NAMES: - raise ValueError("invalid en passant part in fen: {}".format(repr(fen))) + # En passant square. + try: + ep_part = parts.pop(0) + except IndexError: + ep_square = None + else: + try: + ep_square = None if ep_part == "-" else SQUARE_NAMES.index(ep_part) + except ValueError: + raise ValueError(f"invalid en passant part in fen: {fen!r}") # Check that the half-move part is valid. - if int(parts[4]) < 0: - raise ValueError("half-move clock can not be negative: {}".format(repr(fen))) - - # Check that the full-move number part is valid. - # 0 is allowed for compability, but later replaced with 1. - if int(parts[5]) < 0: - raise ValueError("full-move number must be positive: {}".format(repr(fen))) + try: + halfmove_part = parts.pop(0) + except IndexError: + halfmove_clock = 0 + else: + try: + halfmove_clock = int(halfmove_part) + except ValueError: + raise ValueError(f"invalid half-move clock in fen: {fen!r}") - # Validate the board part and set it. - self._set_board_fen(parts[0]) + if halfmove_clock < 0: + raise ValueError(f"half-move clock cannot be negative: {fen!r}") - # Set the turn. - if parts[1] == "w": - self.turn = WHITE + # Check that the full-move number part is valid. + # 0 is allowed for compatibility, but later replaced with 1. + try: + fullmove_part = parts.pop(0) + except IndexError: + fullmove_number = 1 else: - self.turn = BLACK + try: + fullmove_number = int(fullmove_part) + except ValueError: + raise ValueError(f"invalid fullmove number in fen: {fen!r}") - # Set castling flags. - self._set_castling_fen(parts[2]) + if fullmove_number < 0: + raise ValueError(f"fullmove number cannot be negative: {fen!r}") - # Set the en passant square. - if parts[3] == "-": - self.ep_square = None - else: - self.ep_square = SQUARE_NAMES.index(parts[3]) + fullmove_number = max(fullmove_number, 1) - # Set the mover counters. - self.halfmove_clock = int(parts[4]) - self.fullmove_number = int(parts[5]) or 1 + # All parts should be consumed now. + if parts: + raise ValueError(f"fen string has more parts than expected: {fen!r}") - # Clear move stack. + # Validate the board part and set it. + self._set_board_fen(board_part) + + # Apply. + self.turn = turn + self._set_castling_fen(castling_part) + self.ep_square = ep_square + self.halfmove_clock = halfmove_clock + self.fullmove_number = fullmove_number self.clear_stack() - def _set_castling_fen(self, castling_fen): + def _set_castling_fen(self, castling_fen: str) -> None: if not castling_fen or castling_fen == "-": self.castling_rights = BB_EMPTY return if not FEN_CASTLING_REGEX.match(castling_fen): - raise ValueError("invalid castling fen: {}".format(repr(castling_fen))) + raise ValueError(f"invalid castling fen: {castling_fen!r}") self.castling_rights = BB_EMPTY @@ -2207,26 +2784,28 @@ def _set_castling_fen(self, castling_fen): else: self.castling_rights |= BB_FILES[FILE_NAMES.index(flag)] & backrank - def set_castling_fen(self, castling_fen): + def set_castling_fen(self, castling_fen: str) -> None: """ Sets castling rights from a string in FEN notation like ``Qqk``. + Also clears the move stack. + :raises: :exc:`ValueError` if the castling FEN is syntactically invalid. """ self._set_castling_fen(castling_fen) self.clear_stack() - def set_board_fen(self, fen): + def set_board_fen(self, fen: str) -> None: super().set_board_fen(fen) self.clear_stack() - def set_piece_map(self, pieces): + def set_piece_map(self, pieces: Mapping[Square, Piece]) -> None: super().set_piece_map(pieces) self.clear_stack() - def set_chess960_pos(self, sharnagl): - super().set_chess960_pos(sharnagl) + def set_chess960_pos(self, scharnagl: int) -> None: + super().set_chess960_pos(scharnagl) self.chess960 = True self.turn = WHITE self.castling_rights = self.rooks @@ -2236,13 +2815,13 @@ def set_chess960_pos(self, sharnagl): self.clear_stack() - def chess960_pos(self, *, ignore_turn=False, ignore_castling=False, ignore_counters=True): + def chess960_pos(self, *, ignore_turn: bool = False, ignore_castling: bool = False, ignore_counters: bool = True) -> Optional[int]: """ - Gets the Chess960 starting position index between 0 and 956 + Gets the Chess960 starting position index between 0 and 956, or ``None`` if the current position is not a Chess960 starting position. - By default white to move (**ignore_turn**) and full castling rights + By default, white to move (**ignore_turn**) and full castling rights (**ignore_castling**) are required, but move counters (**ignore_counters**) are ignored. """ @@ -2263,65 +2842,49 @@ def chess960_pos(self, *, ignore_turn=False, ignore_castling=False, ignore_count return super().chess960_pos() - def _epd_operations(self, operations): - epd = [] + def _epd_operations(self, operations: Mapping[str, Union[None, str, int, float, Move, Iterable[Move]]]) -> str: + epd: List[str] = [] first_op = True for opcode, operand in operations.items(): + self._validate_epd_opcode(opcode) + if not first_op: epd.append(" ") first_op = False epd.append(opcode) - # Value is empty. if operand is None: epd.append(";") - continue - - # Value is a move. - if hasattr(operand, "from_square") and hasattr(operand, "to_square") and hasattr(operand, "promotion"): - # Append SAN for moves. + elif isinstance(operand, Move): epd.append(" ") epd.append(self.san(operand)) epd.append(";") - continue - - # Value is numeric. - if isinstance(operand, (int, float)): - # Append integer or float. - epd.append(" ") - epd.append(str(operand)) + elif isinstance(operand, int): + epd.append(f" {operand};") + elif isinstance(operand, float): + assert math.isfinite(operand), f"expected numeric epd operand to be finite, got: {operand}" + epd.append(f" {operand};") + elif opcode == "pv" and not isinstance(operand, str) and hasattr(operand, "__iter__"): + position = self.copy(stack=False) + for move in operand: + epd.append(" ") + epd.append(position.san_and_push(move)) epd.append(";") - continue - - # Value is a set of moves or a variation. - if hasattr(operand, "__iter__"): - position = Board(self.shredder_fen()) if opcode == "pv" else self - iterator = operand.__iter__() - first_move = next(iterator) - if hasattr(first_move, "from_square") and hasattr(first_move, "to_square") and hasattr(first_move, "promotion"): + elif opcode in ["am", "bm"] and not isinstance(operand, str) and hasattr(operand, "__iter__"): + for san in sorted(self.san(move) for move in operand): epd.append(" ") - epd.append(position.san(first_move)) - if opcode == "pv": - position.push(first_move) - - for move in iterator: - epd.append(" ") - epd.append(position.san(move)) - if opcode == "pv": - position.push(move) - - epd.append(";") - continue - - # Append as escaped string. - epd.append(" \"") - epd.append(str(operand).replace("\r", "").replace("\n", " ").replace("\\", "\\\\").replace("\"", "\\\"")) - epd.append("\";") + epd.append(san) + epd.append(";") + else: + # Append as escaped string. + epd.append(" \"") + epd.append(str(operand).replace("\\", "\\\\").replace("\t", "\\t").replace("\r", "\\r").replace("\n", "\\n").replace("\"", "\\\"")) + epd.append("\";") return "".join(epd) - def epd(self, *, shredder=False, en_passant="legal", promoted=None, **operations): + def epd(self, *, shredder: bool = False, en_passant: EnPassantSpec = "legal", promoted: Optional[bool] = None, **operations: Union[None, str, int, float, Move, Iterable[Move]]) -> str: """ Gets an EPD representation of the current position. @@ -2329,38 +2892,52 @@ def epd(self, *, shredder=False, en_passant="legal", promoted=None, **operations *ep_square* and *promoted*). EPD operations can be given as keyword arguments. Supported operands - are strings, integers, floats, moves, lists of moves and ``None``. - All other operands are converted to strings. + are strings, integers, finite floats, legal moves and ``None``. + Additionally, the operation ``pv`` accepts a legal variation as + a list of moves. The operations ``am`` and ``bm`` accept a list of + legal moves in the current position. - A list of moves for *pv* will be interpreted as a variation. All other - move lists are interpreted as a set of moves in the current position. + The name of the field cannot be a lone dash and cannot contain spaces, + newlines, carriage returns or tabs. - *hmvc* and *fmvc* are not included by default. You can use: + *hmvc* and *fmvn* are not included by default. You can use: >>> import chess >>> >>> board = chess.Board() - >>> board.epd(hmvc=board.halfmove_clock, fmvc=board.fullmove_number) - 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - hmvc 0; fmvc 1;' + >>> board.epd(hmvc=board.halfmove_clock, fmvn=board.fullmove_number) + 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - hmvc 0; fmvn 1;' """ - epd = [self.board_fen(promoted=promoted), - "w" if self.turn == WHITE else "b", - self.castling_shredder_fen() if shredder else self.castling_xfen()] - if en_passant == "fen": - epd.append(SQUARE_NAMES[self.ep_square] if self.ep_square is not None else "-") + ep_square = self.ep_square elif en_passant == "xfen": - epd.append(SQUARE_NAMES[self.ep_square] if self.has_pseudo_legal_en_passant() else "-") + ep_square = self.ep_square if self.has_pseudo_legal_en_passant() else None else: - epd.append(SQUARE_NAMES[self.ep_square] if self.has_legal_en_passant() else "-") + ep_square = self.ep_square if self.has_legal_en_passant() else None + + epd = [self.board_fen(promoted=promoted), + "w" if self.turn == WHITE else "b", + self.castling_shredder_fen() if shredder else self.castling_xfen(), + SQUARE_NAMES[ep_square] if ep_square is not None else "-"] if operations: epd.append(self._epd_operations(operations)) return " ".join(epd) - def _parse_epd_ops(self, operation_part, make_board): - operations = {} + def _validate_epd_opcode(self, opcode: str) -> None: + if not opcode: + raise ValueError("empty string is not a valid epd opcode") + if opcode == "-": + raise ValueError("dash (-) is not a valid epd opcode") + if not opcode[0].isalpha(): + raise ValueError(f"expected epd opcode to start with a letter, got: {opcode!r}") + for blacklisted in [" ", "\n", "\t", "\r"]: + if blacklisted in opcode: + raise ValueError(f"invalid character {blacklisted!r} in epd opcode: {opcode!r}") + + def _parse_epd_ops(self, operation_part: str, make_board: Callable[[], Self]) -> Dict[str, Union[None, str, int, float, Move, List[Move]]]: + operations: Dict[str, Union[None, str, int, float, Move, List[Move]]] = {} state = "opcode" opcode = "" operand = "" @@ -2368,45 +2945,52 @@ def _parse_epd_ops(self, operation_part, make_board): for ch in itertools.chain(operation_part, [None]): if state == "opcode": - if ch == " ": - if opcode: + if ch in [" ", "\t", "\r", "\n"]: + if opcode == "-": + opcode = "" + elif opcode: + self._validate_epd_opcode(opcode) state = "after_opcode" - elif ch in [";", None]: - if opcode: - operations[opcode] = None + elif ch is None or ch == ";": + if opcode == "-": + opcode = "" + elif opcode: + operations[opcode] = [] if opcode in ["pv", "am", "bm"] else None opcode = "" else: opcode += ch elif state == "after_opcode": - if ch == " ": + if ch in [" ", "\t", "\r", "\n"]: pass - elif ch in "+-.0123456789": - operand = ch - state = "numeric" elif ch == "\"": state = "string" - elif ch in [";", None]: + elif ch is None or ch == ";": if opcode: - operations[opcode] = None + operations[opcode] = [] if opcode in ["pv", "am", "bm"] else None opcode = "" state = "opcode" + elif ch in "+-.0123456789": + operand = ch + state = "numeric" else: operand = ch state = "san" elif state == "numeric": - if ch in [";", None]: - operations[opcode] = float(operand) - try: + if ch is None or ch == ";": + if "." in operand or "e" in operand or "E" in operand: + parsed = float(operand) + if not math.isfinite(parsed): + raise ValueError(f"invalid numeric operand for epd operation {opcode!r}: {operand!r}") + operations[opcode] = parsed + else: operations[opcode] = int(operand) - except: - pass opcode = "" operand = "" state = "opcode" else: operand += ch elif state == "string": - if ch in ["\"", None]: + if ch is None or ch == "\"": operations[opcode] = operand opcode = "" operand = "" @@ -2421,31 +3005,42 @@ def _parse_epd_ops(self, operation_part, make_board): opcode = "" operand = "" state = "opcode" + elif ch == "r": + operand += "\r" + state = "string" + elif ch == "n": + operand += "\n" + state = "string" + elif ch == "t": + operand += "\t" + state = "string" else: operand += ch state = "string" elif state == "san": - if ch in [";", None]: + if ch is None or ch == ";": if position is None: position = make_board() if opcode == "pv": # A variation. - operations[opcode] = [] + variation: List[Move] = [] for token in operand.split(): - move = position.parse_san(token) - operations[opcode].append(move) + move = position.parse_xboard(token) + variation.append(move) position.push(move) # Reset the position. while position.move_stack: position.pop() + + operations[opcode] = variation elif opcode in ["bm", "am"]: # A set of moves. - operations[opcode] = [position.parse_san(token) for token in operand.split()] + operations[opcode] = [position.parse_xboard(token) for token in operand.split()] else: # A single move. - operations[opcode] = position.parse_san(operand) + operations[opcode] = position.parse_xboard(operand) opcode = "" operand = "" @@ -2456,7 +3051,7 @@ def _parse_epd_ops(self, operation_part, make_board): assert state == "opcode" return operations - def set_epd(self, epd): + def set_epd(self, epd: str) -> Dict[str, Union[None, str, int, float, Move, List[Move]]]: """ Parses the given EPD string and uses it to set the position. @@ -2468,87 +3063,94 @@ def set_epd(self, epd): :raises: :exc:`ValueError` if the EPD string is invalid. """ - # Split into 4 or 5 parts. parts = epd.strip().rstrip(";").split(None, 4) - if len(parts) < 4: - raise ValueError("epd should consist of at least 4 parts: {}".format(repr(epd))) # Parse ops. if len(parts) > 4: operations = self._parse_epd_ops(parts.pop(), lambda: type(self)(" ".join(parts) + " 0 1")) + parts.append(str(operations["hmvc"]) if "hmvc" in operations else "0") + parts.append(str(operations["fmvn"]) if "fmvn" in operations else "1") + self.set_fen(" ".join(parts)) + return operations else: - operations = {} - - # Create a full FEN and parse it. - parts.append(str(operations["hmvc"]) if "hmvc" in operations else "0") - parts.append(str(operations["fmvn"]) if "fmvn" in operations else "1") - self.set_fen(" ".join(parts)) - - return operations + self.set_fen(epd) + return {} - def san(self, move): + def san(self, move: Move) -> str: """ Gets the standard algebraic notation of the given move in the context of the current position. """ return self._algebraic(move) - def lan(self, move): + def lan(self, move: Move) -> str: """ Gets the long algebraic notation of the given move in the context of the current position. """ return self._algebraic(move, long=True) - def _algebraic(self, move, long=False): - if not move: - # Null move. - return "--" + def san_and_push(self, move: Move) -> str: + return self._algebraic_and_push(move) + + def _algebraic(self, move: Move, *, long: bool = False) -> str: + san = self._algebraic_and_push(move, long=long) + self.pop() + return san + + def _algebraic_and_push(self, move: Move, *, long: bool = False) -> str: + san = self._algebraic_without_suffix(move, long=long) # Look ahead for check or checkmate. self.push(move) is_check = self.is_check() is_checkmate = (is_check and self.is_checkmate()) or self.is_variant_loss() or self.is_variant_win() - self.pop() + + # Add check or checkmate suffix. + if is_checkmate and move: + return san + "#" + elif is_check and move: + return san + "+" + else: + return san + + def _algebraic_without_suffix(self, move: Move, *, long: bool = False) -> str: + # Null move. + if not move: + return "--" # Drops. if move.drop: san = "" if move.drop != PAWN: - san = PIECE_SYMBOLS[move.drop].upper() + san = piece_symbol(move.drop).upper() san += "@" + SQUARE_NAMES[move.to_square] + return san # Castling. if self.is_castling(move): if square_file(move.to_square) < square_file(move.from_square): - san = "O-O-O" - else: - san = "O-O" - - if move.drop or self.is_castling(move): - if is_checkmate: - return san + "#" - elif is_check: - return san + "+" + return "O-O-O" else: - return san + return "O-O" - piece = self.piece_type_at(move.from_square) + piece_type = self.piece_type_at(move.from_square) + assert piece_type, f"san() and lan() expect move to be legal or null, but got {move} in {self.fen()}" capture = self.is_capture(move) - if piece == PAWN: + if piece_type == PAWN: san = "" else: - san = PIECE_SYMBOLS[piece].upper() + san = piece_symbol(piece_type).upper() if long: san += SQUARE_NAMES[move.from_square] - elif piece != PAWN: + elif piece_type != PAWN: # Get ambiguous move candidates. # Relevant candidates: not exactly the current move, # but to the same square. others = 0 - from_mask = self.pieces_mask(piece, self.turn) + from_mask = self.pieces_mask(piece_type, self.turn) from_mask &= ~BB_SQUARES[move.from_square] to_mask = BB_SQUARES[move.to_square] for candidate in self.generate_legal_moves(from_mask, to_mask): @@ -2584,17 +3186,11 @@ def _algebraic(self, move, long=False): # Promotion. if move.promotion: - san += "=" + PIECE_SYMBOLS[move.promotion].upper() - - # Add check or checkmate suffix. - if is_checkmate: - san += "#" - elif is_check: - san += "+" + san += "=" + piece_symbol(move.promotion).upper() return san - def variation_san(self, variation): + def variation_san(self, variation: Iterable[Move]) -> str: """ Given a sequence of moves, returns a string representing the sequence in standard algebraic notation (e.g., ``1. e4 e5 2. Nf3 Nc6`` or @@ -2602,75 +3198,99 @@ def variation_san(self, variation): The board will not be modified as a result of calling this. - :raises: :exc:`ValueError` if any moves in the sequence are illegal. + :raises: :exc:`IllegalMoveError` if any moves in the sequence are illegal. """ board = self.copy(stack=False) - san = [] + san: List[str] = [] for move in variation: if not board.is_legal(move): - raise ValueError("illegal move {} in position {}".format(move, board.fen())) + raise IllegalMoveError(f"illegal move {move} in position {board.fen()}") if board.turn == WHITE: - san.append("{}. {}".format(board.fullmove_number, board.san(move))) + san.append(f"{board.fullmove_number}. {board.san_and_push(move)}") elif not san: - san.append("{}...{}".format(board.fullmove_number, board.san(move))) + san.append(f"{board.fullmove_number}...{board.san_and_push(move)}") else: - san.append(board.san(move)) - - board.push(move) + san.append(board.san_and_push(move)) return " ".join(san) - def parse_san(self, san): + def parse_san(self, san: str) -> Move: """ Uses the current position as the context to parse a move in standard algebraic notation and returns the corresponding move object. + Ambiguous moves are rejected. Overspecified moves (including long + algebraic notation) are accepted. Some common syntactical deviations + are also accepted. + The returned move is guaranteed to be either legal or a null move. - :raises: :exc:`ValueError` if the SAN is invalid or ambiguous. + :raises: + :exc:`ValueError` (specifically an exception specified below) if the SAN is invalid, illegal or ambiguous. + + - :exc:`InvalidMoveError` if the SAN is syntactically invalid. + - :exc:`IllegalMoveError` if the SAN is illegal. + - :exc:`AmbiguousMoveError` if the SAN is ambiguous. """ # Castling. try: - if san in ["O-O", "O-O+", "O-O#"]: + if san in ["O-O", "O-O+", "O-O#", "0-0", "0-0+", "0-0#"]: return next(move for move in self.generate_castling_moves() if self.is_kingside_castling(move)) - elif san in ["O-O-O", "O-O-O+", "O-O-O#"]: + elif san in ["O-O-O", "O-O-O+", "O-O-O#", "0-0-0", "0-0-0+", "0-0-0#"]: return next(move for move in self.generate_castling_moves() if self.is_queenside_castling(move)) except StopIteration: - raise ValueError("illegal san: {} in {}".format(repr(san), self.fen())) + raise IllegalMoveError(f"illegal san: {san!r} in {self.fen()}") # Match normal moves. match = SAN_REGEX.match(san) if not match: # Null moves. - if san in ["--", "Z0"]: + if san in ["--", "Z0", "0000", "@@@@"]: return Move.null() + elif "," in san: + raise InvalidMoveError(f"unsupported multi-leg move: {san!r}") + else: + raise InvalidMoveError(f"invalid san: {san!r}") - raise ValueError("invalid san: {}".format(repr(san))) - - # Get target square. + # Get target square. Mask our own pieces to exclude castling moves. to_square = SQUARE_NAMES.index(match.group(4)) - to_mask = BB_SQUARES[to_square] + to_mask = BB_SQUARES[to_square] & ~self.occupied_co[self.turn] - # Get the promotion type. + # Get the promotion piece type. p = match.group(5) - promotion = p and PIECE_SYMBOLS.index(p[-1].lower()) + promotion = PIECE_SYMBOLS.index(p[-1].lower()) if p else None + + # Filter by original square. + from_mask = BB_ALL + from_file = None + from_rank = None + if match.group(2): + from_file = FILE_NAMES.index(match.group(2)) + from_mask &= BB_FILES[from_file] + if match.group(3): + from_rank = int(match.group(3)) - 1 + from_mask &= BB_RANKS[from_rank] # Filter by piece type. if match.group(1): piece_type = PIECE_SYMBOLS.index(match.group(1).lower()) - from_mask = self.pieces_mask(piece_type, self.turn) + from_mask &= self.pieces_mask(piece_type, self.turn) + elif from_file is not None and from_rank is not None: + # Allow fully specified moves, even if they are not pawn moves, + # including castling moves. + move = self.find_move(square(from_file, from_rank), to_square, promotion) + if move.promotion == promotion: + return move + else: + raise IllegalMoveError(f"missing promotion piece type: {san!r} in {self.fen()}") else: - from_mask = self.pawns - - # Filter by source file. - if match.group(2): - from_mask &= BB_FILES[FILE_NAMES.index(match.group(2))] + from_mask &= self.pawns - # Filter by source rank. - if match.group(3): - from_mask &= BB_RANKS[int(match.group(3)) - 1] + # Do not allow pawn captures if file is not specified. + if from_file is None: + from_mask &= BB_FILES[square_file(to_square)] # Match legal moves. matched_move = None @@ -2679,29 +3299,34 @@ def parse_san(self, san): continue if matched_move: - raise ValueError("ambiguous san: {} in {}".format(repr(san), self.fen())) + raise AmbiguousMoveError(f"ambiguous san: {san!r} in {self.fen()}") matched_move = move if not matched_move: - raise ValueError("illegal san: {} in {}".format(repr(san), self.fen())) + raise IllegalMoveError(f"illegal san: {san!r} in {self.fen()}") return matched_move - def push_san(self, san): + def push_san(self, san: str) -> Move: """ Parses a move in standard algebraic notation, makes the move and puts - it on the the move stack. + it onto the move stack. Returns the move. - :raises: :exc:`ValueError` if neither legal nor a null move. + :raises: + :exc:`ValueError` (specifically an exception specified below) if neither legal nor a null move. + + - :exc:`InvalidMoveError` if the SAN is syntactically invalid. + - :exc:`IllegalMoveError` if the SAN is illegal. + - :exc:`AmbiguousMoveError` if the SAN is ambiguous. """ move = self.parse_san(san) self.push(move) return move - def uci(self, move, *, chess960=None): + def uci(self, move: Move, *, chess960: Optional[bool] = None) -> str: """ Gets the UCI notation of the move. @@ -2715,7 +3340,7 @@ def uci(self, move, *, chess960=None): move = self._from_chess960(chess960, move.from_square, move.to_square, move.promotion, move.drop) return move.uci() - def parse_uci(self, uci): + def parse_uci(self, uci: str) -> Move: """ Parses the given move in UCI notation. @@ -2723,8 +3348,12 @@ def parse_uci(self, uci): The returned move is guaranteed to be either legal or a null move. - :raises: :exc:`ValueError` if the move is invalid or illegal in the + :raises: + :exc:`ValueError` (specifically an exception specified below) if the move is invalid or illegal in the current position (but not a null move). + + - :exc:`InvalidMoveError` if the UCI is syntactically invalid. + - :exc:`IllegalMoveError` if the UCI is illegal. """ move = Move.from_uci(uci) @@ -2735,79 +3364,107 @@ def parse_uci(self, uci): move = self._from_chess960(self.chess960, move.from_square, move.to_square, move.promotion, move.drop) if not self.is_legal(move): - raise ValueError("illegal uci: {} in {}".format(repr(uci), self.fen())) + raise IllegalMoveError(f"illegal uci: {uci!r} in {self.fen()}") return move - def push_uci(self, uci): + def push_uci(self, uci: str) -> Move: """ Parses a move in UCI notation and puts it on the move stack. Returns the move. - :raises: :exc:`ValueError` if the move is invalid or illegal in the + :raises: + :exc:`ValueError` (specifically an exception specified below) if the move is invalid or illegal in the current position (but not a null move). + + - :exc:`InvalidMoveError` if the UCI is syntactically invalid. + - :exc:`IllegalMoveError` if the UCI is illegal. """ move = self.parse_uci(uci) self.push(move) return move - def is_en_passant(self, move): + def xboard(self, move: Move, chess960: Optional[bool] = None) -> str: + if chess960 is None: + chess960 = self.chess960 + + if not chess960 or not self.is_castling(move): + return move.xboard() + elif self.is_kingside_castling(move): + return "O-O" + else: + return "O-O-O" + + def parse_xboard(self, xboard: str) -> Move: + return self.parse_san(xboard) + + push_xboard = push_san + + def is_en_passant(self, move: Move) -> bool: """Checks if the given pseudo-legal move is an en passant capture.""" return (self.ep_square == move.to_square and bool(self.pawns & BB_SQUARES[move.from_square]) and abs(move.to_square - move.from_square) in [7, 9] and not self.occupied & BB_SQUARES[move.to_square]) - def is_capture(self, move): + def is_capture(self, move: Move) -> bool: """Checks if the given pseudo-legal move is a capture.""" - return bool(BB_SQUARES[move.to_square] & self.occupied_co[not self.turn]) or self.is_en_passant(move) + touched = BB_SQUARES[move.from_square] ^ BB_SQUARES[move.to_square] + return bool(touched & self.occupied_co[not self.turn]) or self.is_en_passant(move) - def is_zeroing(self, move): + def is_zeroing(self, move: Move) -> bool: """Checks if the given pseudo-legal move is a capture or pawn move.""" - return bool(BB_SQUARES[move.from_square] & self.pawns or BB_SQUARES[move.to_square] & self.occupied_co[not self.turn]) + touched = BB_SQUARES[move.from_square] ^ BB_SQUARES[move.to_square] + return bool(touched & self.pawns or touched & self.occupied_co[not self.turn] or move.drop == PAWN) - def is_irreversible(self, move): + 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._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: """ Checks if the given pseudo-legal move is irreversible. - In standard chess, pawn moves, captures and moves that destroy castling - rights are irreversible. + In standard chess, pawn moves, captures, moves that destroy castling + rights and moves that cede en passant are irreversible. + + This method has false-negatives with forced lines. For example, a check + that will force the king to lose castling rights is not considered + irreversible. Only the actual king move is. """ - backrank = BB_RANK_1 if self.turn == WHITE else BB_RANK_8 - cr = self.clean_castling_rights() & backrank - return bool(self.is_zeroing(move) or - cr and BB_SQUARES[move.from_square] & self.kings & ~self.promoted or - cr & BB_SQUARES[move.from_square] or - cr & BB_SQUARES[move.to_square]) + return self.is_zeroing(move) or self._reduces_castling_rights(move) or self.has_legal_en_passant() - def is_castling(self, move): + def is_castling(self, move: Move) -> bool: """Checks if the given pseudo-legal move is a castling move.""" if self.kings & BB_SQUARES[move.from_square]: diff = square_file(move.from_square) - square_file(move.to_square) return abs(diff) > 1 or bool(self.rooks & self.occupied_co[self.turn] & BB_SQUARES[move.to_square]) return False - def is_kingside_castling(self, move): + def is_kingside_castling(self, move: Move) -> bool: """ Checks if the given pseudo-legal move is a kingside castling move. """ return self.is_castling(move) and square_file(move.to_square) > square_file(move.from_square) - def is_queenside_castling(self, move): + def is_queenside_castling(self, move: Move) -> bool: """ Checks if the given pseudo-legal move is a queenside castling move. """ return self.is_castling(move) and square_file(move.to_square) < square_file(move.from_square) - def clean_castling_rights(self): + def clean_castling_rights(self) -> Bitboard: """ Returns valid castling rights filtered from :data:`~chess.Board.castling_rights`. """ - if self.stack: - # Castling rights do not change in a game, so we can assume them to - # be filtered already. + if self._stack: + # No new castling rights are assigned in a game, so we can assume + # they were filtered already. return self.castling_rights castling = self.castling_rights & self.rooks @@ -2820,16 +3477,16 @@ def clean_castling_rights(self): 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: @@ -2845,7 +3502,7 @@ def clean_castling_rights(self): if white_h_side and msb(white_h_side) < msb(white_king_mask): white_h_side = 0 - black_a_side = (black_castling & -black_castling) + black_a_side = black_castling & -black_castling black_h_side = BB_SQUARES[msb(black_castling)] if black_castling else BB_EMPTY if black_a_side and msb(black_a_side) > msb(black_king_mask): @@ -2856,18 +3513,18 @@ def clean_castling_rights(self): # Done. return black_a_side | black_h_side | white_a_side | white_h_side - def has_castling_rights(self, color): + def has_castling_rights(self, color: Color) -> bool: """Checks if the given side has castling rights.""" backrank = BB_RANK_1 if color == WHITE else BB_RANK_8 return bool(self.clean_castling_rights() & backrank) - def has_kingside_castling_rights(self, color): + def has_kingside_castling_rights(self, color: Color) -> bool: """ Checks if the given side has kingside (that is h-side in Chess960) 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 @@ -2878,17 +3535,17 @@ def has_kingside_castling_rights(self, color): if rook > king_mask: return True - castling_rights = castling_rights & (castling_rights - 1) + castling_rights &= castling_rights - 1 return False - def has_queenside_castling_rights(self, color): + def has_queenside_castling_rights(self, color: Color) -> bool: """ Checks if the given side has queenside (that is a-side in Chess960) 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 @@ -2899,11 +3556,11 @@ def has_queenside_castling_rights(self, color): if rook < king_mask: return True - castling_rights = castling_rights & (castling_rights - 1) + castling_rights &= castling_rights - 1 return False - def has_chess960_castling_rights(self): + def has_chess960_castling_rights(self) -> bool: """ Checks if there are castling rights that are only possible in Chess960. """ @@ -2927,14 +3584,13 @@ def has_chess960_castling_rights(self): return False - def status(self): + def status(self) -> Status: """ Gets a bitmask of possible problems with the position. - Move making, generation and validation are only guaranteed to work on - a completely valid board. - - :data:`~chess.STATUS_VALID` for a completely valid board. + :data:`~chess.STATUS_VALID` if all basic validity requirements are met. + This does not imply that the position is actually reachable with a + series of legal moves from the starting position. Otherwise, bitwise combinations of: :data:`~chess.STATUS_NO_WHITE_KING`, @@ -2951,7 +3607,9 @@ def status(self): :data:`~chess.STATUS_EMPTY`, :data:`~chess.STATUS_RACE_CHECK`, :data:`~chess.STATUS_RACE_OVER`, - :data:`~chess.STATUS_RACE_MATERIAL`. + :data:`~chess.STATUS_RACE_MATERIAL`, + :data:`~chess.STATUS_TOO_MANY_CHECKERS`, + :data:`~chess.STATUS_IMPOSSIBLE_CHECK`. """ errors = STATUS_VALID @@ -2960,11 +3618,11 @@ def status(self): 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. @@ -2988,62 +3646,82 @@ def status(self): errors |= STATUS_BAD_CASTLING_RIGHTS # En passant. - if self.ep_square != self._valid_ep_square(): + valid_ep_square = self._valid_ep_square() + if self.ep_square != valid_ep_square: errors |= STATUS_INVALID_EP_SQUARE # Side to move giving check. if self.was_into_check(): errors |= STATUS_OPPOSITE_CHECK + # 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._effective_promoted() + if checkers: + if popcount(checkers) > 2: + errors |= STATUS_TOO_MANY_CHECKERS + + if valid_ep_square is not None: + pushed_to = valid_ep_square ^ A2 + pushed_from = valid_ep_square ^ A4 + occupied_before = (self.occupied & ~BB_SQUARES[pushed_to]) | BB_SQUARES[pushed_from] + if popcount(checkers) > 1 or ( + msb(checkers) != pushed_to and + self._attacked_for_king(our_kings, occupied_before)): + errors |= STATUS_IMPOSSIBLE_CHECK + else: + if popcount(checkers) > 2 or (popcount(checkers) == 2 and ray(lsb(checkers), msb(checkers)) & our_kings): + errors |= STATUS_IMPOSSIBLE_CHECK + return errors - def _valid_ep_square(self): - if self.ep_square: - if self.turn == WHITE: - ep_rank = 5 - pawn_mask = shift_down(BB_SQUARES[self.ep_square]) - seventh_rank_mask = shift_up(BB_SQUARES[self.ep_square]) - else: - ep_rank = 2 - pawn_mask = shift_up(BB_SQUARES[self.ep_square]) - seventh_rank_mask = shift_down(BB_SQUARES[self.ep_square]) + def _valid_ep_square(self) -> Optional[Square]: + if not self.ep_square: + return None + + if self.turn == WHITE: + 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 = RANK_3 + pawn_mask = shift_up(BB_SQUARES[self.ep_square]) + seventh_rank_mask = shift_down(BB_SQUARES[self.ep_square]) - # The en passant square must be on the third or sixth rank. - if square_rank(self.ep_square) != ep_rank: - return + # The en passant square must be on the third or sixth rank. + if square_rank(self.ep_square) != ep_rank: + return None - # The last move must have been a double pawn push, so there must - # be a pawn of the correct color on the fourth or fifth rank. - if not self.pawns & self.occupied_co[not self.turn] & pawn_mask: - return + # The last move must have been a double pawn push, so there must + # be a pawn of the correct color on the fourth or fifth rank. + if not self.pawns & self.occupied_co[not self.turn] & pawn_mask: + return None - # And the en passant square must be empty. - if self.occupied & BB_SQUARES[self.ep_square]: - return + # And the en passant square must be empty. + if self.occupied & BB_SQUARES[self.ep_square]: + return None - # And the second rank must be empty. - if self.occupied & seventh_rank_mask: - return + # And the second rank must be empty. + if self.occupied & seventh_rank_mask: + return None - return self.ep_square + return self.ep_square - def is_valid(self): + def is_valid(self) -> bool: """ - Checks if the board is valid. - - Move making, generation and validation are only guaranteed to work on - a completely valid board. + Checks some basic validity requirements. See :func:`~chess.Board.status()` for details. """ return self.status() == STATUS_VALID - def _ep_skewered(self, king, capturer): + def _ep_skewered(self, king: Square, capturer: Square) -> bool: # Handle the special case where the king would be in check if the # pawn and its capturer disappear from the rank. # Vertical skewers of the captured pawn are not possible. (Pins on # the capturer are not handled here.) + assert self.ep_square is not None last_double = self.ep_square + (-8 if self.turn == WHITE else 8) @@ -3064,7 +3742,7 @@ def _ep_skewered(self, king, capturer): return False - def _slider_blockers(self, king): + def _slider_blockers(self, king: Square) -> Bitboard: rooks_and_queens = self.rooks | self.queens bishops_and_queens = self.bishops | self.queens @@ -3075,7 +3753,7 @@ def _slider_blockers(self, king): blockers = 0 for sniper in scan_reversed(snipers & self.occupied_co[not self.turn]): - b = BB_BETWEEN[king][sniper] & self.occupied + b = between(king, sniper) & self.occupied # Add to blockers if exactly one piece in-between. if b and BB_SQUARES[msb(b)] == b: @@ -3083,25 +3761,25 @@ def _slider_blockers(self, king): return blockers & self.occupied_co[self.turn] - def _is_safe(self, king, blockers, move): + def _is_safe(self, king: Square, blockers: Bitboard, move: Move) -> bool: if move.from_square == king: if self.is_castling(move): return True else: return not self.is_attacked_by(not self.turn, move.to_square) elif self.is_en_passant(move): - return (self.pin_mask(self.turn, move.from_square) & BB_SQUARES[move.to_square] and - not self._ep_skewered(king, move.from_square)) + return bool(self.pin_mask(self.turn, move.from_square) & BB_SQUARES[move.to_square] and + not self._ep_skewered(king, move.from_square)) else: - return (not blockers & BB_SQUARES[move.from_square] or - BB_RAYS[move.from_square][move.to_square] & BB_SQUARES[king]) + return bool(not blockers & BB_SQUARES[move.from_square] or + ray(move.from_square, move.to_square) & BB_SQUARES[king]) - def _generate_evasions(self, king, checkers, from_mask=BB_ALL, to_mask=BB_ALL): + def _generate_evasions(self, king: Square, checkers: Bitboard, from_mask: Bitboard = BB_ALL, to_mask: Bitboard = BB_ALL) -> Iterator[Move]: sliders = checkers & (self.bishops | self.rooks | self.queens) attacked = 0 for checker in scan_reversed(sliders): - attacked |= BB_RAYS[king][checker] & ~BB_SQUARES[checker] + attacked |= ray(king, checker) & ~BB_SQUARES[checker] if BB_SQUARES[king] & from_mask: for to_square in scan_reversed(BB_KING_ATTACKS[king] & ~self.occupied_co[self.turn] & ~attacked & to_mask): @@ -3110,7 +3788,7 @@ def _generate_evasions(self, king, checkers, from_mask=BB_ALL, to_mask=BB_ALL): checker = msb(checkers) if BB_SQUARES[checker] == checkers: # Capture or block a single checker. - target = BB_BETWEEN[king][checker] | checkers + target = between(king, checker) | checkers yield from self.generate_pseudo_legal_moves(~self.kings & from_mask, target & to_mask) @@ -3121,7 +3799,7 @@ def _generate_evasions(self, king, checkers, from_mask=BB_ALL, to_mask=BB_ALL): if last_double == checker: yield from self.generate_pseudo_legal_ep(from_mask, to_mask) - def generate_legal_moves(self, from_mask=BB_ALL, to_mask=BB_ALL): + def generate_legal_moves(self, from_mask: Bitboard = BB_ALL, to_mask: Bitboard = BB_ALL) -> Iterator[Move]: if self.is_variant_end(): return @@ -3141,7 +3819,7 @@ def generate_legal_moves(self, from_mask=BB_ALL, to_mask=BB_ALL): else: yield from self.generate_pseudo_legal_moves(from_mask, to_mask) - def generate_legal_ep(self, from_mask=BB_ALL, to_mask=BB_ALL): + def generate_legal_ep(self, from_mask: Bitboard = BB_ALL, to_mask: Bitboard = BB_ALL) -> Iterator[Move]: if self.is_variant_end(): return @@ -3149,29 +3827,22 @@ def generate_legal_ep(self, from_mask=BB_ALL, to_mask=BB_ALL): if not self.is_into_check(move): yield move - def generate_legal_captures(self, from_mask=BB_ALL, to_mask=BB_ALL): + def generate_legal_captures(self, from_mask: Bitboard = BB_ALL, to_mask: Bitboard = BB_ALL) -> Iterator[Move]: return itertools.chain( self.generate_legal_moves(from_mask, to_mask & self.occupied_co[not self.turn]), self.generate_legal_ep(from_mask, to_mask)) - def _attacked_for_king(self, path, occupied): - return any(self._attackers_mask(not self.turn, sq, occupied) for sq in scan_reversed(path)) - - def _castling_uncovers_rank_attack(self, rook_bb, king_to): - # Test the special case where we castle and our rook shielded us from - # an attack, so castling would be into check. - rank_pieces = BB_RANK_MASKS[king_to] & (self.occupied ^ rook_bb) - sliders = (self.queens | self.rooks) & self.occupied_co[not self.turn] - return BB_RANK_ATTACKS[king_to][rank_pieces] & sliders + def _attacked_for_king(self, path: Bitboard, occupied: Bitboard) -> bool: + return any(self.attackers_mask(not self.turn, sq, occupied) for sq in scan_reversed(path)) - def generate_castling_moves(self, from_mask=BB_ALL, to_mask=BB_ALL): + def generate_castling_moves(self, from_mask: Bitboard = BB_ALL, to_mask: Bitboard = BB_ALL) -> Iterator[Move]: if self.is_variant_end(): 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 = king & -king - if not king or self._attacked_for_king(king, self.occupied): + king = self.occupied_co[self.turn] & self.kings & ~self._effective_promoted() & backrank & from_mask + king &= -king + if not king: return bb_c = BB_FILE_C & backrank @@ -3183,30 +3854,19 @@ def generate_castling_moves(self, from_mask=BB_ALL, to_mask=BB_ALL): rook = BB_SQUARES[candidate] a_side = rook < king + king_to = bb_c if a_side else bb_g + rook_to = bb_d if a_side else bb_f - empty_for_rook = 0 - empty_for_king = 0 + king_path = between(msb(king), msb(king_to)) + rook_path = between(candidate, msb(rook_to)) - if a_side: - king_to = msb(bb_c) - if not rook & bb_d: - empty_for_rook = BB_BETWEEN[candidate][msb(bb_d)] | bb_d - if not king & bb_c: - empty_for_king = BB_BETWEEN[msb(king)][king_to] | bb_c - else: - king_to = msb(bb_g) - if not rook & bb_f: - empty_for_rook = BB_BETWEEN[candidate][msb(bb_f)] | bb_f - if not king & bb_g: - empty_for_king = BB_BETWEEN[msb(king)][king_to] | bb_g - - if not ((self.occupied ^ king ^ rook) & (empty_for_king | empty_for_rook) or - self._attacked_for_king(empty_for_king, self.occupied ^ king) or - self._castling_uncovers_rank_attack(rook, king_to)): + if not ((self.occupied ^ king ^ rook) & (king_path | rook_path | king_to | rook_to) or + self._attacked_for_king(king_path | king, self.occupied ^ king) or + self._attacked_for_king(king_to, self.occupied ^ king ^ rook ^ rook_to)): yield self._from_chess960(self.chess960, msb(king), candidate) - def _from_chess960(self, chess960, from_square, to_square, promotion=None, drop=None): - if not chess960 and drop is None: + def _from_chess960(self, chess960: bool, from_square: Square, to_square: Square, promotion: Optional[PieceType] = None, drop: Optional[PieceType] = None) -> Move: + if not chess960 and promotion is None and drop is None: if from_square == E1 and self.kings & BB_E1: if to_square == H1: return Move(E1, G1) @@ -3220,7 +3880,7 @@ def _from_chess960(self, chess960, from_square, to_square, promotion=None, drop= return Move(from_square, to_square, promotion, drop) - def _to_chess960(self, move): + def _to_chess960(self, move: Move) -> Move: if move.from_square == E1 and self.kings & BB_E1: if move.to_square == G1 and not self.rooks & BB_G1: return Move(E1, H1) @@ -3234,59 +3894,75 @@ def _to_chess960(self, move): return move - def _transposition_key(self): + 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) - def __repr__(self): + def __repr__(self) -> str: if not self.chess960: - return "{}('{}')".format(type(self).__name__, self.fen()) + return f"{type(self).__name__}({self.fen()!r})" else: - return "{}('{}', chess960=True)".format(type(self).__name__, self.fen()) + return f"{type(self).__name__}({self.fen()!r}, chess960=True)" - def _repr_svg_(self): + def _repr_svg_(self) -> str: import chess.svg - lastmove = self.peek() if self.move_stack else None - check = self.king(self.turn) if self.is_check() else None - return chess.svg.board(board=self, lastmove=lastmove, check=check, size=400) - - def __ne__(self, board): - # Compare positions (including move counters), but excluding history. - try: - if self.halfmove_clock != board.halfmove_clock: - return True - if self.fullmove_number != board.fullmove_number: - return True - - if type(self).uci_variant != type(board).uci_variant: - return True - if self._transposition_key() != board._transposition_key(): - return True - except AttributeError: - return NotImplemented + return chess.svg.board( + board=self, + size=390, + lastmove=self.peek() if self.move_stack else None, + check=self.king(self.turn) if self.is_check() else None) + + def __eq__(self, board: object) -> bool: + if isinstance(board, Board): + return ( + self.halfmove_clock == board.halfmove_clock and + self.fullmove_number == board.fullmove_number and + type(self).uci_variant == type(board).uci_variant and + self._transposition_key() == board._transposition_key()) else: - return False + return NotImplemented - def apply_transform(self, f): + def apply_transform(self, f: Callable[[Bitboard], Bitboard]) -> None: super().apply_transform(f) self.clear_stack() + self.ep_square = None if self.ep_square is None else msb(f(BB_SQUARES[self.ep_square])) + self.castling_rights = f(self.castling_rights) - def transform(self, f): + def transform(self, f: Callable[[Bitboard], Bitboard]) -> Self: board = self.copy(stack=False) board.apply_transform(f) - board.ep_square = None if self.ep_square is None else msb(f(BB_SQUARES[self.ep_square])) - board.castling_rights = f(self.castling_rights) return board - def mirror(self): - board = super().mirror() - board.turn = not self.turn + def apply_mirror(self) -> None: + super().apply_mirror() + self.turn = not self.turn + + def mirror(self) -> Self: + """ + Returns a mirrored copy of the board. + + The board is mirrored vertically and piece colors are swapped, so that + the position is equivalent modulo color. Also swap the "en passant" + square, castling rights and turn. + + Alternatively, :func:`~chess.Board.apply_mirror()` can be used + to mirror the board. + """ + board = self.copy() + board.apply_mirror() return board - def copy(self, *, stack=True): + def copy(self, *, stack: Union[bool, int] = True) -> Self: + """ + Creates a copy of the board. + + Defaults to copying the entire move stack. Alternatively, *stack* can + be ``False``, or an integer to copy a limited number of moves. + """ board = super().copy() board.chess960 = self.chess960 @@ -3298,18 +3974,19 @@ def copy(self, *, stack=True): board.halfmove_clock = self.halfmove_clock if stack: - board.move_stack = copy.deepcopy(self.move_stack) - board.stack = copy.copy(self.stack) + stack = len(self.move_stack) if stack is True else stack + board.move_stack = [copy.copy(move) for move in self.move_stack[-stack:]] + board._stack = self._stack[-stack:] return board @classmethod - def empty(cls, *, chess960=False): + def empty(cls: Type[BoardT], *, chess960: bool = False) -> BoardT: """Creates a new empty board. Also see :func:`~chess.Board.clear()`.""" return cls(None, chess960=chess960) @classmethod - def from_epd(cls, epd, *, chess960=False): + def from_epd(cls: Type[BoardT], epd: str, *, chess960: bool = False) -> Tuple[BoardT, Dict[str, Union[None, str, int, float, Move, List[Move]]]]: """ Creates a new board from an EPD string. See :func:`~chess.Board.set_epd()`. @@ -3320,32 +3997,32 @@ def from_epd(cls, epd, *, chess960=False): return board, board.set_epd(epd) @classmethod - def from_chess960_pos(cls, sharnagl): + def from_chess960_pos(cls: Type[BoardT], scharnagl: int) -> BoardT: board = cls.empty(chess960=True) - board.set_chess960_pos(sharnagl) + board.set_chess960_pos(scharnagl) return board class PseudoLegalMoveGenerator: - def __init__(self, board): + def __init__(self, board: Board) -> None: self.board = board - def __bool__(self): + def __bool__(self) -> bool: return any(self.board.generate_pseudo_legal_moves()) - def count(self): + def count(self) -> int: # List conversion is faster than iterating. return len(list(self)) - def __iter__(self): + def __iter__(self) -> Iterator[Move]: return self.board.generate_pseudo_legal_moves() - def __contains__(self, move): + def __contains__(self, move: Move) -> bool: return self.board.is_pseudo_legal(move) - def __repr__(self): - builder = [] + def __repr__(self) -> str: + builder: List[str] = [] for move in self: if self.board.is_legal(move): @@ -3354,42 +4031,47 @@ def __repr__(self): builder.append(self.board.uci(move)) sans = ", ".join(builder) - - return "".format(hex(id(self)), sans) + return f"" class LegalMoveGenerator: - def __init__(self, board): + def __init__(self, board: Board) -> None: self.board = board - def __bool__(self): + def __bool__(self) -> bool: return any(self.board.generate_legal_moves()) - def count(self): + def count(self) -> int: # List conversion is faster than iterating. return len(list(self)) - def __iter__(self): + def __iter__(self) -> Iterator[Move]: return self.board.generate_legal_moves() - def __contains__(self, move): + def __contains__(self, move: Move) -> bool: return self.board.is_legal(move) - def __repr__(self): + def __repr__(self) -> str: sans = ", ".join(self.board.san(move) for move in self) - return "".format(hex(id(self)), sans) + return f"" -class SquareSet(collections.abc.MutableSet): +IntoSquareSet: TypeAlias = Union[SupportsInt, Iterable[Square]] + +class SquareSet: """ A set of squares. >>> import chess >>> + >>> squares = chess.SquareSet([chess.A8, chess.A1]) + >>> squares + SquareSet(0x0100_0000_0000_0001) + >>> squares = chess.SquareSet(chess.BB_A8 | chess.BB_RANK_1) >>> squares - SquareSet(0x01000000000000ff) + SquareSet(0x0100_0000_0000_00ff) >>> print(squares) 1 . . . . . . . @@ -3455,129 +4137,128 @@ class SquareSet(collections.abc.MutableSet): :func:`~chess.SquareSet.clear()`. """ - def __init__(self, squares=BB_EMPTY): - + def __init__(self, squares: IntoSquareSet = BB_EMPTY) -> None: try: - self.mask = squares.__int__() & BB_ALL + self.mask: Bitboard = squares.__int__() & BB_ALL # type: ignore return except AttributeError: self.mask = 0 # Try squares as an iterable. Not under except clause for nicer # backtraces. - for square in squares: + for square in squares: # type: ignore self.add(square) # Set - def __contains__(self, square): + def __contains__(self, square: Square) -> bool: return bool(BB_SQUARES[square] & self.mask) - def __iter__(self): + def __iter__(self) -> Iterator[Square]: return scan_forward(self.mask) - def __reversed__(self): + def __reversed__(self) -> Iterator[Square]: return scan_reversed(self.mask) - def __len__(self): + def __len__(self) -> int: return popcount(self.mask) # MutableSet - def add(self, square): + def add(self, square: Square) -> None: """Adds a square to the set.""" self.mask |= BB_SQUARES[square] - def discard(self, square): + def discard(self, square: Square) -> None: """Discards a square from the set.""" self.mask &= ~BB_SQUARES[square] # frozenset - def isdisjoint(self, other): - """Test if the square sets are disjoint.""" + def isdisjoint(self, other: IntoSquareSet) -> bool: + """Tests if the square sets are disjoint.""" return not bool(self & other) - def issubset(self, other): - """Test if this square set is a subset of another.""" - return not bool(~self & other) + def issubset(self, other: IntoSquareSet) -> bool: + """Tests if this square set is a subset of another.""" + return not bool(self & ~SquareSet(other)) - def issuperset(self, other): - """Test if this square set is a superset of another.""" - return not bool(self & ~other) + def issuperset(self, other: IntoSquareSet) -> bool: + """Tests if this square set is a superset of another.""" + return not bool(~self & other) - def union(self, other): + def union(self, other: IntoSquareSet) -> SquareSet: return self | other - def __or__(self, other): + def __or__(self, other: IntoSquareSet) -> SquareSet: r = SquareSet(other) r.mask |= self.mask return r - def intersection(self, other): + def intersection(self, other: IntoSquareSet) -> SquareSet: return self & other - def __and__(self, other): + def __and__(self, other: IntoSquareSet) -> SquareSet: r = SquareSet(other) r.mask &= self.mask return r - def difference(self, other): + def difference(self, other: IntoSquareSet) -> SquareSet: return self - other - def __sub__(self, other): + def __sub__(self, other: IntoSquareSet) -> SquareSet: r = SquareSet(other) r.mask = self.mask & ~r.mask return r - def symmetric_difference(self, other): + def symmetric_difference(self, other: IntoSquareSet) -> SquareSet: return self ^ other - def __xor__(self, other): + def __xor__(self, other: IntoSquareSet) -> SquareSet: r = SquareSet(other) r.mask ^= self.mask return r - def copy(self): + def copy(self) -> SquareSet: return SquareSet(self.mask) # set - def update(self, *others): + def update(self, *others: IntoSquareSet) -> None: for other in others: self |= other - def __ior__(self, other): + def __ior__(self, other: IntoSquareSet) -> SquareSet: self.mask |= SquareSet(other).mask return self - def intersection_update(self, *others): + def intersection_update(self, *others: IntoSquareSet) -> None: for other in others: self &= other - def __iand__(self, other): + def __iand__(self, other: IntoSquareSet) -> SquareSet: self.mask &= SquareSet(other).mask return self - def difference_update(self, other): + def difference_update(self, other: IntoSquareSet) -> None: self -= other - def __isub__(self, other): + def __isub__(self, other: IntoSquareSet) -> SquareSet: self.mask &= ~SquareSet(other).mask return self - def symmetric_difference_update(self, other): + def symmetric_difference_update(self, other: IntoSquareSet) -> None: self ^= other - def __ixor__(self, other): + def __ixor__(self, other: IntoSquareSet) -> SquareSet: self.mask ^= SquareSet(other).mask return self - def remove(self, square): + def remove(self, square: Square) -> None: """ Removes a square from the set. - :raises: :exc:`KeyError` if the given square was not in the set. + :raises: :exc:`KeyError` if the given *square* was not in the set. """ mask = BB_SQUARES[square] if self.mask & mask: @@ -3585,11 +4266,11 @@ def remove(self, square): else: raise KeyError(square) - def pop(self): + def pop(self) -> Square: """ - Removes a square from the set and returns it. + Removes and returns a square from the set. - :raises: :exc:`KeyError` on an empty set. + :raises: :exc:`KeyError` if the set is empty. """ if not self.mask: raise KeyError("pop from empty SquareSet") @@ -3598,91 +4279,122 @@ def pop(self): self.mask &= (self.mask - 1) return square - def clear(self): - """Remove all elements from this set.""" + def clear(self) -> None: + """Removes all elements from this set.""" self.mask = BB_EMPTY # SquareSet - def carry_rippler(self): + def carry_rippler(self) -> Iterator[Bitboard]: """Iterator over the subsets of this set.""" return _carry_rippler(self.mask) - def mirror(self): + def mirror(self) -> SquareSet: """Returns a vertically mirrored copy of this square set.""" return SquareSet(flip_vertical(self.mask)) - def tolist(self): - """Convert the set to a list of 64 bools.""" - l = [False] * 64 + def tolist(self) -> List[bool]: + """Converts the set to a list of 64 bools.""" + result = [False] * 64 for square in self: - l[square] = True - return l + result[square] = True + return result - def __bool__(self): + def __bool__(self) -> bool: return bool(self.mask) - def __eq__(self, other): - ne = self.__ne__(other) - return NotImplemented if ne is NotImplemented else not ne - - def __ne__(self, other): + def __eq__(self, other: object) -> bool: try: - return self.mask != SquareSet(other).mask + return self.mask == SquareSet(other).mask # type: ignore except (TypeError, ValueError): return NotImplemented - def __lshift__(self, shift): + def __lshift__(self, shift: int) -> SquareSet: return SquareSet((self.mask << shift) & BB_ALL) - def __rshift__(self, shift): + def __rshift__(self, shift: int) -> SquareSet: return SquareSet(self.mask >> shift) - def __ilshift__(self, shift): + def __ilshift__(self, shift: int) -> SquareSet: self.mask = (self.mask << shift) & BB_ALL return self - def __irshift__(self, shift): + def __irshift__(self, shift: int) -> SquareSet: self.mask >>= shift return self - def __invert__(self): + def __invert__(self) -> SquareSet: return SquareSet(~self.mask & BB_ALL) - def __int__(self): + def __int__(self) -> int: return self.mask - def __index__(self): + def __index__(self) -> int: return self.mask - def __repr__(self): - return "SquareSet({0:#018x})".format(self.mask) + def __repr__(self) -> str: + return f"SquareSet({self.mask:#021_x})" - def __str__(self): - builder = [] + def __str__(self) -> str: + builder: List[str] = [] for square in SQUARES_180: mask = BB_SQUARES[square] + builder.append("1" if self.mask & mask else ".") - if self.mask & mask: - builder.append("1") - else: - builder.append(".") - - if mask & BB_FILE_H: - if square != H1: - builder.append("\n") - else: + if not mask & BB_FILE_H: builder.append(" ") + elif square != H1: + builder.append("\n") return "".join(builder) - def _repr_svg_(self): + def _repr_svg_(self) -> str: import chess.svg - return chess.svg.board(squares=self, size=400) + return chess.svg.board(squares=self, size=390) + + @classmethod + def ray(cls, a: Square, b: Square) -> SquareSet: + """ + All squares on the rank, file or diagonal with the two squares, if they + are aligned. + + >>> import chess + >>> + >>> print(chess.SquareSet.ray(chess.E2, chess.B5)) + . . . . . . . . + . . . . . . . . + 1 . . . . . . . + . 1 . . . . . . + . . 1 . . . . . + . . . 1 . . . . + . . . . 1 . . . + . . . . . 1 . . + """ + return cls(ray(a, b)) + + @classmethod + def between(cls, a: Square, b: Square) -> SquareSet: + """ + All squares on the rank, file or diagonal between the two squares + (bounds not included), if they are aligned. + + >>> import chess + >>> + >>> print(chess.SquareSet.between(chess.E2, chess.B5)) + . . . . . . . . + . . . . . . . . + . . . . . . . . + . . . . . . . . + . . 1 . . . . . + . . . 1 . . . . + . . . . . . . . + . . . . . . . . + """ + return cls(between(a, b)) @classmethod - def from_square(cls, square): + def from_square(cls, square: Square) -> SquareSet: """ Creates a :class:`~chess.SquareSet` from a single square. @@ -3692,8 +4404,3 @@ def from_square(cls, square): True """ return cls(BB_SQUARES[square]) - - -# TODO: Deprecated -BB_VOID = 0 -bswap = flip_vertical diff --git a/chess/engine.py b/chess/engine.py index 12ad4a93c..c66bc0c45 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1,308 +1,3114 @@ -# -*- coding: utf-8 -*- -# -# This file is part of the python-chess library. -# Copyright (C) 2012-2018 Niklas Fiekas -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . +from __future__ import annotations +import abc +import asyncio import collections -import collections.abc +import concurrent.futures +import contextlib +import copy +import dataclasses +import enum +import inspect import logging -import os -import platform -import queue -import signal +import math +import shlex import subprocess +import sys import threading +import time +import typing +import re +import chess + +from chess import Color +from types import TracebackType +from typing import Any, Callable, Coroutine, Deque, Dict, Generator, Generic, Iterable, Iterator, List, Literal, Mapping, MutableMapping, Optional, Tuple, Type, TypedDict, TypeVar, Union + +if typing.TYPE_CHECKING: + from typing_extensions import override +else: + F = typing.TypeVar("F", bound=Callable[..., Any]) + def override(fn: F, /) -> F: + return fn + +if typing.TYPE_CHECKING: + from typing_extensions import Self + +WdlModel = Literal["sf", "sf16.1", "sf16", "sf15.1", "sf15", "sf14", "sf12", "lichess"] + + +T = TypeVar("T") +ProtocolT = TypeVar("ProtocolT", bound="Protocol") + +ConfigValue = Union[str, int, bool, None] +ConfigMapping = Mapping[str, ConfigValue] -FUTURE_POLL_TIMEOUT = 0.1 if platform.system() == "Windows" else 60 LOGGER = logging.getLogger(__name__) -class EngineTerminatedException(Exception): - """The engine has been terminated.""" - pass +MANAGED_OPTIONS = ["uci_chess960", "uci_variant", "multipv", "ponder"] -class EngineStateException(Exception): - """Unexpected engine state.""" - pass +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. + Blocks on *future* and returns the result as soon as it is resolved. + The coroutine and all remaining tasks continue running in the background + until complete. + """ + assert inspect.iscoroutinefunction(coroutine) -class Option(collections.namedtuple("Option", "name type default min max var")): - """Information about an available option for an UCI engine.""" + future: concurrent.futures.Future[T] = concurrent.futures.Future() - __slots__ = () + def background() -> None: + try: + asyncio.run(coroutine(future), debug=debug) + future.cancel() + except Exception as exc: + future.set_exception(exc) + threading.Thread(target=background, name=name).start() + return future.result() -class MockProcess: - def __init__(self, engine): - self.engine = engine - self._expectations = collections.deque() - self._is_dead = threading.Event() - self._std_streams_closed = False - self.engine.on_process_spawned(self) +class EngineError(RuntimeError): + """Runtime error caused by a misbehaving engine or incorrect usage.""" - self._send_queue = queue.Queue() - self._send_thread = threading.Thread(target=self._send_thread_target) - self._send_thread.daemon = True - self._send_thread.start() - def _send_thread_target(self): - while not self._is_dead.is_set(): - line = self._send_queue.get() - if line is not None: - self.engine.on_line_received(line) - self._send_queue.task_done() +class EngineTerminatedError(EngineError): + """The engine process exited unexpectedly.""" - def expect(self, expectation, responses=()): - self._expectations.append((expectation, responses)) - def assert_done(self): - assert not self._expectations, "pending expectations: {}".format(self._expectations) +class AnalysisComplete(Exception): + """ + Raised when analysis is complete, all information has been consumed, but + further information was requested. + """ - def assert_terminated(self): - self.assert_done() - assert self._is_dead.is_set() - def is_alive(self): - return not self._is_dead.is_set() +@dataclasses.dataclass(frozen=True) +class Option: + """Information about an available engine option.""" - def terminate(self): - self._is_dead.set() - self._send_queue.put(None) - self.engine.on_terminated() + name: str + """The name of the option.""" - def kill(self): - self._is_dead.set() - self._send_queue.put(None) - self.engine.on_terminated() + type: str + """ + The type of the option. + + +--------+-----+------+------------------------------------------------+ + | type | UCI | CECP | value | + +========+=====+======+================================================+ + | check | X | X | ``True`` or ``False`` | + +--------+-----+------+------------------------------------------------+ + | spin | X | X | integer, between *min* and *max* | + +--------+-----+------+------------------------------------------------+ + | combo | X | X | string, one of *var* | + +--------+-----+------+------------------------------------------------+ + | button | X | X | ``None`` | + +--------+-----+------+------------------------------------------------+ + | reset | | X | ``None`` | + +--------+-----+------+------------------------------------------------+ + | save | | X | ``None`` | + +--------+-----+------+------------------------------------------------+ + | string | X | X | string without line breaks | + +--------+-----+------+------------------------------------------------+ + | file | | X | string, interpreted as the path to a file | + +--------+-----+------+------------------------------------------------+ + | path | | X | string, interpreted as the path to a directory | + +--------+-----+------+------------------------------------------------+ + """ - def send_line(self, string): - assert self.is_alive() + default: ConfigValue + """The default value of the option.""" + + min: Optional[int] + """The minimum integer value of a *spin* option.""" + + max: Optional[int] + """The maximum integer value of a *spin* option.""" + + var: Optional[List[str]] + """A list of allowed string values for a *combo* option.""" + + def parse(self, value: ConfigValue) -> ConfigValue: + if self.type == "check": + return value and value != "false" + elif self.type == "spin": + try: + value = int(value) # type: ignore + except ValueError: + raise EngineError(f"expected integer for spin option {self.name!r}, got: {value!r}") + if self.min is not None and value < self.min: + raise EngineError(f"expected value for option {self.name!r} to be at least {self.min}, got: {value}") + if self.max is not None and self.max < value: + raise EngineError(f"expected value for option {self.name!r} to be at most {self.max}, got: {value}") + return value + elif self.type == "combo": + value = str(value) + if value not in (self.var or []): + raise EngineError("invalid value for combo option {!r}, got: {} (available: {})".format(self.name, value, ", ".join(self.var) if self.var else "-")) + return value + elif self.type in ["button", "reset", "save"]: + return None + elif self.type in ["string", "file", "path"]: + value = str(value) + if "\n" in value or "\r" in value: + raise EngineError(f"invalid line-break in string option {self.name!r}: {value!r}") + return value + else: + raise EngineError(f"unknown option type: {self.type!r}") - assert self._expectations, "unexpected: {}".format(string) - expectation, responses = self._expectations.popleft() - assert expectation == string, "expected: {}, got {}".format(expectation, string) + def is_managed(self) -> bool: + """ + Some options are managed automatically: ``UCI_Chess960``, + ``UCI_Variant``, ``MultiPV``, ``Ponder``. + """ + return self.name.lower() in MANAGED_OPTIONS - for response in responses: - self._send_queue.put(response) - def wait_for_return_code(self): - self._is_dead.wait() - return 0 +@dataclasses.dataclass +class Limit: + """Search-termination condition.""" + + time: Optional[float] = None + """Search exactly *time* seconds.""" + + depth: Optional[int] = None + """Search *depth* ply only.""" + + nodes: Optional[int] = None + """Search only a limited number of *nodes*.""" + + mate: Optional[int] = None + """Search for a mate in *mate* moves.""" + + white_clock: Optional[float] = None + """Time in seconds remaining for White.""" + + black_clock: Optional[float] = None + """Time in seconds remaining for Black.""" + + white_inc: Optional[float] = None + """Fisher increment for White, in seconds.""" + + black_inc: Optional[float] = None + """Fisher increment for Black, in seconds.""" + + remaining_moves: Optional[int] = None + """ + Number of moves to the next time control. If this is not set, but + *white_clock* and *black_clock* are, then it is sudden death. + """ + + clock_id: object = None + """ + An identifier to use with XBoard engines to signal that the time + control has changed. When this field changes, Xboard engines are + sent level or st commands as appropriate. Otherwise, only time + and otim commands are sent to update the engine's clock. + """ + + def __repr__(self) -> str: + # Like default __repr__, but without None values. + return "{}({})".format( + type(self).__name__, + ", ".join("{}={!r}".format(attr, getattr(self, attr)) + for attr in ["time", "depth", "nodes", "mate", "white_clock", "black_clock", "white_inc", "black_inc", "remaining_moves"] + if getattr(self, attr) is not None)) + + +class InfoDict(TypedDict, total=False): + """ + Dictionary of aggregated information sent by the engine. + + Commonly used keys are: ``score`` (a :class:`~chess.engine.PovScore`), + ``pv`` (a list of :class:`~chess.Move` objects), ``depth``, + ``seldepth``, ``time`` (in seconds), ``nodes``, ``nps``, ``multipv`` + (``1`` for the mainline). + + Others: ``tbhits``, ``currmove``, ``currmovenumber``, ``hashfull``, + ``cpuload``, ``refutation``, ``currline``, ``ebf`` (effective branching factor), + ``wdl`` (a :class:`~chess.engine.PovWdl`), and ``string``. + """ + score: PovScore + pv: List[chess.Move] + depth: int + seldepth: int + time: float + nodes: int + nps: int + tbhits: int + multipv: int + currmove: chess.Move + currmovenumber: int + hashfull: int + cpuload: int + refutation: Dict[chess.Move, List[chess.Move]] + currline: Dict[int, List[chess.Move]] + ebf: float + wdl: PovWdl + string: str + + +class PlayResult: + """Returned by :func:`chess.engine.Protocol.play()`.""" + + move: Optional[chess.Move] + """The best move according to the engine, or ``None``.""" + + ponder: Optional[chess.Move] + """The response that the engine expects after *move*, or ``None``.""" + + info: InfoDict + """ + A dictionary of extra :class:`information ` + sent by the engine, if selected with the *info* argument of + :func:`~chess.engine.Protocol.play()`. + """ + + draw_offered: bool + """Whether the engine offered a draw before moving.""" + + resigned: bool + """Whether the engine resigned.""" + + def __init__(self, + move: Optional[chess.Move], + ponder: Optional[chess.Move], + info: Optional[InfoDict] = None, + *, + draw_offered: bool = False, + resigned: bool = False) -> None: + self.move = move + self.ponder = ponder + self.info = info or {} + self.draw_offered = draw_offered + self.resigned = resigned + + def __repr__(self) -> str: + return "<{} at {:#x} (move={}, ponder={}, info={}, draw_offered={}, resigned={})>".format( + type(self).__name__, id(self), self.move, self.ponder, self.info, + self.draw_offered, self.resigned) + + +class Info(enum.IntFlag): + """Used to filter information sent by the chess engine.""" + NONE = 0 + BASIC = 1 + SCORE = 2 + PV = 4 + REFUTATION = 8 + CURRLINE = 16 + ALL = BASIC | SCORE | PV | REFUTATION | CURRLINE + +INFO_NONE = Info.NONE +INFO_BASIC = Info.BASIC +INFO_SCORE = Info.SCORE +INFO_PV = Info.PV +INFO_REFUTATION = Info.REFUTATION +INFO_CURRLINE = Info.CURRLINE +INFO_ALL = Info.ALL + + +@dataclasses.dataclass +class Opponent: + """Used to store information about an engine's opponent.""" + + name: Optional[str] + """The name of the opponent.""" + + title: Optional[str] + """The opponent's title--for example, GM, IM, or BOT.""" + + rating: Optional[int] + """The opponent's ELO rating.""" + + is_engine: Optional[bool] + """Whether the opponent is a chess engine/computer program.""" + + +class PovScore: + """A relative :class:`~chess.engine.Score` and the point of view.""" + + relative: Score + """The relative :class:`~chess.engine.Score`.""" + + turn: Color + """The point of view (``chess.WHITE`` or ``chess.BLACK``).""" + + def __init__(self, relative: Score, turn: Color) -> None: + self.relative = relative + self.turn = turn + + def white(self) -> Score: + """Gets the score from White's point of view.""" + return self.pov(chess.WHITE) - def pid(self): + def black(self) -> Score: + """Gets the score from Black's point of view.""" + return self.pov(chess.BLACK) + + def pov(self, color: Color) -> Score: + """Gets the score from the point of view of the given *color*.""" + return self.relative if self.turn == color else -self.relative + + def is_mate(self) -> bool: + """Tests if this is a mate score.""" + return self.relative.is_mate() + + def wdl(self, *, model: WdlModel = "sf", ply: int = 30) -> PovWdl: + """See :func:`~chess.engine.Score.wdl()`.""" + return PovWdl(self.relative.wdl(model=model, ply=ply), self.turn) + + def __repr__(self) -> str: + return "PovScore({!r}, {})".format(self.relative, "WHITE" if self.turn else "BLACK") + + def __eq__(self, other: object) -> bool: + if isinstance(other, PovScore): + return self.white() == other.white() + else: + return NotImplemented + + +class Score(abc.ABC): + """ + Evaluation of a position. + + The score can be :class:`~chess.engine.Cp` (centi-pawns), + :class:`~chess.engine.Mate` or :py:data:`~chess.engine.MateGiven`. + A positive value indicates an advantage. + + There is a total order defined on centi-pawn and mate scores. + + >>> from chess.engine import Cp, Mate, MateGiven + >>> + >>> Mate(-0) < Mate(-1) < Cp(-50) < Cp(200) < Mate(4) < Mate(1) < MateGiven + True + + Scores can be negated to change the point of view: + + >>> -Cp(20) + Cp(-20) + + >>> -Mate(-4) + Mate(+4) + + >>> -Mate(0) + MateGiven + """ + + @typing.overload + @abc.abstractmethod + def score(self, *, mate_score: int) -> int: ... + @typing.overload + @abc.abstractmethod + def score(self, *, mate_score: Optional[int] = None) -> Optional[int]: ... + @abc.abstractmethod + def score(self, *, mate_score: Optional[int] = None) -> Optional[int]: + """ + Returns the centi-pawn score as an integer or ``None``. + + You can optionally pass a large value to convert mate scores to + centi-pawn scores. + + >>> Cp(-300).score() + -300 + >>> Mate(5).score() is None + True + >>> Mate(5).score(mate_score=100000) + 99995 + """ + + @abc.abstractmethod + def mate(self) -> Optional[int]: + """ + Returns the number of plies to mate, negative if we are getting + mated, or ``None``. + + .. warning:: + This conflates ``Mate(0)`` (we lost) and ``MateGiven`` + (we won) to ``0``. + """ + + def is_mate(self) -> bool: + """Tests if this is a mate score.""" + return self.mate() is not None + + @abc.abstractmethod + def wdl(self, *, model: WdlModel = "sf", ply: int = 30) -> Wdl: + """ + Returns statistics for the expected outcome of this game, based on + a *model*, given that this score is reached at *ply*. + + Scores have a total order, but it makes little sense to compute + the difference between two scores. For example, going from + ``Cp(-100)`` to ``Cp(+100)`` is much more significant than going + from ``Cp(+300)`` to ``Cp(+500)``. It is better to compute differences + of the expectation values for the outcome of the game (based on winning + chances and drawing chances). + + >>> Cp(100).wdl().expectation() - Cp(-100).wdl().expectation() # doctest: +ELLIPSIS + 0.379... + + >>> Cp(500).wdl().expectation() - Cp(300).wdl().expectation() # doctest: +ELLIPSIS + 0.015... + + :param model: + * ``sf``, the WDL model used by the latest Stockfish + (currently ``sf16``). + * ``sf16``, the WDL model used by Stockfish 16. + * ``sf15.1``, the WDL model used by Stockfish 15.1. + * ``sf15``, the WDL model used by Stockfish 15. + * ``sf14``, the WDL model used by Stockfish 14. + * ``sf12``, the WDL model used by Stockfish 12. + * ``lichess``, the win rate model used by Lichess. + Does not use *ply*, and does not consider drawing chances. + :param ply: The number of half-moves played since the starting + position. Models may scale scores slightly differently based on + this. Defaults to middle game. + """ + + @abc.abstractmethod + def __neg__(self) -> Score: + ... + + @abc.abstractmethod + def __pos__(self) -> Score: + ... + + @abc.abstractmethod + def __abs__(self) -> Score: + ... + + def _score_tuple(self) -> Tuple[bool, bool, bool, int, Optional[int]]: + mate = self.mate() + return ( + isinstance(self, MateGivenType), + mate is not None and mate > 0, + mate is None, + -(mate or 0), + self.score(), + ) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Score): + return self._score_tuple() == other._score_tuple() + else: + return NotImplemented + + def __lt__(self, other: object) -> bool: + if isinstance(other, Score): + return self._score_tuple() < other._score_tuple() + else: + return NotImplemented + + def __le__(self, other: object) -> bool: + if isinstance(other, Score): + return self._score_tuple() <= other._score_tuple() + else: + return NotImplemented + + def __gt__(self, other: object) -> bool: + if isinstance(other, Score): + return self._score_tuple() > other._score_tuple() + else: + return NotImplemented + + def __ge__(self, other: object) -> bool: + if isinstance(other, Score): + return self._score_tuple() >= other._score_tuple() + else: + return NotImplemented + +def _sf16_1_wins(cp: int, *, ply: int) -> int: + # https://github.com/official-stockfish/Stockfish/blob/sf_16.1/src/uci.cpp#L48 + NormalizeToPawnValue = 356 + # https://github.com/official-stockfish/Stockfish/blob/sf_16.1/src/uci.cpp#L383-L384 + m = min(120, max(8, ply / 2 + 1)) / 32 + a = (((-1.06249702 * m + 7.42016937) * m + 0.89425629) * m) + 348.60356174 + b = (((-5.33122190 * m + 39.57831533) * m + -90.84473771) * m) + 123.40620748 + x = min(4000, max(cp * NormalizeToPawnValue / 100, -4000)) + return int(0.5 + 1000 / (1 + math.exp((a - x) / b))) + +def _sf16_wins(cp: int, *, ply: int) -> int: + # https://github.com/official-stockfish/Stockfish/blob/sf_16/src/uci.h#L38 + NormalizeToPawnValue = 328 + # https://github.com/official-stockfish/Stockfish/blob/sf_16/src/uci.cpp#L200-L224 + m = min(240, max(ply, 0)) / 64 + a = (((0.38036525 * m + -2.82015070) * m + 23.17882135) * m) + 307.36768407 + b = (((-2.29434733 * m + 13.27689788) * m + -14.26828904) * m) + 63.45318330 + x = min(4000, max(cp * NormalizeToPawnValue / 100, -4000)) + return int(0.5 + 1000 / (1 + math.exp((a - x) / b))) + +def _sf15_1_wins(cp: int, *, ply: int) -> int: + # https://github.com/official-stockfish/Stockfish/blob/sf_15.1/src/uci.h#L38 + NormalizeToPawnValue = 361 + # https://github.com/official-stockfish/Stockfish/blob/sf_15.1/src/uci.cpp#L200-L224 + m = min(240, max(ply, 0)) / 64 + a = (((-0.58270499 * m + 2.68512549) * m + 15.24638015) * m) + 344.49745382 + b = (((-2.65734562 * m + 15.96509799) * m + -20.69040836) * m) + 73.61029937 + x = min(4000, max(cp * NormalizeToPawnValue / 100, -4000)) + return int(0.5 + 1000 / (1 + math.exp((a - x) / b))) + +def _sf15_wins(cp: int, *, ply: int) -> int: + # https://github.com/official-stockfish/Stockfish/blob/sf_15/src/uci.cpp#L200-L220 + m = min(240, max(ply, 0)) / 64 + a = (((-1.17202460e-1 * m + 5.94729104e-1) * m + 1.12065546e+1) * m) + 1.22606222e+2 + b = (((-1.79066759 * m + 11.30759193) * m + -17.43677612) * m) + 36.47147479 + x = min(2000, max(cp, -2000)) + return int(0.5 + 1000 / (1 + math.exp((a - x) / b))) + +def _sf14_wins(cp: int, *, ply: int) -> int: + # https://github.com/official-stockfish/Stockfish/blob/sf_14/src/uci.cpp#L200-L220 + m = min(240, max(ply, 0)) / 64 + a = (((-3.68389304 * m + 30.07065921) * m + -60.52878723) * m) + 149.53378557 + b = (((-2.01818570 * m + 15.85685038) * m + -29.83452023) * m) + 47.59078827 + x = min(2000, max(cp, -2000)) + return int(0.5 + 1000 / (1 + math.exp((a - x) / b))) + +def _sf12_wins(cp: int, *, ply: int) -> int: + # https://github.com/official-stockfish/Stockfish/blob/sf_12/src/uci.cpp#L198-L218 + m = min(240, max(ply, 0)) / 64 + a = (((-8.24404295 * m + 64.23892342) * m + -95.73056462) * m) + 153.86478679 + b = (((-3.37154371 * m + 28.44489198) * m + -56.67657741) * m) + 72.05858751 + x = min(1000, max(cp, -1000)) + return int(0.5 + 1000 / (1 + math.exp((a - x) / b))) + +def _lichess_raw_wins(cp: int) -> int: + # https://github.com/lichess-org/lila/pull/11148 + # https://github.com/lichess-org/lila/blob/2242b0a08faa06e7be5508d338ede7bb09049777/modules/analyse/src/main/WinPercent.scala#L26-L30 + return round(1000 / (1 + math.exp(-0.00368208 * cp))) + + +class Cp(Score): + """Centi-pawn score.""" + + def __init__(self, cp: int) -> None: + self.cp = cp + + def mate(self) -> None: return None - def __repr__(self): - return "".format(hex(id(self))) + def score(self, *, mate_score: Optional[int] = None) -> int: + return self.cp + + def wdl(self, *, model: WdlModel = "sf", ply: int = 30) -> Wdl: + if model == "lichess": + wins = _lichess_raw_wins(max(-1000, min(self.cp, 1000))) + losses = 1000 - wins + elif model == "sf12": + wins = _sf12_wins(self.cp, ply=ply) + losses = _sf12_wins(-self.cp, ply=ply) + elif model == "sf14": + wins = _sf14_wins(self.cp, ply=ply) + losses = _sf14_wins(-self.cp, ply=ply) + elif model == "sf15": + wins = _sf15_wins(self.cp, ply=ply) + losses = _sf15_wins(-self.cp, ply=ply) + elif model == "sf15.1": + wins = _sf15_1_wins(self.cp, ply=ply) + losses = _sf15_1_wins(-self.cp, ply=ply) + elif model == "sf16": + wins = _sf16_wins(self.cp, ply=ply) + losses = _sf16_wins(-self.cp, ply=ply) + else: + wins = _sf16_1_wins(self.cp, ply=ply) + losses = _sf16_1_wins(-self.cp, ply=ply) + draws = 1000 - wins - losses + return Wdl(wins, draws, losses) + def __str__(self) -> str: + return f"+{self.cp:d}" if self.cp > 0 else str(self.cp) -class PopenProcess: - def __init__(self, engine, command, **kwargs): - self.engine = engine + def __repr__(self) -> str: + return f"Cp({self})" - self._receiving_thread = threading.Thread(target=self._receiving_thread_target) - self._receiving_thread.daemon = True - self._stdin_lock = threading.Lock() + def __neg__(self) -> Cp: + return Cp(-self.cp) - self.engine.on_process_spawned(self) + def __pos__(self) -> Cp: + return Cp(self.cp) - popen_args = { - "stdout": subprocess.PIPE, - "stdin": subprocess.PIPE, - "bufsize": 1, # Line buffering - "universal_newlines": True, - } - popen_args.update(kwargs) - self.process = subprocess.Popen(command, **popen_args) + def __abs__(self) -> Cp: + return Cp(abs(self.cp)) - self._receiving_thread.start() - def _receiving_thread_target(self): - for line in iter(self.process.stdout.readline, ""): - self.engine.on_line_received(line.rstrip()) +class Mate(Score): + """Mate score.""" - # Close file descriptors. - self.process.stdout.close() - with self._stdin_lock: - self.process.stdin.close() + def __init__(self, moves: int) -> None: + self.moves = moves - # Ensure the process is terminated (not just the in/out streams). - if self.is_alive(): - self.terminate() - self.wait_for_return_code() + def mate(self) -> int: + return self.moves - self.engine.on_terminated() + @typing.overload + def score(self, *, mate_score: int) -> int: ... + @typing.overload + def score(self, *, mate_score: Optional[int] = None) -> Optional[int]: ... + def score(self, *, mate_score: Optional[int] = None) -> Optional[int]: + if mate_score is None: + return None + elif self.moves > 0: + return mate_score - self.moves + else: + return -mate_score - self.moves - def is_alive(self): - return self.process.poll() is None + def wdl(self, *, model: WdlModel = "sf", ply: int = 30) -> Wdl: + if model == "lichess": + cp = (21 - min(10, abs(self.moves))) * 100 + wins = _lichess_raw_wins(cp) + return Wdl(wins, 0, 1000 - wins) if self.moves > 0 else Wdl(1000 - wins, 0, wins) + else: + return Wdl(1000, 0, 0) if self.moves > 0 else Wdl(0, 0, 1000) - def terminate(self): - self.process.terminate() + def __str__(self) -> str: + return f"#+{self.moves}" if self.moves > 0 else f"#-{abs(self.moves)}" - def kill(self): - self.process.kill() + def __repr__(self) -> str: + return "Mate({})".format(str(self).lstrip("#")) - def send_line(self, string): - with self._stdin_lock: - self.process.stdin.write(string + "\n") - self.process.stdin.flush() + def __neg__(self) -> Union[MateGivenType, Mate]: + return MateGiven if not self.moves else Mate(-self.moves) - def wait_for_return_code(self): - self.process.wait() - return self.process.returncode + def __pos__(self) -> Mate: + return Mate(self.moves) - def pid(self): - return self.process.pid + def __abs__(self) -> Union[MateGivenType, Mate]: + return MateGiven if not self.moves else Mate(abs(self.moves)) - def __repr__(self): - return "".format(hex(id(self)), self.pid()) +class MateGivenType(Score): + """Winning mate score, equivalent to ``-Mate(0)``.""" -class SpurProcess: - def __init__(self, engine, shell, command): - self.engine = engine - self.shell = shell + def mate(self) -> int: + return 0 - self._stdout_buffer = [] + @typing.overload + def score(self, *, mate_score: int) -> int: ... + @typing.overload + def score(self, *, mate_score: Optional[int] = None) -> Optional[int]: ... + def score(self, *, mate_score: Optional[int] = None) -> Optional[int]: + return mate_score - self._result = None + def wdl(self, *, model: WdlModel = "sf", ply: int = 30) -> Wdl: + return Wdl(1000, 0, 0) - self._waiting_thread = threading.Thread(target=self._waiting_thread_target) - self._waiting_thread.daemon = True + def __neg__(self) -> Mate: + return Mate(0) - self.engine.on_process_spawned(self) - self.process = self.shell.spawn(command, store_pid=True, allow_error=True, stdout=self) - self._waiting_thread.start() + def __pos__(self) -> MateGivenType: + return self - def write(self, byte): - # Internally called whenever a byte is received. - if byte == b"\r": - pass - elif byte == b"\n": - self.engine.on_line_received(b"".join(self._stdout_buffer).decode("utf-8")) - del self._stdout_buffer[:] + def __abs__(self) -> MateGivenType: + return self + + def __repr__(self) -> str: + return "MateGiven" + + def __str__(self) -> str: + return "#+0" + +MateGiven = MateGivenType() + + +@dataclasses.dataclass +class PovWdl: + """ + Relative :class:`win/draw/loss statistics ` and the point + of view. + """ + + relative: Wdl + """The relative :class:`~chess.engine.Wdl`.""" + + turn: Color + """The point of view (``chess.WHITE`` or ``chess.BLACK``).""" + + def white(self) -> Wdl: + """Gets the :class:`~chess.engine.Wdl` from White's point of view.""" + return self.pov(chess.WHITE) + + def black(self) -> Wdl: + """Gets the :class:`~chess.engine.Wdl` from Black's point of view.""" + return self.pov(chess.BLACK) + + def pov(self, color: Color) -> Wdl: + """ + Gets the :class:`~chess.engine.Wdl` from the point of view of the given + *color*. + """ + return self.relative if self.turn == color else -self.relative + + def __bool__(self) -> bool: + return bool(self.relative) + + def __repr__(self) -> str: + return "PovWdl({!r}, {})".format(self.relative, "WHITE" if self.turn else "BLACK") + + +@dataclasses.dataclass +class Wdl: + """Win/draw/loss statistics.""" + + wins: int + """The number of wins.""" + + draws: int + """The number of draws.""" + + losses: int + """The number of losses.""" + + def total(self) -> int: + """ + Returns the total number of games. Usually, ``wdl`` reported by engines + is scaled to 1000 games. + """ + return self.wins + self.draws + self.losses + + def winning_chance(self) -> float: + """Returns the relative frequency of wins.""" + return self.wins / self.total() + + def drawing_chance(self) -> float: + """Returns the relative frequency of draws.""" + return self.draws / self.total() + + def losing_chance(self) -> float: + """Returns the relative frequency of losses.""" + return self.losses / self.total() + + def expectation(self) -> float: + """ + Returns the expectation value, where a win is valued 1, a draw is + valued 0.5, and a loss is valued 0. + """ + return (self.wins + 0.5 * self.draws) / self.total() + + def __bool__(self) -> bool: + return bool(self.total()) + + def __pos__(self) -> Wdl: + return self + + def __neg__(self) -> Wdl: + return Wdl(self.losses, self.draws, self.wins) + + +class MockTransport(asyncio.SubprocessTransport, asyncio.WriteTransport): + def __init__(self, protocol: Protocol) -> None: + super().__init__() + self.protocol = protocol + self.expectations: Deque[Tuple[str, List[str]]] = collections.deque() + self.expected_pings = 0 + self.stdin_buffer = bytearray() + self.protocol.connection_made(self) + + def expect(self, expectation: str, responses: List[str] = []) -> None: + self.expectations.append((expectation, responses)) + + def expect_ping(self) -> None: + self.expected_pings += 1 + + def assert_done(self) -> None: + assert not self.expectations, f"pending expectations: {self.expectations}" + + def get_pipe_transport(self, fd: int) -> Optional[asyncio.BaseTransport]: + assert fd == 0, f"expected 0 for stdin, got {fd}" + return self + + def write(self, data: bytes | bytearray | memoryview) -> None: + self.stdin_buffer.extend(data) + while b"\n" in self.stdin_buffer: + line_bytes, self.stdin_buffer = self.stdin_buffer.split(b"\n", 1) + line = line_bytes.decode("utf-8") + + if line.startswith("ping ") and self.expected_pings: + self.expected_pings -= 1 + self.protocol.pipe_data_received(1, (line.replace("ping ", "pong ") + "\n").encode("utf-8")) + else: + assert self.expectations, f"unexpected: {line!r}" + expectation, responses = self.expectations.popleft() + assert expectation == line, f"expected {expectation}, got: {line}" + if responses: + self.protocol.loop.call_soon(self.protocol.pipe_data_received, 1, "\n".join(responses + [""]).encode("utf-8")) + + def get_pid(self) -> int: + return id(self) + + def get_returncode(self) -> Optional[int]: + return None if self.expectations else 0 + + +class Protocol(asyncio.SubprocessProtocol, metaclass=abc.ABCMeta): + """Protocol for communicating with a chess engine process.""" + + id: Dict[str, str] + """ + Dictionary of information about the engine. Common keys are ``name`` + and ``author``. + """ + + returncode: asyncio.Future[int] + """Future: Exit code of the process.""" + + def __init__(self) -> None: + self.loop = asyncio.get_running_loop() + self.transport: Optional[asyncio.SubprocessTransport] = None + + self.buffer = { + 1: bytearray(), # stdout + 2: bytearray(), # stderr + } + + self.command: Optional[BaseCommand[Any]] = None + self.next_command: Optional[BaseCommand[Any]] = None + + self.initialized = False + self.returncode: asyncio.Future[int] = asyncio.Future() + + def connection_made(self, transport: asyncio.BaseTransport) -> None: + # SubprocessTransport expected, but not checked to allow duck typing. + self.transport = transport # type: ignore + LOGGER.debug("%s: Connection made", self) + + def connection_lost(self, exc: Optional[Exception]) -> None: + assert self.transport is not None + code = self.transport.get_returncode() + assert code is not None, "connect lost, but got no returncode" + LOGGER.debug("%s: Connection lost (exit code: %d, error: %s)", self, code, exc) + + # Terminate commands. + command, self.command = self.command, None + next_command, self.next_command = self.next_command, None + if command: + command._engine_terminated(code) + if next_command: + next_command._engine_terminated(code) + + self.returncode.set_result(code) + + def process_exited(self) -> None: + LOGGER.debug("%s: Process exited", self) + + def send_line(self, line: str) -> None: + LOGGER.debug("%s: << %s", self, line) + assert self.transport is not None, "cannot send line before connection is made" + stdin = self.transport.get_pipe_transport(0) + # WriteTransport expected, but not checked to allow duck typing. + stdin.write((line + "\n").encode("utf-8")) # type: ignore + + def pipe_data_received(self, fd: int, data: Union[bytes, str]) -> None: + self.buffer[fd].extend(data) # type: ignore + while b"\n" in self.buffer[fd]: + line_bytes, self.buffer[fd] = self.buffer[fd].split(b"\n", 1) + if line_bytes.endswith(b"\r"): + line_bytes = line_bytes[:-1] + try: + line = line_bytes.decode("utf-8") + except UnicodeDecodeError as err: + LOGGER.warning("%s: >> %r (%s)", self, bytes(line_bytes), err) + else: + if fd == 1: + self._line_received(line) + else: + self.error_line_received(line) + + def error_line_received(self, line: str) -> None: + LOGGER.warning("%s: stderr >> %s", self, line) + + def _line_received(self, line: str) -> None: + LOGGER.debug("%s: >> %s", self, line) + + self.line_received(line) + + if self.command: + self.command._line_received(line) + + def line_received(self, line: str) -> None: + pass + + async def communicate(self, command_factory: Callable[[Self], BaseCommand[T]]) -> T: + command = command_factory(self) + + if self.returncode.done(): + raise EngineTerminatedError(f"engine process dead (exit code: {self.returncode.result()})") + + assert command.state == CommandState.NEW, command.state + + if self.next_command is not None: + self.next_command.result.cancel() + self.next_command.finished.cancel() + self.next_command.set_finished() + + self.next_command = command + + def previous_command_finished() -> None: + self.command, self.next_command = self.next_command, None + if self.command is not None: + cmd = self.command + + def cancel_if_cancelled(result: asyncio.Future[T]) -> None: + if result.cancelled(): + cmd._cancel() + + cmd.result.add_done_callback(cancel_if_cancelled) + cmd._start() + cmd.add_finished_callback(previous_command_finished) + + if self.command is None: + previous_command_finished() + elif not self.command.result.done(): + self.command.result.cancel() + elif not self.command.result.cancelled(): + self.command._cancel() + + return await command.result + + def __repr__(self) -> str: + pid = self.transport.get_pid() if self.transport is not None else "?" + return f"<{type(self).__name__} (pid={pid})>" + + @property + @abc.abstractmethod + def options(self) -> MutableMapping[str, Option]: + """Dictionary of available options.""" + + @abc.abstractmethod + async def initialize(self) -> None: + """Initializes the engine.""" + + @abc.abstractmethod + async def ping(self) -> None: + """ + Pings the engine and waits for a response. Used to ensure the engine + is still alive and idle. + """ + + @abc.abstractmethod + async def configure(self, options: ConfigMapping) -> None: + """ + Configures global engine options. + + :param options: A dictionary of engine options where the keys are + names of :data:`~chess.engine.Protocol.options`. Do not set options + that are managed automatically + (:func:`chess.engine.Option.is_managed()`). + """ + + @abc.abstractmethod + async def send_opponent_information(self, *, opponent: Optional[Opponent] = None, engine_rating: Optional[int] = None) -> None: + """ + Sends the engine information about its opponent. The information will + be sent after a new game is announced and before the first move. This + method should be called before the first move of a game--i.e., the + first call to :func:`chess.engine.Protocol.play()`. + + :param opponent: Optional. An instance of :class:`chess.engine.Opponent` that has the opponent's information. + :param engine_rating: Optional. This engine's own rating. Only used by XBoard engines. + """ + + @abc.abstractmethod + async def play(self, board: chess.Board, limit: Limit, *, game: object = None, info: Info = INFO_NONE, ponder: bool = False, draw_offered: bool = False, root_moves: Optional[Iterable[chess.Move]] = None, options: ConfigMapping = {}, opponent: Optional[Opponent] = None) -> PlayResult: + """ + Plays a position. + + :param board: The position. The entire move stack will be sent to the + engine. + :param limit: An instance of :class:`chess.engine.Limit` that + determines when to stop thinking. + :param game: Optional. An arbitrary object that identifies the game. + Will automatically inform the engine if the object is not equal + to the previous game (e.g., ``ucinewgame``, ``new``). + :param info: Selects which additional information to retrieve from the + engine. ``INFO_NONE``, ``INFO_BASIC`` (basic information that is + trivial to obtain), ``INFO_SCORE``, ``INFO_PV``, + ``INFO_REFUTATION``, ``INFO_CURRLINE``, ``INFO_ALL`` or any + bitwise combination. Some overhead is associated with parsing + extra information. + :param ponder: Whether the engine should keep analysing in the + background even after the result has been returned. + :param draw_offered: Whether the engine's opponent has offered a draw. + Ignored by UCI engines. + :param root_moves: Optional. Consider only root moves from this list. + :param options: Optional. A dictionary of engine options for the + analysis. The previous configuration will be restored after the + analysis is complete. You can permanently apply a configuration + with :func:`~chess.engine.Protocol.configure()`. + :param opponent: Optional. Information about a new opponent. Information + about the original opponent will be restored once the move is + complete. New opponent information can be made permanent with + :func:`~chess.engine.Protocol.send_opponent_information()`. + """ + + @typing.overload + async def analyse(self, board: chess.Board, limit: Limit, *, game: object = None, info: Info = INFO_ALL, root_moves: Optional[Iterable[chess.Move]] = None, options: ConfigMapping = {}) -> InfoDict: ... + @typing.overload + async def analyse(self, board: chess.Board, limit: Limit, *, multipv: int, game: object = None, info: Info = INFO_ALL, root_moves: Optional[Iterable[chess.Move]] = None, options: ConfigMapping = {}) -> List[InfoDict]: ... + @typing.overload + async def analyse(self, board: chess.Board, limit: Limit, *, multipv: Optional[int] = None, game: object = None, info: Info = INFO_ALL, root_moves: Optional[Iterable[chess.Move]] = None, options: ConfigMapping = {}) -> Union[List[InfoDict], InfoDict]: ... + async def analyse(self, board: chess.Board, limit: Limit, *, multipv: Optional[int] = None, game: object = None, info: Info = INFO_ALL, root_moves: Optional[Iterable[chess.Move]] = None, options: ConfigMapping = {}) -> Union[List[InfoDict], InfoDict]: + """ + Analyses a position and returns a dictionary of + :class:`information `. + + :param board: The position to analyse. The entire move stack will be + sent to the engine. + :param limit: An instance of :class:`chess.engine.Limit` that + determines when to stop the analysis. + :param multipv: Optional. Analyse multiple root moves. Will return + a list of at most *multipv* dictionaries rather than just a single + info dictionary. + :param game: Optional. An arbitrary object that identifies the game. + Will automatically inform the engine if the object is not equal + to the previous game (e.g., ``ucinewgame``, ``new``). + :param info: Selects which information to retrieve from the + engine. ``INFO_NONE``, ``INFO_BASIC`` (basic information that is + trivial to obtain), ``INFO_SCORE``, ``INFO_PV``, + ``INFO_REFUTATION``, ``INFO_CURRLINE``, ``INFO_ALL`` or any + bitwise combination. Some overhead is associated with parsing + extra information. + :param root_moves: Optional. Limit analysis to a list of root moves. + :param options: Optional. A dictionary of engine options for the + analysis. The previous configuration will be restored after the + analysis is complete. You can permanently apply a configuration + with :func:`~chess.engine.Protocol.configure()`. + """ + analysis = await self.analysis(board, limit, multipv=multipv, game=game, info=info, root_moves=root_moves, options=options) + + with analysis: + await analysis.wait() + + return analysis.info if multipv is None else analysis.multipv + + @abc.abstractmethod + async def analysis(self, board: chess.Board, limit: Optional[Limit] = None, *, multipv: Optional[int] = None, game: object = None, info: Info = INFO_ALL, root_moves: Optional[Iterable[chess.Move]] = None, options: ConfigMapping = {}) -> AnalysisResult: + """ + Starts analysing a position. + + :param board: The position to analyse. The entire move stack will be + sent to the engine. + :param limit: Optional. An instance of :class:`chess.engine.Limit` + that determines when to stop the analysis. Analysis is infinite + by default. + :param multipv: Optional. Analyse multiple root moves. + :param game: Optional. An arbitrary object that identifies the game. + Will automatically inform the engine if the object is not equal + to the previous game (e.g., ``ucinewgame``, ``new``). + :param info: Selects which information to retrieve from the + engine. ``INFO_NONE``, ``INFO_BASIC`` (basic information that is + trivial to obtain), ``INFO_SCORE``, ``INFO_PV``, + ``INFO_REFUTATION``, ``INFO_CURRLINE``, ``INFO_ALL`` or any + bitwise combination. Some overhead is associated with parsing + extra information. + :param root_moves: Optional. Limit analysis to a list of root moves. + :param options: Optional. A dictionary of engine options for the + analysis. The previous configuration will be restored after the + analysis is complete. You can permanently apply a configuration + with :func:`~chess.engine.Protocol.configure()`. + + Returns :class:`~chess.engine.AnalysisResult`, a handle that allows + asynchronously iterating over the information sent by the engine + and stopping the analysis at any time. + """ + + @abc.abstractmethod + async def send_game_result(self, board: chess.Board, winner: Optional[Color] = None, game_ending: Optional[str] = None, game_complete: bool = True) -> None: + """ + Sends the engine the result of the game. + + XBoard engines receive the final moves and a line of the form + ``result {}``. The ```` field is one of ``1-0``, + ``0-1``, ``1/2-1/2``, or ``*`` to indicate white won, black won, draw, + or adjournment, respectively. The ```` field is a description + of the specific reason for the end of the game: "White mates", + "Time forfeiture", "Stalemate", etc. + + UCI engines do not expect end-of-game information and so are not + sent anything. + + :param board: The final state of the board. + :param winner: Optional. Specify the winner of the game. This is useful + if the result of the game is not evident from the board--e.g., time + forfeiture or draw by agreement. If not ``None``, this parameter + overrides any winner derivable from the board. + :param game_ending: Optional. Text describing the reason for the game + ending. Similarly to the winner parameter, this overrides any game + result derivable from the board. + :param game_complete: Optional. Whether the game reached completion. + """ + + @abc.abstractmethod + async def quit(self) -> None: + """Asks the engine to shut down.""" + + @classmethod + async def popen(cls: Type[ProtocolT], command: Union[str, List[str]], *, setpgrp: bool = False, **popen_args: Any) -> Tuple[asyncio.SubprocessTransport, ProtocolT]: + if not isinstance(command, list): + command = [command] + + if setpgrp: + try: + # Windows. + popen_args["creationflags"] = popen_args.get("creationflags", 0) | subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore + except AttributeError: + # Unix. + if sys.version_info >= (3, 11): + popen_args["process_group"] = 0 + else: + # Before Python 3.11 + popen_args["start_new_session"] = True + + return await asyncio.get_running_loop().subprocess_exec(cls, *command, **popen_args) + + +class CommandState(enum.Enum): + NEW = enum.auto() + ACTIVE = enum.auto() + CANCELLING = enum.auto() + DONE = enum.auto() + + +class BaseCommand(Generic[T]): + def __init__(self, engine: Protocol) -> None: + self._engine = engine + + self.state = CommandState.NEW + + self.result: asyncio.Future[T] = asyncio.Future() + self.finished: asyncio.Future[None] = asyncio.Future() + + self._finished_callbacks: List[Callable[[], None]] = [] + + def add_finished_callback(self, callback: Callable[[], None]) -> None: + self._finished_callbacks.append(callback) + self._dispatch_finished() + + def _dispatch_finished(self) -> None: + if self.finished.done(): + while self._finished_callbacks: + self._finished_callbacks.pop()() + + def _engine_terminated(self, code: int) -> None: + hint = ", binary not compatible with cpu?" if code in [-4, 0xc000001d] else "" + exc = EngineTerminatedError(f"engine process died unexpectedly (exit code: {code}{hint})") + if self.state == CommandState.ACTIVE: + self.engine_terminated(exc) + elif self.state == CommandState.CANCELLING: + self.finished.set_result(None) + self._dispatch_finished() + elif self.state == CommandState.NEW: + self._handle_exception(exc) + + def _handle_exception(self, exc: Exception) -> None: + if not self.result.done(): + self.result.set_exception(exc) else: - self._stdout_buffer.append(byte) + self._engine.loop.call_exception_handler({ # XXX + "message": f"{type(self).__name__} failed after returning preliminary result ({self.result!r})", + "exception": exc, + "protocol": self._engine, + "transport": self._engine.transport, + }) + + if not self.finished.done(): + self.finished.set_result(None) + self._dispatch_finished() + + def set_finished(self) -> None: + assert self.state in [CommandState.ACTIVE, CommandState.CANCELLING], self.state + if not self.result.done(): + self.result.set_exception(EngineError(f"engine command finished before returning result: {self!r}")) + self.state = CommandState.DONE + self.finished.set_result(None) + self._dispatch_finished() + + def _cancel(self) -> None: + if self.state != CommandState.CANCELLING and self.state != CommandState.DONE: + assert self.state == CommandState.ACTIVE, self.state + self.state = CommandState.CANCELLING + self.cancel() + + def _start(self) -> None: + assert self.state == CommandState.NEW, self.state + self.state = CommandState.ACTIVE + try: + self.check_initialized() + self.start() + except EngineError as err: + self._handle_exception(err) - def _waiting_thread_target(self): - self._result = self.process.wait_for_result() - self.engine.on_terminated() + def _line_received(self, line: str) -> None: + assert self.state in [CommandState.ACTIVE, CommandState.CANCELLING], self.state + try: + self.line_received(line) + except EngineError as err: + self._handle_exception(err) - def is_alive(self): - return self.process.is_running() + def cancel(self) -> None: + pass - def terminate(self): - self.process.send_signal(signal.SIGTERM) + def check_initialized(self) -> None: + if not self._engine.initialized: + raise EngineError("tried to run command, but engine is not initialized") - def kill(self): - self.process.send_signal(signal.SIGKILL) + def start(self) -> None: + raise NotImplementedError - def send_line(self, string): - self.process.stdin_write(string.encode("utf-8")) - self.process.stdin_write(b"\n") + def line_received(self, line: str) -> None: + pass - def wait_for_return_code(self): - return self.process.wait_for_result().return_code + def engine_terminated(self, exc: Exception) -> None: + self._handle_exception(exc) - def pid(self): - return self.process.pid + def __repr__(self) -> str: + return "<{} at {:#x} (state={}, result={}, finished={}>".format(type(self).__name__, id(self), self.state, self.result, self.finished) - def __repr__(self): - return "".format(hex(id(self)), self.pid()) +class UciProtocol(Protocol): + """ + An implementation of the + `Universal Chess Interface `_ + protocol. + """ + + def __init__(self) -> None: + super().__init__() + self._options: UciOptionMap[Option] = UciOptionMap() + self.config: UciOptionMap[ConfigValue] = UciOptionMap() + self.target_config: UciOptionMap[ConfigValue] = UciOptionMap() + self.id = {} + self.board = chess.Board() + self.game: object = None + self.first_game = True + self.may_ponderhit: Optional[chess.Board] = None + self.ponderhit = False + + @property + @override + def options(self) -> UciOptionMap[Option]: + return self._options + + async def initialize(self) -> None: + class UciInitializeCommand(BaseCommand[None]): + def __init__(self, engine: UciProtocol): + super().__init__(engine) + self.engine = engine + + @override + def check_initialized(self) -> None: + if self.engine.initialized: + raise EngineError("engine already initialized") + + @override + def start(self) -> None: + self.engine.send_line("uci") + + @override + def line_received(self, line: str) -> None: + token, remaining = _next_token(line) + if line.strip() == "uciok" and not self.result.done(): + self.engine.initialized = True + self.result.set_result(None) + self.set_finished() + elif token == "option": + self._option(remaining) + elif token == "id": + self._id(remaining) + + def _option(self, arg: str) -> None: + current_parameter = None + option_parts: dict[str, str] = {k: "" for k in ["name", "type", "default", "min", "max"]} + var = [] + + parameters = list(option_parts.keys()) + ['var'] + inner_regex = '|'.join([fr"\b{parameter}\b" for parameter in parameters]) + option_regex = fr"\s*({inner_regex})\s*" + for token in re.split(option_regex, arg.strip()): + if token == "var" or (token in option_parts and not option_parts[token]): + current_parameter = token + elif current_parameter == "var": + var.append(token) + elif current_parameter: + option_parts[current_parameter] = token + + def parse_min_max_value(option_parts: dict[str, str], which: Literal["min", "max"]) -> Optional[int]: + try: + number = option_parts[which] + return int(number) if number else None + except ValueError: + LOGGER.exception(f"Exception parsing option {which}") + return None + + name = option_parts["name"] + type = option_parts["type"] + default = option_parts["default"] + min = parse_min_max_value(option_parts, "min") + max = parse_min_max_value(option_parts, "max") + + without_default = Option(name, type, None, min, max, var) + option = Option(without_default.name, without_default.type, without_default.parse(default), min, max, var) + self.engine.options[option.name] = option + + if option.default is not None: + self.engine.config[option.name] = option.default + if option.default is not None and not option.is_managed() and option.name.lower() != "uci_analysemode": + self.engine.target_config[option.name] = option.default + + def _id(self, arg: str) -> None: + key, value = _next_token(arg) + self.engine.id[key] = value.strip() + + return await self.communicate(UciInitializeCommand) + + def _isready(self) -> None: + self.send_line("isready") + + def _opponent_info(self) -> None: + opponent_info = self.config.get("UCI_Opponent") or self.target_config.get("UCI_Opponent") + if opponent_info: + self.send_line(f"setoption name UCI_Opponent value {opponent_info}") + + def _ucinewgame(self) -> None: + self.send_line("ucinewgame") + self._opponent_info() + self.first_game = False + self.ponderhit = False + + def debug(self, on: bool = True) -> None: + """ + Switches debug mode of the engine on or off. This does not interrupt + other ongoing operations. + """ + if on: + self.send_line("debug on") + else: + self.send_line("debug off") -class OptionMap(collections.abc.MutableMapping): - def __init__(self, data=None, **kwargs): - self._store = dict() + async def ping(self) -> None: + class UciPingCommand(BaseCommand[None]): + def __init__(self, engine: UciProtocol) -> None: + super().__init__(engine) + self.engine = engine + + def start(self) -> None: + self.engine._isready() + + @override + def line_received(self, line: str) -> None: + if line.strip() == "readyok": + self.result.set_result(None) + self.set_finished() + else: + LOGGER.warning("%s: Unexpected engine output: %r", self.engine, line) + + return await self.communicate(UciPingCommand) + + def _changed_options(self, options: ConfigMapping) -> bool: + return any(value is None or value != self.config.get(name) for name, value in _chain_config(options, self.target_config)) + + def _setoption(self, name: str, value: ConfigValue) -> None: + try: + value = self.options[name].parse(value) + except KeyError: + raise EngineError("engine does not support option {} (available options: {})".format(name, ", ".join(self.options))) + + if value is None or value != self.config.get(name): + builder = ["setoption name", name] + if value is False: + builder.append("value false") + elif value is True: + builder.append("value true") + elif value is not None: + builder.append("value") + builder.append(str(value)) + + if name != "UCI_Opponent": # sent after ucinewgame + self.send_line(" ".join(builder)) + self.config[name] = value + + def _configure(self, options: ConfigMapping) -> None: + for name, value in _chain_config(options, self.target_config): + if name.lower() in MANAGED_OPTIONS: + raise EngineError("cannot set {} which is automatically managed".format(name)) + self._setoption(name, value) + + async def configure(self, options: ConfigMapping) -> None: + class UciConfigureCommand(BaseCommand[None]): + def __init__(self, engine: UciProtocol): + super().__init__(engine) + self.engine = engine + + def start(self) -> None: + self.engine._configure(options) + self.engine.target_config.update({name: value for name, value in options.items() if value is not None}) + self.result.set_result(None) + self.set_finished() + + return await self.communicate(UciConfigureCommand) + + def _opponent_configuration(self, *, opponent: Optional[Opponent] = None) -> ConfigMapping: + if opponent and opponent.name and "UCI_Opponent" in self.options: + rating = opponent.rating or "none" + title = opponent.title or "none" + player_type = "computer" if opponent.is_engine else "human" + return {"UCI_Opponent": f"{title} {rating} {player_type} {opponent.name}"} + else: + return {} + + async def send_opponent_information(self, *, opponent: Optional[Opponent] = None, engine_rating: Optional[int] = None) -> None: + return await self.configure(self._opponent_configuration(opponent=opponent)) + + def _position(self, board: chess.Board) -> None: + # Select UCI_Variant and UCI_Chess960. + uci_variant = type(board).uci_variant + if "UCI_Variant" in self.options: + self._setoption("UCI_Variant", uci_variant) + elif uci_variant != "chess": + raise EngineError("engine does not support UCI_Variant") + + if "UCI_Chess960" in self.options: + self._setoption("UCI_Chess960", board.chess960) + elif board.chess960: + raise EngineError("engine does not support UCI_Chess960") + + # Send starting position. + builder = ["position"] + safe_history = all(board.move_stack) + root = board.root() if safe_history else board + fen = root.fen(shredder=board.chess960, en_passant="fen") + if uci_variant == "chess" and fen == chess.STARTING_FEN: + builder.append("startpos") + else: + builder.append("fen") + builder.append(fen) + + # Send moves. + if not safe_history: + LOGGER.warning("Not transmitting history with null moves to UCI engine") + elif board.move_stack: + builder.append("moves") + builder.extend(move.uci() for move in board.move_stack) + + self.send_line(" ".join(builder)) + self.board = board.copy(stack=False) + + def _go(self, limit: Limit, *, root_moves: Optional[Iterable[chess.Move]] = None, ponder: bool = False, infinite: bool = False) -> None: + builder = ["go"] + if ponder: + builder.append("ponder") + if limit.white_clock is not None: + builder.append("wtime") + builder.append(str(max(1, round(limit.white_clock * 1000)))) + if limit.black_clock is not None: + builder.append("btime") + builder.append(str(max(1, round(limit.black_clock * 1000)))) + if limit.white_inc is not None: + builder.append("winc") + builder.append(str(round(limit.white_inc * 1000))) + if limit.black_inc is not None: + builder.append("binc") + builder.append(str(round(limit.black_inc * 1000))) + if limit.remaining_moves is not None and int(limit.remaining_moves) > 0: + builder.append("movestogo") + builder.append(str(int(limit.remaining_moves))) + if limit.depth is not None: + builder.append("depth") + builder.append(str(max(1, int(limit.depth)))) + if limit.nodes is not None: + builder.append("nodes") + builder.append(str(max(1, int(limit.nodes)))) + if limit.mate is not None: + builder.append("mate") + builder.append(str(max(1, int(limit.mate)))) + if limit.time is not None: + builder.append("movetime") + builder.append(str(max(1, round(limit.time * 1000)))) + if infinite: + builder.append("infinite") + if root_moves is not None: + builder.append("searchmoves") + if root_moves: + builder.extend(move.uci() for move in root_moves) + else: + # Work around searchmoves followed by nothing. + builder.append("0000") + self.send_line(" ".join(builder)) + + async def play(self, board: chess.Board, limit: Limit, *, game: object = None, info: Info = INFO_NONE, ponder: bool = False, draw_offered: bool = False, root_moves: Optional[Iterable[chess.Move]] = None, options: ConfigMapping = {}, opponent: Optional[Opponent] = None) -> PlayResult: + new_options: Dict[str, ConfigValue] = {} + for name, value in options.items(): + new_options[name] = value + new_options.update(self._opponent_configuration(opponent=opponent)) + + engine = self + + class UciPlayCommand(BaseCommand[PlayResult]): + def __init__(self, engine: UciProtocol): + super().__init__(engine) + self.engine = engine + + # May ponderhit only in the same game and with unchanged target + # options. The managed options UCI_AnalyseMode, Ponder, and + # MultiPV never change between pondering play commands. + engine.may_ponderhit = board if ponder and not engine.first_game and game == engine.game and not engine._changed_options(new_options) else None + + @override + def start(self) -> None: + self.info: InfoDict = {} + self.pondering: Optional[chess.Board] = None + self.sent_isready = False + self.start_time = time.perf_counter() + + if self.engine.ponderhit: + self.engine.ponderhit = False + self.engine.send_line("ponderhit") + return + + if "UCI_AnalyseMode" in self.engine.options and "UCI_AnalyseMode" not in self.engine.target_config and all(name.lower() != "uci_analysemode" for name in new_options): + self.engine._setoption("UCI_AnalyseMode", False) + if "Ponder" in self.engine.options: + self.engine._setoption("Ponder", ponder) + if "MultiPV" in self.engine.options: + self.engine._setoption("MultiPV", self.engine.options["MultiPV"].default) + + new_opponent = new_options.get("UCI_Opponent") or self.engine.target_config.get("UCI_Opponent") + opponent_changed = new_opponent != self.engine.config.get("UCI_Opponent") + self.engine._configure(new_options) + + if self.engine.first_game or self.engine.game != game or opponent_changed: + self.engine.game = game + self.engine._ucinewgame() + self.sent_isready = True + self.engine._isready() + else: + self._readyok() + + @override + def line_received(self, line: str) -> None: + token, remaining = _next_token(line) + if token == "info": + self._info(remaining) + elif token == "bestmove": + self._bestmove(remaining) + elif line.strip() == "readyok" and self.sent_isready: + self._readyok() + else: + LOGGER.warning("%s: Unexpected engine output: %r", self.engine, line) + + def _readyok(self) -> None: + self.sent_isready = False + engine._position(board) + engine._go(limit, root_moves=root_moves) + + def _info(self, arg: str) -> None: + if not self.pondering: + self.info.update(_parse_uci_info(arg, self.engine.board, info)) + + def _bestmove(self, arg: str) -> None: + if self.pondering: + self.pondering = None + elif not self.result.cancelled(): + best = _parse_uci_bestmove(self.engine.board, arg) + self.result.set_result(PlayResult(best.move, best.ponder, self.info)) + + if ponder and best.move and best.ponder: + self.pondering = board.copy() + self.pondering.push(best.move) + self.pondering.push(best.ponder) + self.engine._position(self.pondering) + + # Adjust clocks for pondering. + time_used = time.perf_counter() - self.start_time + ponder_limit = copy.copy(limit) + if ponder_limit.white_clock is not None: + ponder_limit.white_clock += (ponder_limit.white_inc or 0.0) + if self.pondering.turn == chess.WHITE: + ponder_limit.white_clock -= time_used + if ponder_limit.black_clock is not None: + ponder_limit.black_clock += (ponder_limit.black_inc or 0.0) + if self.pondering.turn == chess.BLACK: + ponder_limit.black_clock -= time_used + if ponder_limit.remaining_moves: + ponder_limit.remaining_moves -= 1 + + self.engine._go(ponder_limit, ponder=True) + + if not self.pondering: + self.end() + + def end(self) -> None: + engine.may_ponderhit = None + self.set_finished() + + @override + def cancel(self) -> None: + if self.engine.may_ponderhit and self.pondering and self.engine.may_ponderhit.move_stack == self.pondering.move_stack and self.engine.may_ponderhit == self.pondering: + self.engine.ponderhit = True + self.end() + else: + self.engine.send_line("stop") + + @override + def engine_terminated(self, exc: Exception) -> None: + # Allow terminating engine while pondering. + if not self.result.done(): + super().engine_terminated(exc) + + return await self.communicate(UciPlayCommand) + + async def analysis(self, board: chess.Board, limit: Optional[Limit] = None, *, multipv: Optional[int] = None, game: object = None, info: Info = INFO_ALL, root_moves: Optional[Iterable[chess.Move]] = None, options: ConfigMapping = {}) -> AnalysisResult: + class UciAnalysisCommand(BaseCommand[AnalysisResult]): + def __init__(self, engine: UciProtocol): + super().__init__(engine) + self.engine = engine + + def start(self) -> None: + self.analysis = AnalysisResult(stop=lambda: self.cancel()) + self.sent_isready = False + + if "Ponder" in self.engine.options: + self.engine._setoption("Ponder", False) + if "UCI_AnalyseMode" in self.engine.options and "UCI_AnalyseMode" not in self.engine.target_config and all(name.lower() != "uci_analysemode" for name in options): + self.engine._setoption("UCI_AnalyseMode", True) + if "MultiPV" in self.engine.options or (multipv and multipv > 1): + self.engine._setoption("MultiPV", 1 if multipv is None else multipv) + + self.engine._configure(options) + + if self.engine.first_game or self.engine.game != game: + self.engine.game = game + self.engine._ucinewgame() + self.sent_isready = True + self.engine._isready() + else: + self._readyok() + + @override + def line_received(self, line: str) -> None: + token, remaining = _next_token(line) + if token == "info": + self._info(remaining) + elif token == "bestmove": + self._bestmove(remaining) + elif line.strip() == "readyok" and self.sent_isready: + self._readyok() + else: + LOGGER.warning("%s: Unexpected engine output: %r", self.engine, line) + + def _readyok(self) -> None: + self.sent_isready = False + self.engine._position(board) + + if limit: + self.engine._go(limit, root_moves=root_moves) + else: + self.engine._go(Limit(), root_moves=root_moves, infinite=True) + + self.result.set_result(self.analysis) + + def _info(self, arg: str) -> None: + self.analysis.post(_parse_uci_info(arg, self.engine.board, info)) + + def _bestmove(self, arg: str) -> None: + if not self.result.done(): + raise EngineError("was not searching, but engine sent bestmove") + best = _parse_uci_bestmove(self.engine.board, arg) + self.set_finished() + self.analysis.set_finished(best) + + @override + def cancel(self) -> None: + self.engine.send_line("stop") + + @override + def engine_terminated(self, exc: Exception) -> None: + LOGGER.debug("%s: Closing analysis because engine has been terminated (error: %s)", self.engine, exc) + self.analysis.set_exception(exc) + + return await self.communicate(UciAnalysisCommand) + + async def send_game_result(self, board: chess.Board, winner: Optional[Color] = None, game_ending: Optional[str] = None, game_complete: bool = True) -> None: + pass + + async def quit(self) -> None: + self.send_line("quit") + await asyncio.shield(self.returncode) + + +UCI_REGEX = re.compile(r"^[a-h][1-8][a-h][1-8][pnbrqk]?|[PNBRQK]@[a-h][1-8]|0000\Z") + +def _create_variation_line(root_board: chess.Board, line: str) -> tuple[list[chess.Move], str]: + board = root_board.copy(stack=False) + currline: list[chess.Move] = [] + while True: + next_move, remaining_line_after_move = _next_token(line) + if UCI_REGEX.match(next_move): + currline.append(board.push_uci(next_move)) + line = remaining_line_after_move + else: + return currline, line + + +def _parse_uci_info(arg: str, root_board: chess.Board, selector: Info = INFO_ALL) -> InfoDict: + info: InfoDict = {} + if not selector: + return info + + remaining_line = arg + while remaining_line: + parameter, remaining_line = _next_token(remaining_line) + + if parameter == "string": + info["string"] = remaining_line + break + elif parameter in ["depth", "seldepth", "nodes", "multipv", "currmovenumber", + "hashfull", "nps", "tbhits", "cpuload", "movesleft"]: + try: + number, remaining_line = _next_token(remaining_line) + info[parameter] = int(number) # type: ignore + except (ValueError, IndexError): + LOGGER.error("Exception parsing %s from info: %r", parameter, arg) + elif parameter == "time": + try: + time_ms, remaining_line = _next_token(remaining_line) + info["time"] = int(time_ms) / 1000.0 + except (ValueError, IndexError): + LOGGER.error("Exception parsing %s from info: %r", parameter, arg) + elif parameter == "ebf": + try: + number, remaining_line = _next_token(remaining_line) + info["ebf"] = float(number) + except (ValueError, IndexError): + LOGGER.error("Exception parsing %s from info: %r", parameter, arg) + elif parameter == "score" and selector & INFO_SCORE: + try: + kind, remaining_line = _next_token(remaining_line) + value, remaining_line = _next_token(remaining_line) + token, remaining_after_token = _next_token(remaining_line) + if token in ["lowerbound", "upperbound"]: + info[token] = True # type: ignore + remaining_line = remaining_after_token + if kind == "cp": + info["score"] = PovScore(Cp(int(value)), root_board.turn) + elif kind == "mate": + info["score"] = PovScore(Mate(int(value)), root_board.turn) + else: + LOGGER.error("Unknown score kind %r in info (expected cp or mate): %r", kind, arg) + except (ValueError, IndexError): + LOGGER.error("Exception parsing score from info: %r", arg) + elif parameter == "currmove": + try: + current_move, remaining_line = _next_token(remaining_line) + info["currmove"] = chess.Move.from_uci(current_move) + except (ValueError, IndexError): + LOGGER.error("Exception parsing currmove from info: %r", arg) + elif parameter == "currline" and selector & INFO_CURRLINE: + try: + if "currline" not in info: + info["currline"] = {} + + cpunr_text, remaining_line = _next_token(remaining_line) + cpunr = int(cpunr_text) + currline, remaining_line = _create_variation_line(root_board, remaining_line) + info["currline"][cpunr] = currline + except (ValueError, IndexError): + LOGGER.error("Exception parsing currline from info: %r, position at root: %s", arg, root_board.fen()) + elif parameter == "refutation" and selector & INFO_REFUTATION: + try: + if "refutation" not in info: + info["refutation"] = {} + + board = root_board.copy(stack=False) + refuted_text, remaining_line = _next_token(remaining_line) + refuted = board.push_uci(refuted_text) + + refuted_by, remaining_line = _create_variation_line(board, remaining_line) + info["refutation"][refuted] = refuted_by + except (ValueError, IndexError): + LOGGER.error("Exception parsing refutation from info: %r, position at root: %s", arg, root_board.fen()) + elif parameter == "pv" and selector & INFO_PV: + try: + pv, remaining_line = _create_variation_line(root_board, remaining_line) + info["pv"] = pv + except (ValueError, IndexError): + LOGGER.error("Exception parsing pv from info: %r, position at root: %s", arg, root_board.fen()) + elif parameter == "wdl": + try: + wins, remaining_line = _next_token(remaining_line) + draws, remaining_line = _next_token(remaining_line) + losses, remaining_line = _next_token(remaining_line) + info["wdl"] = PovWdl(Wdl(int(wins), int(draws), int(losses)), root_board.turn) + except (ValueError, IndexError): + LOGGER.error("Exception parsing wdl from info: %r", arg) + + return info + +def _parse_uci_bestmove(board: chess.Board, args: str) -> BestMove: + tokens = args.split() + + move = None + ponder = None + + if tokens and tokens[0] not in ["(none)", "NULL"]: + try: + # AnMon 5.75 uses uppercase letters to denote promotion types. + move = board.push_uci(tokens[0].lower()) + except ValueError as err: + raise EngineError(err) + + try: + # Houdini 1.5 sends NULL instead of skipping the token. + if len(tokens) >= 3 and tokens[1] == "ponder" and tokens[2] not in ["(none)", "NULL"]: + ponder = board.parse_uci(tokens[2].lower()) + except ValueError: + LOGGER.exception("Engine sent invalid ponder move") + finally: + board.pop() + + return BestMove(move, ponder) + + +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: + # 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]): + """Dictionary with case-insensitive keys.""" + + def __init__(self, data: Optional[Iterable[Tuple[str, T]]] = None, **kwargs: T) -> None: + self._store: Dict[str, Tuple[str, T]] = {} if data is None: data = {} self.update(data, **kwargs) - def __setitem__(self, key, value): + def __setitem__(self, key: str, value: T) -> None: self._store[key.lower()] = (key, value) - def __getitem__(self, key): + def __getitem__(self, key: str) -> T: return self._store[key.lower()][1] - def __delitem__(self, key): + def __delitem__(self, key: str) -> None: del self._store[key.lower()] - def __iter__(self): - return (casedkey for casedkey, mappedvalue in self._store.values()) + def __iter__(self) -> Iterator[str]: + return (casedkey for casedkey, _ in self._store.values()) - def __len__(self): + def __len__(self) -> int: return len(self._store) - def __eq__(self, other): - for key, value in self.items(): - if key not in other or other[key] != value: - return False + def __eq__(self, other: object) -> bool: + try: + for key, value in self.items(): + if key not in other or other[key] != value: # type: ignore + return False - for key, value in other.items(): - if key not in self or self[key] != value: - return False + for key, value in other.items(): # type: ignore + if key not in self or self[key] != value: + return False - return True + return True + except (TypeError, AttributeError): + return NotImplemented - def copy(self): + def copy(self) -> UciOptionMap[T]: return type(self)(self._store.values()) - def __copy__(self): + def __copy__(self) -> UciOptionMap[T]: return self.copy() - def __repr__(self): - return "{}({})".format(type(self).__name__, dict(self.items())) + def __repr__(self) -> str: + return f"{type(self).__name__}({dict(self.items())!r})" + + +XBOARD_ERROR_REGEX = re.compile(r"^\s*(Error|Illegal move)(\s*\([^()]+\))?\s*:") + + +class XBoardProtocol(Protocol): + """ + An implementation of the + `XBoard protocol `__ (CECP). + """ + + def __init__(self) -> None: + super().__init__() + self.features: Dict[str, Union[int, str]] = {} + self.id = {} + self._options = { + "random": Option("random", "check", False, None, None, None), + "computer": Option("computer", "check", False, None, None, None), + "name": Option("name", "string", "", None, None, None), + "engine_rating": Option("engine_rating", "spin", 0, None, None, None), + "opponent_rating": Option("opponent_rating", "spin", 0, None, None, None) + } + self.config: Dict[str, ConfigValue] = {} + self.target_config: Dict[str, ConfigValue] = {} + self.board = chess.Board() + self.game: object = None + self.clock_id: object = None + self.first_game = True + + @property + @override + def options(self) -> Dict[str, Option]: + return self._options + + async def initialize(self) -> None: + class XBoardInitializeCommand(BaseCommand[None]): + def __init__(self, engine: XBoardProtocol): + super().__init__(engine) + self.engine = engine + + @override + def check_initialized(self) -> None: + if self.engine.initialized: + raise EngineError("engine already initialized") + + @override + def start(self) -> None: + self.engine.send_line("xboard") + self.engine.send_line("protover 2") + self.timeout_handle = self.engine.loop.call_later(2.0, lambda: self.timeout()) + + def timeout(self) -> None: + LOGGER.error("%s: Timeout during initialization", self.engine) + self.end() + + @override + def line_received(self, line: str) -> None: + token, remaining = _next_token(line) + if token.startswith("#"): + pass + elif token == "feature": + self._feature(remaining) + elif XBOARD_ERROR_REGEX.match(line): + raise EngineError(line) + + def _feature(self, arg: str) -> None: + for feature in shlex.split(arg): + key, value = feature.split("=", 1) + if key == "option": + option = _parse_xboard_option(value) + if option.name not in ["random", "computer", "cores", "memory"]: + self.engine.options[option.name] = option + else: + try: + self.engine.features[key] = int(value) + except ValueError: + self.engine.features[key] = value + + if "done" in self.engine.features: + self.timeout_handle.cancel() + if self.engine.features.get("done"): + self.end() + + def end(self) -> None: + if not self.engine.features.get("ping", 0): + self.result.set_exception(EngineError("xboard engine did not declare required feature: ping")) + self.set_finished() + return + if not self.engine.features.get("setboard", 0): + self.result.set_exception(EngineError("xboard engine did not declare required feature: setboard")) + self.set_finished() + return + + if not self.engine.features.get("reuse", 1): + LOGGER.warning("%s: Rejecting feature reuse=0", self.engine) + self.engine.send_line("rejected reuse") + if not self.engine.features.get("sigterm", 1): + LOGGER.warning("%s: Rejecting feature sigterm=0", self.engine) + self.engine.send_line("rejected sigterm") + if self.engine.features.get("san", 0): + LOGGER.warning("%s: Rejecting feature san=1", self.engine) + self.engine.send_line("rejected san") + + if "myname" in self.engine.features: + self.engine.id["name"] = str(self.engine.features["myname"]) + + if self.engine.features.get("memory", 0): + self.engine.options["memory"] = Option("memory", "spin", 16, 1, None, None) + self.engine.send_line("accepted memory") + if self.engine.features.get("smp", 0): + self.engine.options["cores"] = Option("cores", "spin", 1, 1, None, None) + self.engine.send_line("accepted smp") + if self.engine.features.get("egt"): + for egt in str(self.engine.features["egt"]).split(","): + name = f"egtpath {egt}" + self.engine.options[name] = Option(name, "path", None, None, None, None) + self.engine.send_line("accepted egt") + + for option in self.engine.options.values(): + if option.default is not None: + self.engine.config[option.name] = option.default + if option.default is not None and not option.is_managed(): + self.engine.target_config[option.name] = option.default + + self.engine.initialized = True + self.result.set_result(None) + self.set_finished() + + return await self.communicate(XBoardInitializeCommand) + + def _ping(self, n: int) -> None: + self.send_line(f"ping {n}") + + def _variant(self, variant: Optional[str]) -> None: + variants = str(self.features.get("variants", "")).split(",") + if not variant or variant not in variants: + raise EngineError("unsupported xboard variant: {} (available: {})".format(variant, ", ".join(variants))) + + self.send_line(f"variant {variant}") + + def _new(self, board: chess.Board, game: object, options: ConfigMapping, opponent: Optional[Opponent] = None) -> None: + self._configure(options) + self._configure(self._opponent_configuration(opponent=opponent)) + + # Set up starting position. + root = board.root() + new_options = any(param in options for param in ("random", "computer")) + new_game = self.first_game or self.game != game or new_options or opponent or root != self.board.root() + self.game = game + self.first_game = False + if new_game: + self.board = root + self.send_line("new") + + variant = type(board).xboard_variant + if variant == "normal" and board.chess960: + self._variant("fischerandom") + elif variant != "normal": + self._variant(variant) + + if self.config.get("random"): + self.send_line("random") + + opponent_name = self.config.get("name") + if opponent_name and self.features.get("name", True): + self.send_line(f"name {opponent_name}") + + opponent_rating = self.config.get("opponent_rating") + engine_rating = self.config.get("engine_rating") + if engine_rating or opponent_rating: + self.send_line(f"rating {engine_rating or 0} {opponent_rating or 0}") + + if self.config.get("computer"): + self.send_line("computer") + + self.send_line("force") + + fen = root.fen(shredder=board.chess960, en_passant="fen") + if variant != "normal" or fen != chess.STARTING_FEN or board.chess960: + self.send_line(f"setboard {fen}") + else: + self.send_line("force") + + # Undo moves until common position. + common_stack_len = 0 + if not new_game: + for left, right in zip(self.board.move_stack, board.move_stack): + if left == right: + common_stack_len += 1 + else: + break + + while len(self.board.move_stack) > common_stack_len + 1: + self.send_line("remove") + self.board.pop() + self.board.pop() + + while len(self.board.move_stack) > common_stack_len: + self.send_line("undo") + self.board.pop() + + # Play moves from board stack. + for move in board.move_stack[common_stack_len:]: + if not move: + LOGGER.warning("Null move (in %s) may not be supported by all XBoard engines", self.board.fen()) + prefix = "usermove " if self.features.get("usermove", 0) else "" + self.send_line(prefix + self.board.xboard(move)) + self.board.push(move) + + async def ping(self) -> None: + class XBoardPingCommand(BaseCommand[None]): + def __init__(self, engine: XBoardProtocol): + super().__init__(engine) + self.engine = engine + + @override + def start(self) -> None: + n = id(self) & 0xffff + self.pong = f"pong {n}" + self.engine._ping(n) + + @override + def line_received(self, line: str) -> None: + if line == self.pong: + self.result.set_result(None) + self.set_finished() + elif not line.startswith("#"): + LOGGER.warning("%s: Unexpected engine output: %r", self.engine, line) + elif XBOARD_ERROR_REGEX.match(line): + raise EngineError(line) + + return await self.communicate(XBoardPingCommand) + + async def play(self, board: chess.Board, limit: Limit, *, game: object = None, info: Info = INFO_NONE, ponder: bool = False, draw_offered: bool = False, root_moves: Optional[Iterable[chess.Move]] = None, options: ConfigMapping = {}, opponent: Optional[Opponent] = None) -> PlayResult: + if root_moves is not None: + raise EngineError("play with root_moves, but xboard supports 'include' only in analysis mode") + + class XBoardPlayCommand(BaseCommand[PlayResult]): + def __init__(self, engine: XBoardProtocol): + super().__init__(engine) + self.engine = engine + + @override + def start(self) -> None: + self.play_result = PlayResult(None, None) + self.stopped = False + self.pong_after_move: Optional[str] = None + self.pong_after_ponder: Optional[str] = None + + # Set game, position and configure. + self.engine._new(board, game, options, opponent) + + # Limit or time control. + clock = limit.white_clock if board.turn else limit.black_clock + increment = limit.white_inc if board.turn else limit.black_inc + if limit.clock_id is None or limit.clock_id != self.engine.clock_id: + self._send_time_control(clock, increment) + self.engine.clock_id = limit.clock_id + if limit.nodes is not None: + if limit.time is not None or limit.white_clock is not None or limit.black_clock is not None or increment is not None: + raise EngineError("xboard does not support mixing node limits with time limits") + + if "nps" not in self.engine.features: + LOGGER.warning("%s: Engine did not explicitly declare support for node limits (feature nps=?)") + elif not self.engine.features["nps"]: + raise EngineError("xboard engine does not support node limits (feature nps=0)") + + self.engine.send_line("nps 1") + self.engine.send_line(f"st {max(1, int(limit.nodes))}") + if limit.depth is not None: + self.engine.send_line(f"sd {max(1, int(limit.depth))}") + if limit.white_clock is not None: + self.engine.send_line("{} {}".format("time" if board.turn else "otim", max(1, round(limit.white_clock * 100)))) + if limit.black_clock is not None: + self.engine.send_line("{} {}".format("otim" if board.turn else "time", max(1, round(limit.black_clock * 100)))) + + if draw_offered and self.engine.features.get("draw", 1): + self.engine.send_line("draw") + + # Start thinking. + self.engine.send_line("post" if info else "nopost") + self.engine.send_line("hard" if ponder else "easy") + self.engine.send_line("go") + + @override + def line_received(self, line: str) -> None: + token, remaining = _next_token(line) + if token == "move": + self._move(remaining.strip()) + elif token == "Hint:": + self._hint(remaining.strip()) + elif token == "pong": + pong_line = f"{token} {remaining.strip()}" + if pong_line == self.pong_after_move: + if not self.result.done(): + self.result.set_result(self.play_result) + if not ponder: + self.set_finished() + elif pong_line == self.pong_after_ponder: + if not self.result.done(): + self.result.set_result(self.play_result) + self.set_finished() + elif f"{token} {remaining.strip()}" == "offer draw": + if not self.result.done(): + self.play_result.draw_offered = True + self._ping_after_move() + elif line.strip() == "resign": + if not self.result.done(): + self.play_result.resigned = True + self._ping_after_move() + elif token in ["1-0", "0-1", "1/2-1/2"]: + if "resign" in line and not self.result.done(): + self.play_result.resigned = True + self._ping_after_move() + elif token.startswith("#"): + pass + elif XBOARD_ERROR_REGEX.match(line): + self.engine.first_game = True # Board state might no longer be in sync + raise EngineError(line) + elif len(line.split()) >= 4 and line.lstrip()[0].isdigit(): + self._post(line) + else: + LOGGER.warning("%s: Unexpected engine output: %r", self.engine, line) + + def _send_time_control(self, clock: Optional[float], increment: Optional[float]) -> None: + if limit.remaining_moves or clock is not None or increment is not None: + base_mins, base_secs = divmod(int(clock or 0), 60) + self.engine.send_line(f"level {limit.remaining_moves or 0} {base_mins}:{base_secs:02d} {increment or 0}") + if limit.time is not None: + self.engine.send_line(f"st {max(0.01, limit.time)}") + + def _post(self, line: str) -> None: + if not self.result.done(): + self.play_result.info = _parse_xboard_post(line, self.engine.board, info) + + def _move(self, arg: str) -> None: + if not self.result.done() and self.play_result.move is None: + try: + self.play_result.move = self.engine.board.push_xboard(arg) + except ValueError as err: + self.result.set_exception(EngineError(err)) + else: + self._ping_after_move() + else: + try: + self.engine.board.push_xboard(arg) + except ValueError: + LOGGER.exception("Exception playing unexpected move") + + def _hint(self, arg: str) -> None: + if not self.result.done() and self.play_result.move is not None and self.play_result.ponder is None: + try: + self.play_result.ponder = self.engine.board.parse_xboard(arg) + except ValueError: + LOGGER.exception("Exception parsing hint") + else: + LOGGER.warning("Unexpected hint: %r", arg) + + def _ping_after_move(self) -> None: + if self.pong_after_move is None: + n = id(self) & 0xffff + self.pong_after_move = f"pong {n}" + self.engine._ping(n) + + @override + def cancel(self) -> None: + if self.stopped: + return + self.stopped = True + + if self.result.cancelled(): + self.engine.send_line("?") + + if ponder: + self.engine.send_line("easy") + + n = (id(self) + 1) & 0xffff + self.pong_after_ponder = f"pong {n}" + self.engine._ping(n) + + @override + def engine_terminated(self, exc: Exception) -> None: + # Allow terminating engine while pondering. + if not self.result.done(): + super().engine_terminated(exc) + + return await self.communicate(XBoardPlayCommand) + + async def analysis(self, board: chess.Board, limit: Optional[Limit] = None, *, multipv: Optional[int] = None, game: object = None, info: Info = INFO_ALL, root_moves: Optional[Iterable[chess.Move]] = None, options: ConfigMapping = {}) -> AnalysisResult: + if multipv is not None: + raise EngineError("xboard engine does not support multipv") + + if limit is not None and (limit.white_clock is not None or limit.black_clock is not None): + raise EngineError("xboard analysis does not support clock limits") + + class XBoardAnalysisCommand(BaseCommand[AnalysisResult]): + def __init__(self, engine: XBoardProtocol): + super().__init__(engine) + self.engine = engine + + @override + def start(self) -> None: + self.stopped = False + self.best_move: Optional[chess.Move] = None + self.analysis = AnalysisResult(stop=lambda: self.cancel()) + self.final_pong: Optional[str] = None + + self.engine._new(board, game, options) + + if root_moves is not None: + if not self.engine.features.get("exclude", 0): + raise EngineError("xboard engine does not support root_moves (feature exclude=0)") + + self.engine.send_line("exclude all") + for move in root_moves: + self.engine.send_line(f"include {self.engine.board.xboard(move)}") + + self.engine.send_line("post") + self.engine.send_line("analyze") + + self.result.set_result(self.analysis) + + if limit is not None and limit.time is not None: + self.time_limit_handle: Optional[asyncio.Handle] = self.engine.loop.call_later(limit.time, lambda: self.cancel()) + else: + self.time_limit_handle = None + + @override + def line_received(self, line: str) -> None: + token, remaining = _next_token(line) + if token.startswith("#"): + pass + elif len(line.split()) >= 4 and line.lstrip()[0].isdigit(): + self._post(line) + elif f"{token} {remaining.strip()}" == self.final_pong: + self.end() + elif XBOARD_ERROR_REGEX.match(line): + self.engine.first_game = True # Board state might no longer be in sync + raise EngineError(line) + else: + LOGGER.warning("%s: Unexpected engine output: %r", self.engine, line) + + def _post(self, line: str) -> None: + post_info = _parse_xboard_post(line, self.engine.board, info) + self.analysis.post(post_info) + + pv = post_info.get("pv") + if pv: + self.best_move = pv[0] + + if limit is not None: + if limit.time is not None and post_info.get("time", 0) >= limit.time: + self.cancel() + elif limit.nodes is not None and post_info.get("nodes", 0) >= limit.nodes: + self.cancel() + elif limit.depth is not None and post_info.get("depth", 0) >= limit.depth: + self.cancel() + elif limit.mate is not None and "score" in post_info: + if post_info["score"].relative >= Mate(limit.mate): + self.cancel() + + def end(self) -> None: + if self.time_limit_handle: + self.time_limit_handle.cancel() + + self.set_finished() + self.analysis.set_finished(BestMove(self.best_move, None)) + + @override + def cancel(self) -> None: + if self.stopped: + return + self.stopped = True + + self.engine.send_line(".") + self.engine.send_line("exit") + + n = id(self) & 0xffff + self.final_pong = f"pong {n}" + self.engine._ping(n) + + @override + def engine_terminated(self, exc: Exception) -> None: + LOGGER.debug("%s: Closing analysis because engine has been terminated (error: %s)", self.engine, exc) + + if self.time_limit_handle: + self.time_limit_handle.cancel() + + self.analysis.set_exception(exc) + + return await self.communicate(XBoardAnalysisCommand) + + def _setoption(self, name: str, value: ConfigValue) -> None: + if value is not None and value == self.config.get(name): + return + + try: + option = self.options[name] + except KeyError: + raise EngineError(f"unsupported xboard option or command: {name}") + + self.config[name] = value = option.parse(value) + + if name in ["random", "computer", "name", "engine_rating", "opponent_rating"]: + # Applied in _new. + pass + elif name in ["memory", "cores"] or name.startswith("egtpath "): + self.send_line(f"{name} {value}") + elif value is None: + self.send_line(f"option {name}") + elif value is True: + self.send_line(f"option {name}=1") + elif value is False: + self.send_line(f"option {name}=0") + else: + self.send_line(f"option {name}={value}") + + def _configure(self, options: ConfigMapping) -> None: + for name, value in _chain_config(options, self.target_config): + if name.lower() in MANAGED_OPTIONS: + raise EngineError(f"cannot set {name} which is automatically managed") + self._setoption(name, value) + + async def configure(self, options: ConfigMapping) -> None: + class XBoardConfigureCommand(BaseCommand[None]): + def __init__(self, engine: XBoardProtocol): + super().__init__(engine) + self.engine = engine + + @override + def start(self) -> None: + self.engine._configure(options) + self.engine.target_config.update({name: value for name, value in options.items() if value is not None}) + self.result.set_result(None) + self.set_finished() + + return await self.communicate(XBoardConfigureCommand) + + def _opponent_configuration(self, *, opponent: Optional[Opponent] = None, engine_rating: Optional[int] = None) -> ConfigMapping: + if opponent is None: + return {} + + opponent_info: Dict[str, Union[int, bool, str]] = {"engine_rating": engine_rating or self.target_config.get("engine_rating") or 0, + "opponent_rating": opponent.rating or 0, + "computer": opponent.is_engine or False} + + if opponent.name and self.features.get("name", True): + opponent_info["name"] = f"{opponent.title or ''} {opponent.name}".strip() + + return opponent_info + + async def send_opponent_information(self, *, opponent: Optional[Opponent] = None, engine_rating: Optional[int] = None) -> None: + return await self.configure(self._opponent_configuration(opponent=opponent, engine_rating=engine_rating)) + + async def send_game_result(self, board: chess.Board, winner: Optional[Color] = None, game_ending: Optional[str] = None, game_complete: bool = True) -> None: + class XBoardGameResultCommand(BaseCommand[None]): + def __init__(self, engine: XBoardProtocol): + super().__init__(engine) + self.engine = engine + + @override + def start(self) -> None: + if game_ending and any(c in game_ending for c in "{}\n\r"): + raise EngineError(f"invalid line break or curly braces in game ending message: {game_ending!r}") + + self.engine._new(board, self.engine.game, {}) # Send final moves to engine. + + outcome = board.outcome(claim_draw=True) + + if not game_complete: + result = "*" + ending = game_ending or "" + elif winner is not None or game_ending: + result = "1-0" if winner == chess.WHITE else "0-1" if winner == chess.BLACK else "1/2-1/2" + ending = game_ending or "" + elif outcome is not None and outcome.winner is not None: + result = outcome.result() + winning_color = "White" if outcome.winner == chess.WHITE else "Black" + is_checkmate = outcome.termination == chess.Termination.CHECKMATE + ending = f"{winning_color} {'mates' if is_checkmate else 'variant win'}" + elif outcome is not None: + result = outcome.result() + ending = outcome.termination.name.capitalize().replace("_", " ") + else: + result = "*" + ending = "" + + ending_text = f"{{{ending}}}" if ending else "" + self.engine.send_line(f"result {result} {ending_text}".strip()) + self.result.set_result(None) + self.set_finished() + + return await self.communicate(XBoardGameResultCommand) + + async def quit(self) -> None: + self.send_line("quit") + await asyncio.shield(self.returncode) + + +def _parse_xboard_option(feature: str) -> Option: + params = feature.split() + + name = params[0] + type = params[1][1:] + default: Optional[ConfigValue] = None + min = None + max = None + var = None + + if type == "combo": + var = [] + choices = params[2:] + for choice in choices: + if choice == "///": + continue + elif choice[0] == "*": + default = choice[1:] + var.append(choice[1:]) + else: + var.append(choice) + elif type == "check": + default = int(params[2]) + elif type in ["string", "file", "path"]: + if len(params) > 2: + default = params[2] + else: + default = "" + elif type == "spin": + default = int(params[2]) + min = int(params[3]) + max = int(params[4]) + + return Option(name, type, default, min, max, var) + + +def _parse_xboard_post(line: str, root_board: chess.Board, selector: Info = INFO_ALL) -> InfoDict: + # Format: depth score time nodes [seldepth [nps [tbhits]]] pv + info: InfoDict = {} + + # Split leading integer tokens from pv. + pv_tokens = line.split() + integer_tokens = [] + while pv_tokens: + token = pv_tokens.pop(0) + try: + integer_tokens.append(int(token)) + except ValueError: + pv_tokens.insert(0, token) + break + + if len(integer_tokens) < 4: + return info + + # Required integer tokens. + info["depth"] = integer_tokens.pop(0) + cp = integer_tokens.pop(0) + info["time"] = int(integer_tokens.pop(0)) / 100 + info["nodes"] = int(integer_tokens.pop(0)) + + # Score. + if cp <= -100000: + score: Score = Mate(cp + 100000) + elif cp == 100000: + score = MateGiven + elif cp >= 100000: + score = Mate(cp - 100000) + else: + score = Cp(cp) + info["score"] = PovScore(score, root_board.turn) + + # Optional integer tokens. + if integer_tokens: + info["seldepth"] = integer_tokens.pop(0) + if integer_tokens: + info["nps"] = integer_tokens.pop(0) + + while len(integer_tokens) > 1: + # Reserved for future extensions. + integer_tokens.pop(0) + + if integer_tokens: + info["tbhits"] = integer_tokens.pop(0) + + # Principal variation. + pv = [] + board = root_board.copy(stack=False) + for token in pv_tokens: + if token.rstrip(".").isdigit(): + continue + + try: + pv.append(board.push_xboard(token)) + except ValueError: + break + if not (selector & INFO_PV): + break + info["pv"] = pv -def _popen_engine(command, engine_cls, setpgrp=False, **kwargs): + return info + + +def _next_token(line: str) -> tuple[str, str]: + """ + Get the next token in a whitespace-delimited line of text. + + The result is returned as a 2-part tuple of strings. + + If the input line is empty or all whitespace, then the result is two + empty strings. + + If the input line is not empty and not completely whitespace, then + the first element of the returned tuple is a single word with + leading and trailing whitespace removed. The second element is the + unchanged rest of the line. + """ + parts = line.split(maxsplit=1) + return parts[0] if parts else "", parts[1] if len(parts) == 2 else "" + + +class BestMove: + """Returned by :func:`chess.engine.AnalysisResult.wait()`.""" + + move: Optional[chess.Move] + """The best move according to the engine, or ``None``.""" + + ponder: Optional[chess.Move] + """The response that the engine expects after *move*, or ``None``.""" + + def __init__(self, move: Optional[chess.Move], ponder: Optional[chess.Move]): + self.move = move + self.ponder = ponder + + def __repr__(self) -> str: + return "<{} at {:#x} (move={}, ponder={}>".format( + type(self).__name__, id(self), self.move, self.ponder) + + +class AnalysisResult: + """ + Handle to ongoing engine analysis. + Returned by :func:`chess.engine.Protocol.analysis()`. + + Can be used to asynchronously iterate over information sent by the engine. + + Automatically stops the analysis when used as a context manager. + """ + + multipv: List[InfoDict] + """ + A list of dictionaries with aggregated information sent by the engine. + One item for each root move. """ - Opens a local chess engine process. - :param engine_cls: Engine class + def __init__(self, stop: Optional[Callable[[], None]] = None): + self._stop = stop + self._queue: asyncio.Queue[InfoDict] = asyncio.Queue() + self._posted_kork = False + self._seen_kork = False + self._finished: asyncio.Future[BestMove] = asyncio.Future() + self.multipv = [{}] + + def post(self, info: InfoDict) -> None: + # Empty dictionary reserved for kork. + if not info: + return + + multipv = info.get("multipv", 1) + while len(self.multipv) < multipv: + self.multipv.append({}) + self.multipv[multipv - 1].update(info) + + self._queue.put_nowait(info) + + def _kork(self) -> None: + if not self._posted_kork: + self._posted_kork = True + self._queue.put_nowait({}) + + def set_finished(self, best: BestMove) -> None: + if not self._finished.done(): + self._finished.set_result(best) + self._kork() + + def set_exception(self, exc: Exception) -> None: + self._finished.set_exception(exc) + self._kork() + + @property + def info(self) -> InfoDict: + """ + A dictionary of aggregated information sent by the engine. This is + actually an alias for ``multipv[0]``. + """ + return self.multipv[0] + + def stop(self) -> None: + """Stops the analysis as soon as possible.""" + if self._stop and not self._posted_kork: + self._stop() + self._stop = None + + async def wait(self) -> BestMove: + """Waits until the analysis is finished.""" + return await self._finished + + async def get(self) -> InfoDict: + """ + Waits for the next dictionary of information from the engine and + returns it. + + It might be more convenient to use ``async for info in analysis: ...``. + + :raises: :exc:`chess.engine.AnalysisComplete` if the analysis is + complete (or has been stopped) and all information has been + consumed. Use :func:`~chess.engine.AnalysisResult.next()` if you + prefer to get ``None`` instead of an exception. + """ + if self._seen_kork: + raise AnalysisComplete() + + info = await self._queue.get() + if not info: + # Empty dictionary marks end. + self._seen_kork = True + await self._finished + raise AnalysisComplete() + + return info + + def would_block(self) -> bool: + """ + Checks if calling :func:`~chess.engine.AnalysisResult.get()`, + calling :func:`~chess.engine.AnalysisResult.next()`, + or advancing the iterator one step would require waiting for the + engine. + + These functions would return immediately if information is + pending (queue is not + :func:`empty `) or if the search + is finished. + """ + return not self._seen_kork and self._queue.empty() + + def empty(self) -> bool: + """ + Checks if all current information has been consumed. + + If the queue is empty, but the analysis is still ongoing, then further + information can become available in the future. + """ + return self._seen_kork or self._queue.qsize() <= self._posted_kork + + async def next(self) -> Optional[InfoDict]: + try: + return await self.get() + except AnalysisComplete: + return None + + def __aiter__(self) -> AnalysisResult: + return self + + async def __anext__(self) -> InfoDict: + try: + return await self.get() + except AnalysisComplete: + raise StopAsyncIteration + + def __enter__(self) -> AnalysisResult: + return self + + def __exit__(self, exc_type: Optional[Type[BaseException]], exc_value: Optional[BaseException], traceback: Optional[TracebackType]) -> None: + self.stop() + + +async def popen_uci(command: Union[str, List[str]], *, setpgrp: bool = False, **popen_args: Any) -> Tuple[asyncio.SubprocessTransport, UciProtocol]: + """ + Spawns and initializes a UCI engine. + + :param command: Path of the engine executable, or a list including the + path and arguments. :param setpgrp: Open the engine process in a new process group. This will - stop signals (such as keyboards interrupts) from propagating from the + stop signals (such as keyboard interrupts) from propagating from the parent process. Defaults to ``False``. + :param popen_args: Additional arguments for + `popen `_. + Do not set ``stdin``, ``stdout``, ``bufsize`` or + ``universal_newlines``. + + Returns a subprocess transport and engine protocol pair. """ - engine = engine_cls() + transport, protocol = await UciProtocol.popen(command, setpgrp=setpgrp, **popen_args) + try: + await protocol.initialize() + except: + transport.close() + raise + return transport, protocol - popen_args = {} - if setpgrp: - try: - # Windows. - popen_args["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP - except AttributeError: - # Unix. - popen_args["preexec_fn"] = os.setpgrp - popen_args.update(kwargs) - PopenProcess(engine, command, **popen_args) +async def popen_xboard(command: Union[str, List[str]], *, setpgrp: bool = False, **popen_args: Any) -> Tuple[asyncio.SubprocessTransport, XBoardProtocol]: + """ + Spawns and initializes an XBoard engine. + + :param command: Path of the engine executable, or a list including the + path and arguments. + :param setpgrp: Open the engine process in a new process group. This will + stop signals (such as keyboard interrupts) from propagating from the + parent process. Defaults to ``False``. + :param popen_args: Additional arguments for + `popen `_. + Do not set ``stdin``, ``stdout``, ``bufsize`` or + ``universal_newlines``. + + Returns a subprocess transport and engine protocol pair. + """ + transport, protocol = await XBoardProtocol.popen(command, setpgrp=setpgrp, **popen_args) + try: + await protocol.initialize() + except: + transport.close() + raise + return transport, protocol + + +async def _async(sync: Callable[[], T]) -> T: + return sync() + - return engine +class SimpleEngine: + """ + Synchronous wrapper around a transport and engine protocol pair. Provides + the same methods and attributes as :class:`chess.engine.Protocol` + with blocking functions instead of coroutines. + + You may not concurrently modify objects passed to any of the methods. Other + than that, :class:`~chess.engine.SimpleEngine` is thread-safe. When sending + a new command to the engine, any previous running command will be cancelled + as soon as possible. + Methods will raise :class:`asyncio.TimeoutError` if an operation takes + *timeout* seconds longer than expected (unless *timeout* is ``None``). -def _spur_spawn_engine(shell, command, engine_cls): + Automatically closes the transport when used as a context manager. """ - Spawns a remote engine using a `Spur`_ shell. - .. _Spur: https://pypi.python.org/pypi/spur + def __init__(self, transport: asyncio.SubprocessTransport, protocol: Protocol, *, timeout: Optional[float] = 10.0) -> None: + self.transport = transport + self.protocol = protocol + self.timeout = timeout + + self._shutdown_lock = threading.Lock() + self._shutdown = False + self.shutdown_event = asyncio.Event() + + self.returncode: concurrent.futures.Future[int] = concurrent.futures.Future() + + def _timeout_for(self, limit: Optional[Limit]) -> Optional[float]: + if self.timeout is None or limit is None or limit.time is None: + return None + return self.timeout + limit.time + + @contextlib.contextmanager + def _not_shut_down(self) -> Generator[None, None, None]: + with self._shutdown_lock: + if self._shutdown: + raise EngineTerminatedError("engine event loop dead") + yield + + @property + def options(self) -> MutableMapping[str, Option]: + with self._not_shut_down(): + coro = _async(lambda: copy.copy(self.protocol.options)) + future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop) + return future.result() + + @property + def id(self) -> Mapping[str, str]: + with self._not_shut_down(): + coro = _async(lambda: self.protocol.id.copy()) + future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop) + return future.result() + + def communicate(self, command_factory: Callable[[Protocol], BaseCommand[T]]) -> T: + with self._not_shut_down(): + coro = self.protocol.communicate(command_factory) + future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop) + return future.result() + + def configure(self, options: ConfigMapping) -> None: + with self._not_shut_down(): + coro = asyncio.wait_for(self.protocol.configure(options), self.timeout) + future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop) + return future.result() + + def send_opponent_information(self, *, opponent: Optional[Opponent] = None, engine_rating: Optional[int] = None) -> None: + with self._not_shut_down(): + coro = asyncio.wait_for( + self.protocol.send_opponent_information(opponent=opponent, engine_rating=engine_rating), + self.timeout) + future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop) + return future.result() + + def ping(self) -> None: + with self._not_shut_down(): + coro = asyncio.wait_for(self.protocol.ping(), self.timeout) + future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop) + return future.result() + + def play(self, board: chess.Board, limit: Limit, *, game: object = None, info: Info = INFO_NONE, ponder: bool = False, draw_offered: bool = False, root_moves: Optional[Iterable[chess.Move]] = None, options: ConfigMapping = {}, opponent: Optional[Opponent] = None) -> PlayResult: + with self._not_shut_down(): + coro = asyncio.wait_for( + self.protocol.play(board, limit, game=game, info=info, ponder=ponder, draw_offered=draw_offered, root_moves=root_moves, options=options, opponent=opponent), + self._timeout_for(limit)) + future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop) + return future.result() + + @typing.overload + def analyse(self, board: chess.Board, limit: Limit, *, game: object = None, info: Info = INFO_ALL, root_moves: Optional[Iterable[chess.Move]] = None, options: ConfigMapping = {}) -> InfoDict: ... + @typing.overload + def analyse(self, board: chess.Board, limit: Limit, *, multipv: int, game: object = None, info: Info = INFO_ALL, root_moves: Optional[Iterable[chess.Move]] = None, options: ConfigMapping = {}) -> List[InfoDict]: ... + @typing.overload + def analyse(self, board: chess.Board, limit: Limit, *, multipv: Optional[int] = None, game: object = None, info: Info = INFO_ALL, root_moves: Optional[Iterable[chess.Move]] = None, options: ConfigMapping = {}) -> Union[InfoDict, List[InfoDict]]: ... + def analyse(self, board: chess.Board, limit: Limit, *, multipv: Optional[int] = None, game: object = None, info: Info = INFO_ALL, root_moves: Optional[Iterable[chess.Move]] = None, options: ConfigMapping = {}) -> Union[InfoDict, List[InfoDict]]: + with self._not_shut_down(): + coro = asyncio.wait_for( + self.protocol.analyse(board, limit, multipv=multipv, game=game, info=info, root_moves=root_moves, options=options), + self._timeout_for(limit)) + future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop) + return future.result() + + def analysis(self, board: chess.Board, limit: Optional[Limit] = None, *, multipv: Optional[int] = None, game: object = None, info: Info = INFO_ALL, root_moves: Optional[Iterable[chess.Move]] = None, options: ConfigMapping = {}) -> SimpleAnalysisResult: + with self._not_shut_down(): + coro = asyncio.wait_for( + self.protocol.analysis(board, limit, multipv=multipv, game=game, info=info, root_moves=root_moves, options=options), + self.timeout) # Timeout until analysis is *started* + future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop) + return SimpleAnalysisResult(self, future.result()) + + def send_game_result(self, board: chess.Board, winner: Optional[Color] = None, game_ending: Optional[str] = None, game_complete: bool = True) -> None: + with self._not_shut_down(): + coro = asyncio.wait_for(self.protocol.send_game_result(board, winner, game_ending, game_complete), self.timeout) + future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop) + return future.result() + + def quit(self) -> None: + with self._not_shut_down(): + coro = asyncio.wait_for(self.protocol.quit(), self.timeout) + future = asyncio.run_coroutine_threadsafe(coro, self.protocol.loop) + return future.result() + + def close(self) -> None: + """ + Closes the transport and the background event loop as soon as possible. + """ + def _shutdown() -> None: + self.transport.close() + self.shutdown_event.set() + + with self._shutdown_lock: + if not self._shutdown: + self._shutdown = True + self.protocol.loop.call_soon_threadsafe(_shutdown) + + @classmethod + def popen(cls, Protocol: Type[Protocol], command: Union[str, List[str]], *, timeout: Optional[float] = 10.0, debug: Optional[bool] = None, setpgrp: bool = False, **popen_args: Any) -> SimpleEngine: + async def background(future: concurrent.futures.Future[SimpleEngine]) -> None: + transport, protocol = await Protocol.popen(command, setpgrp=setpgrp, **popen_args) + threading.current_thread().name = f"{cls.__name__} (pid={transport.get_pid()})" + simple_engine = cls(transport, protocol, timeout=timeout) + try: + await asyncio.wait_for(protocol.initialize(), timeout) + future.set_result(simple_engine) + returncode = await protocol.returncode + simple_engine.returncode.set_result(returncode) + finally: + simple_engine.close() + await simple_engine.shutdown_event.wait() + + return run_in_background(background, name=f"{cls.__name__} (command={command!r})", debug=debug) + + @classmethod + def popen_uci(cls, command: Union[str, List[str]], *, timeout: Optional[float] = 10.0, debug: Optional[bool] = None, setpgrp: bool = False, **popen_args: Any) -> SimpleEngine: + """ + Spawns and initializes a UCI engine. + Returns a :class:`~chess.engine.SimpleEngine` instance. + """ + return cls.popen(UciProtocol, command, timeout=timeout, debug=debug, setpgrp=setpgrp, **popen_args) + + @classmethod + def popen_xboard(cls, command: Union[str, List[str]], *, timeout: Optional[float] = 10.0, debug: Optional[bool] = None, setpgrp: bool = False, **popen_args: Any) -> SimpleEngine: + """ + Spawns and initializes an XBoard engine. + Returns a :class:`~chess.engine.SimpleEngine` instance. + """ + return cls.popen(XBoardProtocol, command, timeout=timeout, debug=debug, setpgrp=setpgrp, **popen_args) + + def __enter__(self) -> SimpleEngine: + return self + + def __exit__(self, exc_type: Optional[Type[BaseException]], exc_value: Optional[BaseException], traceback: Optional[TracebackType]) -> None: + self.close() + + def __repr__(self) -> str: + pid = self.transport.get_pid() # This happens to be thread-safe + return f"<{type(self).__name__} (pid={pid})>" + + +class SimpleAnalysisResult: """ - engine = engine_cls() - SpurProcess(engine, shell, command) - return engine + Synchronous wrapper around :class:`~chess.engine.AnalysisResult`. Returned + by :func:`chess.engine.SimpleEngine.analysis()`. + """ + + def __init__(self, simple_engine: SimpleEngine, inner: AnalysisResult) -> None: + self.simple_engine = simple_engine + self.inner = inner + + @property + def info(self) -> InfoDict: + with self.simple_engine._not_shut_down(): + coro = _async(lambda: self.inner.info.copy()) + future = asyncio.run_coroutine_threadsafe(coro, self.simple_engine.protocol.loop) + return future.result() + + @property + def multipv(self) -> List[InfoDict]: + with self.simple_engine._not_shut_down(): + coro = _async(lambda: [info.copy() for info in self.inner.multipv]) + future = asyncio.run_coroutine_threadsafe(coro, self.simple_engine.protocol.loop) + return future.result() + + def stop(self) -> None: + with self.simple_engine._not_shut_down(): + self.simple_engine.protocol.loop.call_soon_threadsafe(self.inner.stop) + + def wait(self) -> BestMove: + with self.simple_engine._not_shut_down(): + future = asyncio.run_coroutine_threadsafe(self.inner.wait(), self.simple_engine.protocol.loop) + return future.result() + + def would_block(self) -> bool: + with self.simple_engine._not_shut_down(): + future = asyncio.run_coroutine_threadsafe(_async(self.inner.would_block), self.simple_engine.protocol.loop) + return future.result() + + def empty(self) -> bool: + with self.simple_engine._not_shut_down(): + future = asyncio.run_coroutine_threadsafe(_async(self.inner.empty), self.simple_engine.protocol.loop) + return future.result() + + def get(self) -> InfoDict: + with self.simple_engine._not_shut_down(): + future = asyncio.run_coroutine_threadsafe(self.inner.get(), self.simple_engine.protocol.loop) + return future.result() + + def next(self) -> Optional[InfoDict]: + with self.simple_engine._not_shut_down(): + future = asyncio.run_coroutine_threadsafe(self.inner.next(), self.simple_engine.protocol.loop) + return future.result() + + def __iter__(self) -> Iterator[InfoDict]: + with self.simple_engine._not_shut_down(): + self.simple_engine.protocol.loop.call_soon_threadsafe(self.inner.__aiter__) + return self + + def __next__(self) -> InfoDict: + try: + with self.simple_engine._not_shut_down(): + future = asyncio.run_coroutine_threadsafe(self.inner.__anext__(), self.simple_engine.protocol.loop) + return future.result() + except StopAsyncIteration: + raise StopIteration + + def __enter__(self) -> SimpleAnalysisResult: + return self + + def __exit__(self, exc_type: Optional[Type[BaseException]], exc_value: Optional[BaseException], traceback: Optional[TracebackType]) -> None: + self.stop() diff --git a/chess/gaviota.py b/chess/gaviota.py index 273c28cbb..7152a18f0 100644 --- a/chess/gaviota.py +++ b/chess/gaviota.py @@ -1,34 +1,22 @@ -# -*- coding: utf-8 -*- -# -# This file is part of the python-chess library. -# Copyright (C) 2015 Jean-Noël Avila -# Copyright (C) 2015-2018 Niklas Fiekas -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import collections +from __future__ import annotations + import ctypes import ctypes.util +import dataclasses import fnmatch import logging import lzma import os import os.path import struct +import typing import chess +from types import TracebackType +from typing import BinaryIO, Callable, Dict, List, Optional, Tuple, Type, Union + + LOGGER = logging.getLogger(__name__) @@ -96,38 +84,38 @@ EGTB_MAXBLOCKSIZE = 65536 -def map24_b(s): - s = s - 8 +def map24_b(s: int) -> int: + s -= 8 return ((s & 3) + s) >> 1 -def map88(x): +def map88(x: int) -> int: return x + (x & 56) -def in_queenside(x): +def in_queenside(x: int) -> int: return (x & (1 << 2)) == 0 -def flip_we(x): +def flip_we(x: int) -> int: return x ^ 7 -def flip_ns(x): +def flip_ns(x: int) -> int: return x ^ 56 -def flip_nw_se(x): +def flip_nw_se(x: int) -> int: return ((x & 7) << 3) | (x >> 3) -def idx_is_empty(x): +def idx_is_empty(x: int) -> int: return x == -1 -def flip_type(x, y): +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 @@ -149,17 +137,16 @@ def flip_type(x, y): return ret -def init_flipt(): +def init_flipt() -> List[List[int]]: return [[flip_type(j, i) for i in range(64)] for j in range(64)] FLIPT = init_flipt() -def init_pp48_idx(): - MAX_I = 48 - MAX_J = 48 +def init_pp48_idx() -> Tuple[List[List[int]], List[int], List[int]]: + MAX_I = MAX_J = 48 idx = 0 - pp48_idx = [[-1] * MAX_J for i in range(MAX_I)] + pp48_idx = [[-1] * MAX_J for _ in range(MAX_I)] pp48_sq_x = [NOSQUARE] * MAX_PP48_INDEX pp48_sq_y = [NOSQUARE] * MAX_PP48_INDEX @@ -181,11 +168,9 @@ def init_pp48_idx(): PP48_IDX, PP48_SQ_X, PP48_SQ_Y = init_pp48_idx() -def init_ppp48_idx(): - MAX_I = 48 - MAX_J = 48 - MAX_K = 48 - ppp48_idx = [[[-1] * MAX_I for j in range(MAX_J)] for k in range(MAX_K)] +def init_ppp48_idx() -> Tuple[List[List[List[int]]], List[int], List[int], List[int]]: + MAX_I = MAX_J = MAX_K = 48 + ppp48_idx = [[[-1] * MAX_I for _ in range(MAX_J)] for _ in range(MAX_K)] ppp48_sq_x = [NOSQUARE] * MAX_PPP48_INDEX ppp48_sq_y = [NOSQUARE] * MAX_PPP48_INDEX ppp48_sq_z = [NOSQUARE] * MAX_PPP48_INDEX @@ -214,15 +199,15 @@ def init_ppp48_idx(): ppp48_sq_x[idx] = i ppp48_sq_y[idx] = j ppp48_sq_z[idx] = k - idx = idx + 1 + idx += 1 return ppp48_idx, ppp48_sq_x, ppp48_sq_y, ppp48_sq_z PPP48_IDX, PPP48_SQ_X, PPP48_SQ_Y, PPP48_SQ_Z = init_ppp48_idx() -def init_aaidx(): - aaidx = [[-1] * 64 for y in range(64)] +def init_aaidx() -> Tuple[List[int], List[List[int]]]: + aaidx = [[-1] * 64 for _ in range(64)] aabase = [0] * MAX_AAINDEX idx = 0 @@ -241,18 +226,18 @@ def init_aaidx(): AABASE, AAIDX = init_aaidx() -def init_aaa(): +def init_aaa() -> Tuple[List[int], List[List[int]]]: # Get aaa_base. comb = [a * (a - 1) // 2 for a in range(64)] accum = 0 aaa_base = [0] * 64 for a in range(64 - 1): - accum = accum + comb[a] + accum += comb[a] aaa_base[a + 1] = accum # Get aaa_xyz. - aaa_xyz = [[-1] * 3 for idx in range(MAX_AAAINDEX)] + aaa_xyz = [[-1] * 3 for _ in range(MAX_AAAINDEX)] idx = 0 for z in range(64): @@ -268,7 +253,7 @@ def init_aaa(): AAA_BASE, AAA_XYZ = init_aaa() -def pp_putanchorfirst(a, b): +def pp_putanchorfirst(a: int, b: int) -> Tuple[int, int]: row_b = b & 56 row_a = a & 56 @@ -312,26 +297,26 @@ def pp_putanchorfirst(a, b): return anchor, loosen -def wsq_to_pidx24(pawn): +def wsq_to_pidx24(pawn: int) -> int: sq = pawn sq = flip_ns(sq) - sq -= 8 # Down one row. + sq -= 8 # Down one row idx24 = (sq + (sq & 3)) >> 1 return idx24 -def wsq_to_pidx48(pawn): +def wsq_to_pidx48(pawn: int) -> int: sq = pawn sq = flip_ns(sq) - sq -= 8 # Down one row. + sq -= 8 # Down one row idx48 = sq return idx48 -def init_ppidx(): - ppidx = [[-1] * 48 for i in range(24)] +def init_ppidx() -> Tuple[List[List[int]], List[int], List[int]]: + ppidx = [[-1] * 48 for _ in range(24)] pp_hi24 = [-1] * MAX_PPINDEX pp_lo48 = [-1] * MAX_PPINDEX @@ -347,7 +332,7 @@ def init_ppidx(): anchor, loosen = pp_putanchorfirst(a, b) if (anchor & 7) > 3: - # Square in the kingside. + # Square on the kingside. anchor = flip_we(anchor) loosen = flip_we(loosen) @@ -365,12 +350,12 @@ def init_ppidx(): PPIDX, PP_HI24, PP_LO48 = init_ppidx() -def norm_kkindex(x, y): - if chess.square_file(x) > 3: +def norm_kkindex(x: chess.Square, y: chess.Square) -> Tuple[int, int]: + 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) @@ -390,8 +375,8 @@ def norm_kkindex(x, y): return x, y -def init_kkidx(): - kkidx = [[-1] * 64 for x in range(64)] +def init_kkidx() -> Tuple[List[List[int]], List[int], List[int]]: + kkidx = [[-1] * 64 for _ in range(64)] bksq = [-1] * MAX_KKINDEX wksq = [-1] * MAX_KKINDEX idx = 0 @@ -414,7 +399,7 @@ def init_kkidx(): KKIDX, WKSQ, BKSQ = init_kkidx() -def kxk_pctoindex(c): +def kxk_pctoindex(c: Request) -> int: BLOCK_Ax = 64 ft = flip_type(c.black_piece_squares[0], c.white_piece_squares[0]) @@ -441,7 +426,7 @@ def kxk_pctoindex(c): return ki * BLOCK_Ax + ws[1] -def kapkb_pctoindex(c): +def kapkb_pctoindex(c: Request) -> int: BLOCK_A = 64 * 64 * 64 * 64 BLOCK_B = 64 * 64 * 64 BLOCK_C = 64 * 64 @@ -457,7 +442,7 @@ def kapkb_pctoindex(c): return NOINDEX if (pawn & 7) > 3: - # Column is more than 3, i.e. e, f, g or h. + # Column is more than 3, i.e., e, f, g or h. pawn = flip_we(pawn) wk = flip_we(wk) bk = flip_we(bk) @@ -466,12 +451,12 @@ def kapkb_pctoindex(c): sq = pawn sq ^= 56 # flip_ns - sq -= 8 # down one row + sq -= 8 # Down one row pslice = (sq + (sq & 3)) >> 1 return pslice * BLOCK_A + wk * BLOCK_B + bk * BLOCK_C + wa * BLOCK_D + ba -def kabpk_pctoindex(c): +def kabpk_pctoindex(c: Request) -> int: BLOCK_A = 64 * 64 * 64 * 64 BLOCK_B = 64 * 64 * 64 BLOCK_C = 64 * 64 @@ -484,7 +469,7 @@ def kabpk_pctoindex(c): bk = c.black_piece_squares[0] if (pawn & 7) > 3: - # Column is more than 3, i.e. e, f, g or h. + # Column is more than 3, i.e., e, f, g or h. pawn = flip_we(pawn) wk = flip_we(wk) bk = flip_we(bk) @@ -495,7 +480,7 @@ def kabpk_pctoindex(c): return pslice * BLOCK_A + wk * BLOCK_B + bk * BLOCK_C + wa * BLOCK_D + wb -def kabkp_pctoindex(c): +def kabkp_pctoindex(c: Request) -> int: BLOCK_A = 64 * 64 * 64 * 64 BLOCK_B = 64 * 64 * 64 BLOCK_C = 64 * 64 @@ -511,7 +496,7 @@ def kabkp_pctoindex(c): return NOINDEX if (pawn & 7) > 3: - # Column is more than 3, i.e. e, f, g or h. + # Column is more than 3, i.e., e, f, g or h. pawn = flip_we(pawn) wk = flip_we(wk) bk = flip_we(bk) @@ -519,12 +504,12 @@ def kabkp_pctoindex(c): wb = flip_we(wb) sq = pawn - sq -= 8 # down one row + sq -= 8 # Down one row pslice = (sq + (sq & 3)) >> 1 return pslice * BLOCK_A + wk * BLOCK_B + bk * BLOCK_C + wa * BLOCK_D + wb -def kaapk_pctoindex(c): +def kaapk_pctoindex(c: Request) -> int: BLOCK_C = MAX_AAINDEX BLOCK_B = 64 * BLOCK_C BLOCK_A = 64 * BLOCK_B @@ -536,7 +521,7 @@ def kaapk_pctoindex(c): bk = c.black_piece_squares[0] if (pawn & 7) > 3: - # Column is more than 3, i.e. e, f, g or h. + # Column is more than 3, i.e., e, f, g or h. pawn = flip_we(pawn) wk = flip_we(wk) bk = flip_we(bk) @@ -552,7 +537,7 @@ def kaapk_pctoindex(c): return pslice * BLOCK_A + wk * BLOCK_B + bk * BLOCK_C + aa_combo -def kaakp_pctoindex(c): +def kaakp_pctoindex(c: Request) -> int: BLOCK_C = MAX_AAINDEX BLOCK_B = 64 * BLOCK_C BLOCK_A = 64 * BLOCK_B @@ -564,7 +549,7 @@ def kaakp_pctoindex(c): pawn = c.black_piece_squares[1] if (pawn & 7) > 3: - # Column is more than 3, i.e. e, f, g or h. + # Column is more than 3, i.e., e, f, g or h. pawn = flip_we(pawn) wk = flip_we(wk) bk = flip_we(bk) @@ -581,7 +566,7 @@ def kaakp_pctoindex(c): return pslice * BLOCK_A + wk * BLOCK_B + bk * BLOCK_C + aa_combo -def kapkp_pctoindex(c): +def kapkp_pctoindex(c: Request) -> int: BLOCK_A = 64 * 64 * 64 BLOCK_B = 64 * 64 BLOCK_C = 64 @@ -596,7 +581,7 @@ def kapkp_pctoindex(c): loosen = pawn_b if (anchor & 7) > 3: - # Column is more than 3, i.e. e, f, g or h. + # Column is more than 3, i.e., e, f, g or h. anchor = flip_we(anchor) loosen = flip_we(loosen) wk = flip_we(wk) @@ -612,7 +597,7 @@ def kapkp_pctoindex(c): return pp_slice * BLOCK_A + wk * BLOCK_B + bk * BLOCK_C + wa -def kappk_pctoindex(c): +def kappk_pctoindex(c: Request) -> int: BLOCK_A = 64 * 64 * 64 BLOCK_B = 64 * 64 BLOCK_C = 64 @@ -626,7 +611,7 @@ def kappk_pctoindex(c): anchor, loosen = pp_putanchorfirst(pawn_a, pawn_b) if (anchor & 7) > 3: - # Column is more than 3, i.e. e, f, g or h. + # Column is more than 3, i.e., e, f, g or h. anchor = flip_we(anchor) loosen = flip_we(loosen) wk = flip_we(wk) @@ -643,7 +628,7 @@ def kappk_pctoindex(c): return pp_slice * BLOCK_A + wk * BLOCK_B + bk * BLOCK_C + wa -def kppka_pctoindex(c): +def kppka_pctoindex(c: Request) -> int: BLOCK_A = 64 * 64 * 64 BLOCK_B = 64 * 64 BLOCK_C = 64 @@ -673,7 +658,7 @@ def kppka_pctoindex(c): return pp_slice * BLOCK_A + wk * BLOCK_B + bk * BLOCK_C + ba -def kabck_pctoindex(c): +def kabck_pctoindex(c: Request) -> int: N_WHITE = 4 N_BLACK = 1 BLOCK_A = 64 * 64 * 64 @@ -704,7 +689,7 @@ def kabck_pctoindex(c): return ki * BLOCK_A + ws[1] * BLOCK_B + ws[2] * BLOCK_C + ws[3] -def kabbk_pctoindex(c): +def kabbk_pctoindex(c: Request) -> int: N_WHITE = 4 N_BLACK = 1 BLOCK_Bx = 64 @@ -735,7 +720,7 @@ def kabbk_pctoindex(c): return ki * BLOCK_Ax + ai * BLOCK_Bx + ws[1] -def kaabk_pctoindex(c): +def kaabk_pctoindex(c: Request) -> int: N_WHITE = 4 N_BLACK = 1 BLOCK_Bx = 64 @@ -766,12 +751,12 @@ def kaabk_pctoindex(c): return ki * BLOCK_Ax + ai * BLOCK_Bx + ws[3] -def aaa_getsubi(x, y, z): +def aaa_getsubi(x: int, y: int, z: int) -> int: bse = AAA_BASE[z] calc_idx = x + (y - 1) * y // 2 + bse return calc_idx -def kaaak_pctoindex(c): +def kaaak_pctoindex(c: Request) -> int: N_WHITE = 4 N_BLACK = 1 BLOCK_Ax = MAX_AAAINDEX @@ -818,7 +803,7 @@ def kaaak_pctoindex(c): return ki * BLOCK_Ax + ai -def kppkp_pctoindex(c): +def kppkp_pctoindex(c: Request) -> int: BLOCK_Ax = MAX_PP48_INDEX * 64 * 64 BLOCK_Bx = 64 * 64 BLOCK_Cx = 64 @@ -839,7 +824,7 @@ def kppkp_pctoindex(c): i = flip_we(flip_ns(pawn_a)) - 8 j = flip_we(flip_ns(pawn_b)) - 8 - # Black pawn, so low indexes mean more advanced. + # Black pawn, so low indexes are more advanced. k = map24_b(pawn_c) pp48_slice = PP48_IDX[i][j] @@ -849,7 +834,7 @@ def kppkp_pctoindex(c): return k * BLOCK_Ax + pp48_slice * BLOCK_Bx + wk * BLOCK_Cx + bk -def kaakb_pctoindex(c): +def kaakb_pctoindex(c: Request) -> int: N_WHITE = 3 N_BLACK = 2 BLOCK_Bx = 64 @@ -880,7 +865,7 @@ def kaakb_pctoindex(c): return ki * BLOCK_Ax + ai * BLOCK_Bx + bs[1] -def kabkc_pctoindex(c): +def kabkc_pctoindex(c: Request) -> int: N_WHITE = 3 N_BLACK = 2 @@ -912,7 +897,7 @@ def kabkc_pctoindex(c): return ki * BLOCK_Ax + ws[1] * BLOCK_Bx + ws[2] * BLOCK_Cx + bs[1] -def kpkp_pctoindex(c): +def kpkp_pctoindex(c: Request) -> int: BLOCK_Ax = 64 * 64 BLOCK_Bx = 64 @@ -940,7 +925,7 @@ def kpkp_pctoindex(c): return pp_slice * BLOCK_Ax + wk * BLOCK_Bx + bk -def kppk_pctoindex(c): +def kppk_pctoindex(c: Request) -> int: BLOCK_Ax = 64 * 64 BLOCK_Bx = 64 wk = c.white_piece_squares[0] @@ -966,7 +951,7 @@ def kppk_pctoindex(c): return pp_slice * BLOCK_Ax + wk * BLOCK_Bx + bk -def kapk_pctoindex(c): +def kapk_pctoindex(c: Request) -> int: BLOCK_Ax = 64 * 64 * 64 BLOCK_Bx = 64 * 64 BLOCK_Cx = 64 @@ -987,12 +972,12 @@ def kapk_pctoindex(c): sq = pawn sq ^= 56 # flip_ns - sq -= 8 # down one row + sq -= 8 # Down one row pslice = ((sq + (sq & 3)) >> 1) return pslice * BLOCK_Ax + wk * BLOCK_Bx + bk * BLOCK_Cx + wa -def kabk_pctoindex(c): +def kabk_pctoindex(c: Request) -> int: BLOCK_Ax = 64 * 64 BLOCK_Bx = 64 @@ -1020,7 +1005,7 @@ def kabk_pctoindex(c): return ki * BLOCK_Ax + ws[1] * BLOCK_Bx + ws[2] -def kakp_pctoindex(c): +def kakp_pctoindex(c: Request) -> int: BLOCK_Ax = 64 * 64 * 64 BLOCK_Bx = 64 * 64 BLOCK_Cx = 64 @@ -1040,12 +1025,12 @@ def kakp_pctoindex(c): wa = flip_we(wa) sq = pawn - sq -= 8 # down one row + sq -= 8 # Down one row pslice = (sq + (sq & 3)) >> 1 return pslice * BLOCK_Ax + wk * BLOCK_Bx + bk * BLOCK_Cx + wa -def kaak_pctoindex(c): +def kaak_pctoindex(c: Request) -> int: N_WHITE = 3 N_BLACK = 1 BLOCK_Ax = MAX_AAINDEX @@ -1075,7 +1060,7 @@ def kaak_pctoindex(c): return ki * BLOCK_Ax + ai -def kakb_pctoindex(c): +def kakb_pctoindex(c: Request) -> int: BLOCK_Ax = 64 * 64 BLOCK_Bx = 64 @@ -1109,7 +1094,7 @@ def kakb_pctoindex(c): return ki * BLOCK_Ax + ws[1] * BLOCK_Bx + bs[1] -def kpk_pctoindex(c): +def kpk_pctoindex(c: Request) -> int: BLOCK_A = 64 * 64 BLOCK_B = 64 @@ -1127,13 +1112,13 @@ def kpk_pctoindex(c): sq = pawn sq ^= 56 # flip_ns - sq -= 8 # down one row + sq -= 8 # Down one row pslice = ((sq + (sq & 3)) >> 1) res = pslice * BLOCK_A + wk * BLOCK_B + bk return res -def kpppk_pctoindex(c): +def kpppk_pctoindex(c: Request) -> int: BLOCK_A = 64 * 64 BLOCK_B = 64 @@ -1169,189 +1154,190 @@ def kpppk_pctoindex(c): return ppp48_slice * BLOCK_A + wk * BLOCK_B + bk -Endgamekey = collections.namedtuple("Endgamekey", "maxindex slice_n pctoi") +class EndgameKey: + def __init__(self, maxindex: int, slice_n: int, pctoi: Callable[[Request], int]): + self.maxindex = maxindex + self.slice_n = slice_n + self.pctoi = pctoi EGKEY = { - "kqk": Endgamekey(MAX_KXK, 1, kxk_pctoindex), - "krk": Endgamekey(MAX_KXK, 1, kxk_pctoindex), - "kbk": Endgamekey(MAX_KXK, 1, kxk_pctoindex), - "knk": Endgamekey(MAX_KXK, 1, kxk_pctoindex), - "kpk": Endgamekey(MAX_kpk, 24, kpk_pctoindex), - - "kqkq": Endgamekey(MAX_kakb, 1, kakb_pctoindex), - "kqkr": Endgamekey(MAX_kakb, 1, kakb_pctoindex), - "kqkb": Endgamekey(MAX_kakb, 1, kakb_pctoindex), - "kqkn": Endgamekey(MAX_kakb, 1, kakb_pctoindex), - - "krkr": Endgamekey(MAX_kakb, 1, kakb_pctoindex), - "krkb": Endgamekey(MAX_kakb, 1, kakb_pctoindex), - "krkn": Endgamekey(MAX_kakb, 1, kakb_pctoindex), - - "kbkb": Endgamekey(MAX_kakb, 1, kakb_pctoindex), - "kbkn": Endgamekey(MAX_kakb, 1, kakb_pctoindex), - - "knkn": Endgamekey(MAX_kakb, 1, kakb_pctoindex), - - "kqqk": Endgamekey(MAX_kaak, 1, kaak_pctoindex), - "kqrk": Endgamekey(MAX_kabk, 1, kabk_pctoindex), - "kqbk": Endgamekey(MAX_kabk, 1, kabk_pctoindex), - "kqnk": Endgamekey(MAX_kabk, 1, kabk_pctoindex), - - "krrk": Endgamekey(MAX_kaak, 1, kaak_pctoindex), - "krbk": Endgamekey(MAX_kabk, 1, kabk_pctoindex), - "krnk": Endgamekey(MAX_kabk, 1, kabk_pctoindex), - - "kbbk": Endgamekey(MAX_kaak, 1, kaak_pctoindex), - "kbnk": Endgamekey(MAX_kabk, 1, kabk_pctoindex), - - "knnk": Endgamekey(MAX_kaak, 1, kaak_pctoindex), - "kqkp": Endgamekey(MAX_kakp, 24, kakp_pctoindex), - "krkp": Endgamekey(MAX_kakp, 24, kakp_pctoindex), - "kbkp": Endgamekey(MAX_kakp, 24, kakp_pctoindex), - "knkp": Endgamekey(MAX_kakp, 24, kakp_pctoindex), - - "kqpk": Endgamekey(MAX_kapk, 24, kapk_pctoindex), - "krpk": Endgamekey(MAX_kapk, 24, kapk_pctoindex), - "kbpk": Endgamekey(MAX_kapk, 24, kapk_pctoindex), - "knpk": Endgamekey(MAX_kapk, 24, kapk_pctoindex), - - "kppk": Endgamekey(MAX_kppk, MAX_PPINDEX, kppk_pctoindex), - - "kpkp": Endgamekey(MAX_kpkp, MAX_PpINDEX, kpkp_pctoindex), - - "kppkp": Endgamekey(MAX_kppkp, 24 * MAX_PP48_INDEX, kppkp_pctoindex), - - "kbbkr": Endgamekey(MAX_kaakb, 1, kaakb_pctoindex), - "kbbkb": Endgamekey(MAX_kaakb, 1, kaakb_pctoindex), - "knnkb": Endgamekey(MAX_kaakb, 1, kaakb_pctoindex), - "knnkn": Endgamekey(MAX_kaakb, 1, kaakb_pctoindex), - - "kqqqk": Endgamekey(MAX_kaaak, 1, kaaak_pctoindex), - "kqqrk": Endgamekey(MAX_kaabk, 1, kaabk_pctoindex), - "kqqbk": Endgamekey(MAX_kaabk, 1, kaabk_pctoindex), - "kqqnk": Endgamekey(MAX_kaabk, 1, kaabk_pctoindex), - "kqrrk": Endgamekey(MAX_kabbk, 1, kabbk_pctoindex), - "kqrbk": Endgamekey(MAX_kabck, 1, kabck_pctoindex), - "kqrnk": Endgamekey(MAX_kabck, 1, kabck_pctoindex), - "kqbbk": Endgamekey(MAX_kabbk, 1, kabbk_pctoindex), - "kqbnk": Endgamekey(MAX_kabck, 1, kabck_pctoindex), - "kqnnk": Endgamekey(MAX_kabbk, 1, kabbk_pctoindex), - "krrrk": Endgamekey(MAX_kaaak, 1, kaaak_pctoindex), - "krrbk": Endgamekey(MAX_kaabk, 1, kaabk_pctoindex), - "krrnk": Endgamekey(MAX_kaabk, 1, kaabk_pctoindex), - "krbbk": Endgamekey(MAX_kabbk, 1, kabbk_pctoindex), - "krbnk": Endgamekey(MAX_kabck, 1, kabck_pctoindex), - "krnnk": Endgamekey(MAX_kabbk, 1, kabbk_pctoindex), - "kbbbk": Endgamekey(MAX_kaaak, 1, kaaak_pctoindex), - "kbbnk": Endgamekey(MAX_kaabk, 1, kaabk_pctoindex), - "kbnnk": Endgamekey(MAX_kabbk, 1, kabbk_pctoindex), - "knnnk": Endgamekey(MAX_kaaak, 1, kaaak_pctoindex), - "kqqkq": Endgamekey(MAX_kaakb, 1, kaakb_pctoindex), - "kqqkr": Endgamekey(MAX_kaakb, 1, kaakb_pctoindex), - "kqqkb": Endgamekey(MAX_kaakb, 1, kaakb_pctoindex), - "kqqkn": Endgamekey(MAX_kaakb, 1, kaakb_pctoindex), - "kqrkq": Endgamekey(MAX_kabkc, 1, kabkc_pctoindex), - "kqrkr": Endgamekey(MAX_kabkc, 1, kabkc_pctoindex), - "kqrkb": Endgamekey(MAX_kabkc, 1, kabkc_pctoindex), - "kqrkn": Endgamekey(MAX_kabkc, 1, kabkc_pctoindex), - "kqbkq": Endgamekey(MAX_kabkc, 1, kabkc_pctoindex), - "kqbkr": Endgamekey(MAX_kabkc, 1, kabkc_pctoindex), - "kqbkb": Endgamekey(MAX_kabkc, 1, kabkc_pctoindex), - "kqbkn": Endgamekey(MAX_kabkc, 1, kabkc_pctoindex), - "kqnkq": Endgamekey(MAX_kabkc, 1, kabkc_pctoindex), - "kqnkr": Endgamekey(MAX_kabkc, 1, kabkc_pctoindex), - "kqnkb": Endgamekey(MAX_kabkc, 1, kabkc_pctoindex), - "kqnkn": Endgamekey(MAX_kabkc, 1, kabkc_pctoindex), - "krrkq": Endgamekey(MAX_kaakb, 1, kaakb_pctoindex), - "krrkr": Endgamekey(MAX_kaakb, 1, kaakb_pctoindex), - "krrkb": Endgamekey(MAX_kaakb, 1, kaakb_pctoindex), - "krrkn": Endgamekey(MAX_kaakb, 1, kaakb_pctoindex), - "krbkq": Endgamekey(MAX_kabkc, 1, kabkc_pctoindex), - "krbkr": Endgamekey(MAX_kabkc, 1, kabkc_pctoindex), - "krbkb": Endgamekey(MAX_kabkc, 1, kabkc_pctoindex), - "krbkn": Endgamekey(MAX_kabkc, 1, kabkc_pctoindex), - "krnkq": Endgamekey(MAX_kabkc, 1, kabkc_pctoindex), - "krnkr": Endgamekey(MAX_kabkc, 1, kabkc_pctoindex), - "krnkb": Endgamekey(MAX_kabkc, 1, kabkc_pctoindex), - "krnkn": Endgamekey(MAX_kabkc, 1, kabkc_pctoindex), - "kbbkq": Endgamekey(MAX_kaakb, 1, kaakb_pctoindex), - "kbbkn": Endgamekey(MAX_kaakb, 1, kaakb_pctoindex), - "kbnkq": Endgamekey(MAX_kabkc, 1, kabkc_pctoindex), - "kbnkr": Endgamekey(MAX_kabkc, 1, kabkc_pctoindex), - "kbnkb": Endgamekey(MAX_kabkc, 1, kabkc_pctoindex), - "kbnkn": Endgamekey(MAX_kabkc, 1, kabkc_pctoindex), - "knnkq": Endgamekey(MAX_kaakb, 1, kaakb_pctoindex), - "knnkr": Endgamekey(MAX_kaakb, 1, kaakb_pctoindex), - - "kqqpk": Endgamekey(MAX_kaapk, 24, kaapk_pctoindex), - "kqrpk": Endgamekey(MAX_kabpk, 24, kabpk_pctoindex), - "kqbpk": Endgamekey(MAX_kabpk, 24, kabpk_pctoindex), - "kqnpk": Endgamekey(MAX_kabpk, 24, kabpk_pctoindex), - "krrpk": Endgamekey(MAX_kaapk, 24, kaapk_pctoindex), - "krbpk": Endgamekey(MAX_kabpk, 24, kabpk_pctoindex), - "krnpk": Endgamekey(MAX_kabpk, 24, kabpk_pctoindex), - "kbbpk": Endgamekey(MAX_kaapk, 24, kaapk_pctoindex), - "kbnpk": Endgamekey(MAX_kabpk, 24, kabpk_pctoindex), - "knnpk": Endgamekey(MAX_kaapk, 24, kaapk_pctoindex), - - "kqppk": Endgamekey(MAX_kappk, MAX_PPINDEX, kappk_pctoindex), - "krppk": Endgamekey(MAX_kappk, MAX_PPINDEX, kappk_pctoindex), - "kbppk": Endgamekey(MAX_kappk, MAX_PPINDEX, kappk_pctoindex), - "knppk": Endgamekey(MAX_kappk, MAX_PPINDEX, kappk_pctoindex), - - "kqpkq": Endgamekey(MAX_kapkb, 24, kapkb_pctoindex), - "kqpkr": Endgamekey(MAX_kapkb, 24, kapkb_pctoindex), - "kqpkb": Endgamekey(MAX_kapkb, 24, kapkb_pctoindex), - "kqpkn": Endgamekey(MAX_kapkb, 24, kapkb_pctoindex), - "krpkq": Endgamekey(MAX_kapkb, 24, kapkb_pctoindex), - "krpkr": Endgamekey(MAX_kapkb, 24, kapkb_pctoindex), - "krpkb": Endgamekey(MAX_kapkb, 24, kapkb_pctoindex), - "krpkn": Endgamekey(MAX_kapkb, 24, kapkb_pctoindex), - "kbpkq": Endgamekey(MAX_kapkb, 24, kapkb_pctoindex), - "kbpkr": Endgamekey(MAX_kapkb, 24, kapkb_pctoindex), - "kbpkb": Endgamekey(MAX_kapkb, 24, kapkb_pctoindex), - "kbpkn": Endgamekey(MAX_kapkb, 24, kapkb_pctoindex), - "knpkq": Endgamekey(MAX_kapkb, 24, kapkb_pctoindex), - "knpkr": Endgamekey(MAX_kapkb, 24, kapkb_pctoindex), - "knpkb": Endgamekey(MAX_kapkb, 24, kapkb_pctoindex), - "knpkn": Endgamekey(MAX_kapkb, 24, kapkb_pctoindex), - "kppkq": Endgamekey(MAX_kppka, MAX_PPINDEX, kppka_pctoindex), - "kppkr": Endgamekey(MAX_kppka, MAX_PPINDEX, kppka_pctoindex), - "kppkb": Endgamekey(MAX_kppka, MAX_PPINDEX, kppka_pctoindex), - "kppkn": Endgamekey(MAX_kppka, MAX_PPINDEX, kppka_pctoindex), - - "kqqkp": Endgamekey(MAX_kaakp, 24, kaakp_pctoindex), - "kqrkp": Endgamekey(MAX_kabkp, 24, kabkp_pctoindex), - "kqbkp": Endgamekey(MAX_kabkp, 24, kabkp_pctoindex), - "kqnkp": Endgamekey(MAX_kabkp, 24, kabkp_pctoindex), - "krrkp": Endgamekey(MAX_kaakp, 24, kaakp_pctoindex), - "krbkp": Endgamekey(MAX_kabkp, 24, kabkp_pctoindex), - "krnkp": Endgamekey(MAX_kabkp, 24, kabkp_pctoindex), - "kbbkp": Endgamekey(MAX_kaakp, 24, kaakp_pctoindex), - "kbnkp": Endgamekey(MAX_kabkp, 24, kabkp_pctoindex), - "knnkp": Endgamekey(MAX_kaakp, 24, kaakp_pctoindex), - - "kqpkp": Endgamekey(MAX_kapkp, MAX_PpINDEX, kapkp_pctoindex), - "krpkp": Endgamekey(MAX_kapkp, MAX_PpINDEX, kapkp_pctoindex), - "kbpkp": Endgamekey(MAX_kapkp, MAX_PpINDEX, kapkp_pctoindex), - "knpkp": Endgamekey(MAX_kapkp, MAX_PpINDEX, kapkp_pctoindex), - - "kpppk": Endgamekey(MAX_kpppk, MAX_PPP48_INDEX, kpppk_pctoindex), + "kqk": EndgameKey(MAX_KXK, 1, kxk_pctoindex), + "krk": EndgameKey(MAX_KXK, 1, kxk_pctoindex), + "kbk": EndgameKey(MAX_KXK, 1, kxk_pctoindex), + "knk": EndgameKey(MAX_KXK, 1, kxk_pctoindex), + "kpk": EndgameKey(MAX_kpk, 24, kpk_pctoindex), + + "kqkq": EndgameKey(MAX_kakb, 1, kakb_pctoindex), + "kqkr": EndgameKey(MAX_kakb, 1, kakb_pctoindex), + "kqkb": EndgameKey(MAX_kakb, 1, kakb_pctoindex), + "kqkn": EndgameKey(MAX_kakb, 1, kakb_pctoindex), + + "krkr": EndgameKey(MAX_kakb, 1, kakb_pctoindex), + "krkb": EndgameKey(MAX_kakb, 1, kakb_pctoindex), + "krkn": EndgameKey(MAX_kakb, 1, kakb_pctoindex), + + "kbkb": EndgameKey(MAX_kakb, 1, kakb_pctoindex), + "kbkn": EndgameKey(MAX_kakb, 1, kakb_pctoindex), + + "knkn": EndgameKey(MAX_kakb, 1, kakb_pctoindex), + + "kqqk": EndgameKey(MAX_kaak, 1, kaak_pctoindex), + "kqrk": EndgameKey(MAX_kabk, 1, kabk_pctoindex), + "kqbk": EndgameKey(MAX_kabk, 1, kabk_pctoindex), + "kqnk": EndgameKey(MAX_kabk, 1, kabk_pctoindex), + + "krrk": EndgameKey(MAX_kaak, 1, kaak_pctoindex), + "krbk": EndgameKey(MAX_kabk, 1, kabk_pctoindex), + "krnk": EndgameKey(MAX_kabk, 1, kabk_pctoindex), + + "kbbk": EndgameKey(MAX_kaak, 1, kaak_pctoindex), + "kbnk": EndgameKey(MAX_kabk, 1, kabk_pctoindex), + + "knnk": EndgameKey(MAX_kaak, 1, kaak_pctoindex), + "kqkp": EndgameKey(MAX_kakp, 24, kakp_pctoindex), + "krkp": EndgameKey(MAX_kakp, 24, kakp_pctoindex), + "kbkp": EndgameKey(MAX_kakp, 24, kakp_pctoindex), + "knkp": EndgameKey(MAX_kakp, 24, kakp_pctoindex), + + "kqpk": EndgameKey(MAX_kapk, 24, kapk_pctoindex), + "krpk": EndgameKey(MAX_kapk, 24, kapk_pctoindex), + "kbpk": EndgameKey(MAX_kapk, 24, kapk_pctoindex), + "knpk": EndgameKey(MAX_kapk, 24, kapk_pctoindex), + + "kppk": EndgameKey(MAX_kppk, MAX_PPINDEX, kppk_pctoindex), + + "kpkp": EndgameKey(MAX_kpkp, MAX_PpINDEX, kpkp_pctoindex), + + "kppkp": EndgameKey(MAX_kppkp, 24 * MAX_PP48_INDEX, kppkp_pctoindex), + + "kbbkr": EndgameKey(MAX_kaakb, 1, kaakb_pctoindex), + "kbbkb": EndgameKey(MAX_kaakb, 1, kaakb_pctoindex), + "knnkb": EndgameKey(MAX_kaakb, 1, kaakb_pctoindex), + "knnkn": EndgameKey(MAX_kaakb, 1, kaakb_pctoindex), + + "kqqqk": EndgameKey(MAX_kaaak, 1, kaaak_pctoindex), + "kqqrk": EndgameKey(MAX_kaabk, 1, kaabk_pctoindex), + "kqqbk": EndgameKey(MAX_kaabk, 1, kaabk_pctoindex), + "kqqnk": EndgameKey(MAX_kaabk, 1, kaabk_pctoindex), + "kqrrk": EndgameKey(MAX_kabbk, 1, kabbk_pctoindex), + "kqrbk": EndgameKey(MAX_kabck, 1, kabck_pctoindex), + "kqrnk": EndgameKey(MAX_kabck, 1, kabck_pctoindex), + "kqbbk": EndgameKey(MAX_kabbk, 1, kabbk_pctoindex), + "kqbnk": EndgameKey(MAX_kabck, 1, kabck_pctoindex), + "kqnnk": EndgameKey(MAX_kabbk, 1, kabbk_pctoindex), + "krrrk": EndgameKey(MAX_kaaak, 1, kaaak_pctoindex), + "krrbk": EndgameKey(MAX_kaabk, 1, kaabk_pctoindex), + "krrnk": EndgameKey(MAX_kaabk, 1, kaabk_pctoindex), + "krbbk": EndgameKey(MAX_kabbk, 1, kabbk_pctoindex), + "krbnk": EndgameKey(MAX_kabck, 1, kabck_pctoindex), + "krnnk": EndgameKey(MAX_kabbk, 1, kabbk_pctoindex), + "kbbbk": EndgameKey(MAX_kaaak, 1, kaaak_pctoindex), + "kbbnk": EndgameKey(MAX_kaabk, 1, kaabk_pctoindex), + "kbnnk": EndgameKey(MAX_kabbk, 1, kabbk_pctoindex), + "knnnk": EndgameKey(MAX_kaaak, 1, kaaak_pctoindex), + "kqqkq": EndgameKey(MAX_kaakb, 1, kaakb_pctoindex), + "kqqkr": EndgameKey(MAX_kaakb, 1, kaakb_pctoindex), + "kqqkb": EndgameKey(MAX_kaakb, 1, kaakb_pctoindex), + "kqqkn": EndgameKey(MAX_kaakb, 1, kaakb_pctoindex), + "kqrkq": EndgameKey(MAX_kabkc, 1, kabkc_pctoindex), + "kqrkr": EndgameKey(MAX_kabkc, 1, kabkc_pctoindex), + "kqrkb": EndgameKey(MAX_kabkc, 1, kabkc_pctoindex), + "kqrkn": EndgameKey(MAX_kabkc, 1, kabkc_pctoindex), + "kqbkq": EndgameKey(MAX_kabkc, 1, kabkc_pctoindex), + "kqbkr": EndgameKey(MAX_kabkc, 1, kabkc_pctoindex), + "kqbkb": EndgameKey(MAX_kabkc, 1, kabkc_pctoindex), + "kqbkn": EndgameKey(MAX_kabkc, 1, kabkc_pctoindex), + "kqnkq": EndgameKey(MAX_kabkc, 1, kabkc_pctoindex), + "kqnkr": EndgameKey(MAX_kabkc, 1, kabkc_pctoindex), + "kqnkb": EndgameKey(MAX_kabkc, 1, kabkc_pctoindex), + "kqnkn": EndgameKey(MAX_kabkc, 1, kabkc_pctoindex), + "krrkq": EndgameKey(MAX_kaakb, 1, kaakb_pctoindex), + "krrkr": EndgameKey(MAX_kaakb, 1, kaakb_pctoindex), + "krrkb": EndgameKey(MAX_kaakb, 1, kaakb_pctoindex), + "krrkn": EndgameKey(MAX_kaakb, 1, kaakb_pctoindex), + "krbkq": EndgameKey(MAX_kabkc, 1, kabkc_pctoindex), + "krbkr": EndgameKey(MAX_kabkc, 1, kabkc_pctoindex), + "krbkb": EndgameKey(MAX_kabkc, 1, kabkc_pctoindex), + "krbkn": EndgameKey(MAX_kabkc, 1, kabkc_pctoindex), + "krnkq": EndgameKey(MAX_kabkc, 1, kabkc_pctoindex), + "krnkr": EndgameKey(MAX_kabkc, 1, kabkc_pctoindex), + "krnkb": EndgameKey(MAX_kabkc, 1, kabkc_pctoindex), + "krnkn": EndgameKey(MAX_kabkc, 1, kabkc_pctoindex), + "kbbkq": EndgameKey(MAX_kaakb, 1, kaakb_pctoindex), + "kbbkn": EndgameKey(MAX_kaakb, 1, kaakb_pctoindex), + "kbnkq": EndgameKey(MAX_kabkc, 1, kabkc_pctoindex), + "kbnkr": EndgameKey(MAX_kabkc, 1, kabkc_pctoindex), + "kbnkb": EndgameKey(MAX_kabkc, 1, kabkc_pctoindex), + "kbnkn": EndgameKey(MAX_kabkc, 1, kabkc_pctoindex), + "knnkq": EndgameKey(MAX_kaakb, 1, kaakb_pctoindex), + "knnkr": EndgameKey(MAX_kaakb, 1, kaakb_pctoindex), + + "kqqpk": EndgameKey(MAX_kaapk, 24, kaapk_pctoindex), + "kqrpk": EndgameKey(MAX_kabpk, 24, kabpk_pctoindex), + "kqbpk": EndgameKey(MAX_kabpk, 24, kabpk_pctoindex), + "kqnpk": EndgameKey(MAX_kabpk, 24, kabpk_pctoindex), + "krrpk": EndgameKey(MAX_kaapk, 24, kaapk_pctoindex), + "krbpk": EndgameKey(MAX_kabpk, 24, kabpk_pctoindex), + "krnpk": EndgameKey(MAX_kabpk, 24, kabpk_pctoindex), + "kbbpk": EndgameKey(MAX_kaapk, 24, kaapk_pctoindex), + "kbnpk": EndgameKey(MAX_kabpk, 24, kabpk_pctoindex), + "knnpk": EndgameKey(MAX_kaapk, 24, kaapk_pctoindex), + + "kqppk": EndgameKey(MAX_kappk, MAX_PPINDEX, kappk_pctoindex), + "krppk": EndgameKey(MAX_kappk, MAX_PPINDEX, kappk_pctoindex), + "kbppk": EndgameKey(MAX_kappk, MAX_PPINDEX, kappk_pctoindex), + "knppk": EndgameKey(MAX_kappk, MAX_PPINDEX, kappk_pctoindex), + + "kqpkq": EndgameKey(MAX_kapkb, 24, kapkb_pctoindex), + "kqpkr": EndgameKey(MAX_kapkb, 24, kapkb_pctoindex), + "kqpkb": EndgameKey(MAX_kapkb, 24, kapkb_pctoindex), + "kqpkn": EndgameKey(MAX_kapkb, 24, kapkb_pctoindex), + "krpkq": EndgameKey(MAX_kapkb, 24, kapkb_pctoindex), + "krpkr": EndgameKey(MAX_kapkb, 24, kapkb_pctoindex), + "krpkb": EndgameKey(MAX_kapkb, 24, kapkb_pctoindex), + "krpkn": EndgameKey(MAX_kapkb, 24, kapkb_pctoindex), + "kbpkq": EndgameKey(MAX_kapkb, 24, kapkb_pctoindex), + "kbpkr": EndgameKey(MAX_kapkb, 24, kapkb_pctoindex), + "kbpkb": EndgameKey(MAX_kapkb, 24, kapkb_pctoindex), + "kbpkn": EndgameKey(MAX_kapkb, 24, kapkb_pctoindex), + "knpkq": EndgameKey(MAX_kapkb, 24, kapkb_pctoindex), + "knpkr": EndgameKey(MAX_kapkb, 24, kapkb_pctoindex), + "knpkb": EndgameKey(MAX_kapkb, 24, kapkb_pctoindex), + "knpkn": EndgameKey(MAX_kapkb, 24, kapkb_pctoindex), + "kppkq": EndgameKey(MAX_kppka, MAX_PPINDEX, kppka_pctoindex), + "kppkr": EndgameKey(MAX_kppka, MAX_PPINDEX, kppka_pctoindex), + "kppkb": EndgameKey(MAX_kppka, MAX_PPINDEX, kppka_pctoindex), + "kppkn": EndgameKey(MAX_kppka, MAX_PPINDEX, kppka_pctoindex), + + "kqqkp": EndgameKey(MAX_kaakp, 24, kaakp_pctoindex), + "kqrkp": EndgameKey(MAX_kabkp, 24, kabkp_pctoindex), + "kqbkp": EndgameKey(MAX_kabkp, 24, kabkp_pctoindex), + "kqnkp": EndgameKey(MAX_kabkp, 24, kabkp_pctoindex), + "krrkp": EndgameKey(MAX_kaakp, 24, kaakp_pctoindex), + "krbkp": EndgameKey(MAX_kabkp, 24, kabkp_pctoindex), + "krnkp": EndgameKey(MAX_kabkp, 24, kabkp_pctoindex), + "kbbkp": EndgameKey(MAX_kaakp, 24, kaakp_pctoindex), + "kbnkp": EndgameKey(MAX_kabkp, 24, kabkp_pctoindex), + "knnkp": EndgameKey(MAX_kaakp, 24, kaakp_pctoindex), + + "kqpkp": EndgameKey(MAX_kapkp, MAX_PpINDEX, kapkp_pctoindex), + "krpkp": EndgameKey(MAX_kapkp, MAX_PpINDEX, kapkp_pctoindex), + "kbpkp": EndgameKey(MAX_kapkp, MAX_PpINDEX, kapkp_pctoindex), + "knpkp": EndgameKey(MAX_kapkp, MAX_PpINDEX, kapkp_pctoindex), + + "kpppk": EndgameKey(MAX_kpppk, MAX_PPP48_INDEX, kpppk_pctoindex), } -def sortlists(ws, wp): +def sortlists(ws: List[int], wp: List[int]) -> Tuple[List[int], List[int]]: z = sorted(zip(wp, ws), key=lambda x: x[0], reverse=True) wp2, ws2 = zip(*z) return list(ws2), list(wp2) -def egtb_block_unpack(side, n, bp): - try: - return [dtm_unpack(side, i) for i in bp[:n]] - except TypeError: - return [dtm_unpack(side, ord(i)) for i in bp[:n]] +def egtb_block_unpack(side: int, n: int, bp: bytes) -> List[int]: + return [dtm_unpack(side, i) for i in bp[:n]] -def split_index(i): +def split_index(i: int) -> Tuple[int, int]: return divmod(i, ENTRIES_PER_BLOCK) @@ -1370,55 +1356,13 @@ def split_index(i): iBMATEt = tb_BMATE | 4 -def removepiece(ys, yp, j): - del ys[j] - del yp[j] - -def opp(side): +def opp(side: int) -> int: return 1 if side == 0 else 0 -def adjust_up(dist): - udist = dist - sw = udist & INFOMASK - - if sw in [iWMATE, iWMATEt, iBMATE, iBMATEt]: - udist += (1 << PLYSHIFT) - - return udist - -def bestx(side, a, b): - # 0 = selectfirst - # 1 = selectlowest - # 2 = selecthighest - # 3 = selectsecond - comparison = [ - # draw, wmate, bmate, forbid - [0, 3, 0, 0], # draw - [0, 1, 0, 0], # wmate - [3, 3, 2, 0], # bmate - [3, 3, 3, 0], # forbid - ] - - xorkey = [0, 3] - - if a == iFORBID: - return b - if b == iFORBID: - return a - - retu = [a, a, b, b] - - if b < a: - retu[1] = b - retu[2] = a - - key = comparison[a & 3][b & 3] ^ xorkey[side] - return retu[key] - -def unpackdist(d): +def unpackdist(d: int) -> Tuple[int, int]: return d >> PLYSHIFT, d & INFOMASK -def dtm_unpack(stm, packed): +def dtm_unpack(stm: int, packed: int) -> int: p = packed if p in [iDRAW, iFORBID]: @@ -1489,67 +1433,70 @@ class MissingTableError(KeyError): class TableBlock: - def __init__(self, egkey, side, offset, age): + pcache: List[int] + + def __init__(self, egkey: str, side: int, offset: int, age: int): self.egkey = egkey self.side = side self.offset = offset self.age = age - self.pcache = None class Request: - def __init__(self, white_squares, white_types, black_squares, black_types, side, epsq): + egkey: str + white_piece_squares: List[int] + white_piece_types: List[int] + black_piece_squares: List[int] + black_piece_types: List[int] + is_reversed: bool + + def __init__(self, white_squares: List[int], white_types: List[chess.PieceType], black_squares: List[int], black_types: List[chess.PieceType], side: int): self.white_squares, self.white_types = sortlists(white_squares, white_types) self.black_squares, self.black_types = sortlists(black_squares, black_types) self.realside = side self.side = side - self.epsq = epsq - - self.egkey = None - self.white_piece_squares = None - self.white_piece_types = None - self.black_piece_squares = None - self.black_piece_types = None - self.is_reversed = None - self.white_piece_squares = None -Zipinfo = collections.namedtuple("Zipinfo", "extraoffset totalblocks blockindex") +@dataclasses.dataclass +class ZipInfo: + extraoffset: int + totalblocks: int + blockindex: List[int] class PythonTablebase: """Provides access to Gaviota tablebases using pure Python code.""" - def __init__(self): - self.available_tables = {} + def __init__(self) -> None: + self.available_tables: Dict[str, str] = {} - self.streams = {} - self.zipinfo = {} + self.streams: Dict[str, BinaryIO] = {} + self.zipinfo: Dict[str, ZipInfo] = {} - self.block_cache = {} + self.block_cache: Dict[Tuple[str, int, int], TableBlock] = {} self.block_age = 0 - def add_directory(self, directory): - """Loads *.gtb.cp4* tables from a directory.""" + def add_directory(self, directory: str) -> None: + """ + Adds *.gtb.cp4* tables from a directory. The relevant files are lazily + opened when the tablebase is actually probed. + """ directory = os.path.abspath(directory) if not os.path.isdir(directory): - raise IOError("not a directory: {}".format(repr(directory))) + raise IOError(f"not a directory: {directory!r}") for tbfile in fnmatch.filter(os.listdir(directory), "*.gtb.cp4"): self.available_tables[os.path.basename(tbfile).replace(".gtb.cp4", "")] = os.path.join(directory, tbfile) - # TODO: Deprecated - open_directory = add_directory - - def probe_dtm(self, board): + def probe_dtm(self, board: chess.Board) -> int: """ Probes for depth to mate information. The absolute value is the number of half-moves until forced mate - (or ``0`` in drawn positions). The value is positive if the - side to move is winning, otherwise it is negative. + (or ``0`` in drawn or checkmated positions). The value is positive if + the side to move is winning, otherwise it is negative or 0. - In the example position white to move will get mated in 10 half-moves: + In the example position, white to move will get mated in 10 half-moves: >>> import chess >>> import chess.gaviota @@ -1564,36 +1511,55 @@ def probe_dtm(self, board): :exc:`chess.gaviota.MissingTableError`) if the probe fails. Use :func:`~chess.gaviota.PythonTablebase.get_dtm()` if you prefer to get ``None`` instead of an exception. + + Note that probing a corrupted table file is undefined behavior. """ # Can not probe positions with castling rights. if board.castling_rights: - raise KeyError("gaviota tables do not contain positions with castling rights: {}".format(board.fen())) + raise KeyError(f"gaviota tables do not contain positions with castling rights: {board.fen()}") - # Prepare the tablebase request. - white = [(square, board.piece_type_at(square)) for square in chess.SquareSet(board.occupied_co[chess.WHITE])] - black = [(square, board.piece_type_at(square)) for square in chess.SquareSet(board.occupied_co[chess.BLACK])] - white_squares, white_types = zip(*white) - black_squares, black_types = zip(*black) - side = 0 if (board.turn == chess.WHITE) else 1 - epsq = board.ep_square if board.ep_square else NOSQUARE - req = Request(white_squares, white_types, black_squares, black_types, side, epsq) + # Supports only up to 5 pieces. + 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 len(white_squares) == 1 and len(black_squares) == 1: + if board.occupied == board.kings: return 0 - # Supports only up to 5 pieces. - if len(white_squares) + len(black_squares) > 5: - raise KeyError("gaviota tables support up to 5 pieces, not {}: {}".format(chess.popcount(board.occupied), board.fen())) + # Resolve en passant. + dtm = self._probe_dtm_no_ep(board) + for move in board.generate_legal_ep(): + try: + board.push(move) + + if board.is_checkmate(): + child_dtm = 1 + else: + child_dtm = -self._probe_dtm_no_ep(board) + if child_dtm > 0: + child_dtm += 1 + elif child_dtm < 0: + child_dtm -= 1 + + dtm = min(dtm, child_dtm) if dtm * child_dtm > 0 else max(dtm, child_dtm) + finally: + board.pop() + return dtm + + def _probe_dtm_no_ep(self, board: chess.Board) -> int: + # Prepare the tablebase request. + white_squares = list(chess.SquareSet(board.occupied_co[chess.WHITE])) + white_types = [typing.cast(chess.PieceType, board.piece_type_at(sq)) for sq in white_squares] + black_squares = list(chess.SquareSet(board.occupied_co[chess.BLACK])) + black_types = [typing.cast(chess.PieceType, board.piece_type_at(sq)) for sq in black_squares] + side = 0 if (board.turn == chess.WHITE) else 1 + req = Request(white_squares, white_types, black_squares, black_types, side) # Probe. - dtm = self.egtb_get_dtm(req) + dtm = self._tb_probe(req) ply, res = unpackdist(dtm) - if res == iDRAW: - # Draw. - return 0 - elif res == iWMATE: + if res == iWMATE: # White mates in the stored position. if req.realside == 1: if req.is_reversed: @@ -1617,16 +1583,19 @@ def probe_dtm(self, board): return -ply else: return ply + else: + # Draw. + return 0 - def get_dtm(self, board, default=None): + def get_dtm(self, board: chess.Board, default: Optional[int] = None) -> Optional[int]: try: return self.probe_dtm(board) except KeyError: return default - def probe_wdl(self, board): + def probe_wdl(self, board: chess.Board) -> int: """ - Probes for win/draw/loss-information. + Probes for win/draw/loss information. Returns ``1`` if the side to move is winning, ``0`` if it is a draw, and ``-1`` if the side to move is losing. @@ -1644,6 +1613,8 @@ def probe_wdl(self, board): :exc:`chess.gaviota.MissingTableError`) if the probe fails. Use :func:`~chess.gaviota.PythonTablebase.get_wdl()` if you prefer to get ``None`` instead of an exception. + + Note that probing a corrupted table file is undefined behavior. """ dtm = self.probe_dtm(board) @@ -1654,18 +1625,18 @@ def probe_wdl(self, board): return 0 elif dtm > 0: return 1 - elif dtm < 0: + else: return -1 - def get_wdl(self, board, default=None): + def get_wdl(self, board: chess.Board, default: Optional[int] = None) -> Optional[int]: try: return self.probe_wdl(board) except KeyError: return default - def _setup_tablebase(self, req): - white_letters = "".join([chess.PIECE_SYMBOLS[i] for i in req.white_types]) - black_letters = "".join([chess.PIECE_SYMBOLS[i] for i in req.black_types]) + def _setup_tablebase(self, req: Request) -> BinaryIO: + white_letters = "".join(chess.piece_symbol(i) for i in req.white_types) + black_letters = "".join(chess.piece_symbol(i) for i in req.black_types) if (white_letters + black_letters) in self.available_tables: req.is_reversed = False @@ -1681,27 +1652,24 @@ def _setup_tablebase(self, req): req.white_piece_types = req.black_types req.black_piece_squares = [flip_ns(s) for s in req.white_squares] req.black_piece_types = req.white_types - req.side = opp(req.side) - if req.epsq != NOSQUARE: - req.epsq = flip_ns(req.epsq) else: - raise MissingTableError("no gaviota table available for: {}v{}".format(white_letters.upper(), black_letters.upper())) + raise MissingTableError(f"no gaviota table available for: {white_letters.upper()}v{black_letters.upper()}") return self._open_tablebase(req) - def _open_tablebase(self, req): + def _open_tablebase(self, req: Request) -> BinaryIO: stream = self.streams.get(req.egkey) 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 return stream - def close(self): + def close(self) -> None: """Closes all loaded tables.""" self.available_tables.clear() @@ -1714,78 +1682,7 @@ def close(self): _, stream = self.streams.popitem() stream.close() - def egtb_get_dtm(self, req): - dtm = self._tb_probe(req) - - if req.epsq != NOSQUARE: - capturer_a = 0 - capturer_b = 0 - xed = 0 - - # Flip for move generation. - if req.side == 0: - xs = list(req.white_piece_squares) - xp = list(req.white_piece_types) - ys = list(req.black_piece_squares) - yp = list(req.black_piece_types) - else: - xs = list(req.black_piece_squares) - xp = list(req.black_piece_types) - ys = list(req.white_piece_squares) - yp = list(req.white_piece_types) - - # Captured pawn trick: from ep square to captured. - xed = req.epsq ^ (1 << 3) - - # Find captured index (j). - try: - j = ys.index(xed) - except ValueError: - j = -1 - - # Try first possible ep capture. - if 0 == (0x88 & (map88(xed) + 1)): - capturer_a = xed + 1 - - # Try second possible ep capture - if 0 == (0x88 & (map88(xed) - 1)): - capturer_b = xed - 1 - - if (j > -1) and (ys[j] == xed): - # Find capturers (i). - for i in range(len(xs)): - if xp[i] == chess.PAWN and (xs[i] == capturer_a or xs[i] == capturer_b): - epscore = iFORBID - - # Copy position. - xs_after = xs[:] - ys_after = ys[:] - xp_after = xp[:] - yp_after = yp[:] - - # Execute capture. - xs_after[i] = req.epsq - removepiece(ys_after, yp_after, j) - - # Flip back. - if req.side == 1: - xs_after, ys_after = ys_after, xs_after - xp_after, yp_after = yp_after, xp_after - - # Make subrequest. - subreq = Request(xs_after, xp_after, ys_after, yp_after, opp(req.side), NOSQUARE) - try: - epscore = self._tb_probe(subreq) - epscore = adjust_up(epscore) - - # Chooses to ep or not. - dtm = bestx(req.side, epscore, dtm) - except IndexError: - break - - return dtm - - def egtb_block_getnumber(self, req, idx): + def egtb_block_getnumber(self, req: Request, idx: int) -> int: maxindex = EGKEY[req.egkey].maxindex blocks_per_side = 1 + (maxindex - 1) // ENTRIES_PER_BLOCK @@ -1793,18 +1690,18 @@ def egtb_block_getnumber(self, req, idx): return req.side * blocks_per_side + block_in_side - def egtb_block_getsize(self, req, idx): + def egtb_block_getsize(self, req: Request, idx: int) -> int: blocksz = ENTRIES_PER_BLOCK maxindex = EGKEY[req.egkey].maxindex block = idx // blocksz offset = block * blocksz if (offset + blocksz) > maxindex: - return maxindex - offset # last block size + return maxindex - offset # Last block size else: - return blocksz # size of a normal block + return blocksz # Size of a normal block - def _tb_probe(self, req): + def _tb_probe(self, req: Request) -> int: stream = self._setup_tablebase(req) idx = EGKEY[req.egkey].pctoi(req) offset, remainder = split_index(idx) @@ -1819,13 +1716,13 @@ def _tb_probe(self, req): 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. buffer_zipped = buffer_zipped[2:] else: - # Else LZMA86. We have to build a fake header. + # Else LZMA86. Build a fake header. DICTIONARY_SIZE = 4096 POS_STATE_BITS = 2 NUM_LITERAL_POS_STATE_BITS = 0 @@ -1847,12 +1744,7 @@ def _tb_probe(self, req): # Update LRU block cache. self.block_cache[(t.egkey, t.offset, t.side)] = t if len(self.block_cache) > 128: - lru_cache_key, lru_age = None, None - for cache_key, cache_entry in self.block_cache.items(): - if lru_age is None or cache_entry.age < lru_age: - lru_cache_key = cache_key - lru_age = cache_entry.age - + lru_cache_key = min(self.block_cache, key=lambda cache_key: self.block_cache[cache_key].age) del self.block_cache[lru_cache_key] else: t.age = self.block_age @@ -1862,7 +1754,7 @@ def _tb_probe(self, req): return dtm - def egtb_loadindexes(self, egkey, stream): + def egtb_loadindexes(self, egkey: str, stream: BinaryIO) -> ZipInfo: zipinfo = self.zipinfo.get(egkey) if zipinfo is None: @@ -1876,28 +1768,28 @@ def egtb_loadindexes(self, egkey, stream): n_idx = blocks + 1 IndexStruct = struct.Struct("<" + "I" * n_idx) - p = IndexStruct.unpack(stream.read(IndexStruct.size)) + p = list(IndexStruct.unpack(stream.read(IndexStruct.size))) - zipinfo = Zipinfo(extraoffset=0, totalblocks=n_idx, blockindex=p) + zipinfo = ZipInfo(extraoffset=0, totalblocks=n_idx, blockindex=p) self.zipinfo[egkey] = zipinfo return zipinfo - def egtb_block_getsize_zipped(self, egkey, block): + def egtb_block_getsize_zipped(self, egkey: str, block: int) -> int: i = self.zipinfo[egkey].blockindex[block] j = self.zipinfo[egkey].blockindex[block + 1] return j - i - def egtb_block_park(self, egkey, block, stream): + def egtb_block_park(self, egkey: str, block: int, stream: BinaryIO) -> int: i = self.zipinfo[egkey].blockindex[block] i += self.zipinfo[egkey].extraoffset stream.seek(i) return i - def __enter__(self): + def __enter__(self) -> PythonTablebase: return self - def __exit__(self, exc_type, exc_value, traceback): + def __exit__(self, exc_type: Optional[Type[BaseException]], exc_value: Optional[BaseException], traceback: Optional[TracebackType]) -> None: self.close() @@ -1907,8 +1799,8 @@ class NativeTablebase: Has the same interface as :class:`~chess.gaviota.PythonTablebase`. """ - def __init__(self, libgtb): - self.paths = [] + def __init__(self, libgtb: ctypes.CDLL) -> None: + self.paths: List[str] = [] self.libgtb = libgtb self.libgtb.tb_init.restype = ctypes.c_char_p @@ -1931,17 +1823,14 @@ def __init__(self, libgtb): self._tbcache_restart(1024 * 1024, 50) - def add_directory(self, directory): + def add_directory(self, directory: str) -> None: if not os.path.isdir(directory): - raise IOError("not a directory: {}".format(repr(directory))) + raise IOError(f"not a directory: {directory!r}") self.paths.append(directory) self._tb_restart() - # TODO: Deprecated - open_directory = add_directory - - def _tb_restart(self): + def _tb_restart(self) -> None: self.c_paths = (ctypes.c_char_p * len(self.paths))() self.c_paths[:] = [path.encode("utf-8") for path in self.paths] @@ -1956,48 +1845,48 @@ def _tb_restart(self): av = self.libgtb.tb_availability() if av & 1: - LOGGER.debug("Some 3 piece tables available") + LOGGER.debug("Some 3-piece tables available") if av & 2: - LOGGER.debug("All 3 piece tables complete") + LOGGER.debug("All 3-piece tables complete") if av & 4: - LOGGER.debug("Some 4 piece tables available") + LOGGER.debug("Some 4-piece tables available") if av & 8: - LOGGER.debug("All 4 piece tables complete") + LOGGER.debug("All 4-piece tables complete") if av & 16: - LOGGER.debug("Some 5 piece tables available") + LOGGER.debug("Some 5-piece tables available") if av & 32: - LOGGER.debug("All 5 piece tables complete") + LOGGER.debug("All 5-piece tables complete") - def _tbcache_restart(self, cache_mem, wdl_fraction): + def _tbcache_restart(self, cache_mem: int, wdl_fraction: int) -> None: self.libgtb.tbcache_restart(ctypes.c_size_t(cache_mem), ctypes.c_int(wdl_fraction)) - def probe_dtm(self, board): + def probe_dtm(self, board: chess.Board) -> int: return self._probe_hard(board) - def probe_wdl(self, board): + def probe_wdl(self, board: chess.Board) -> int: return self._probe_hard(board, wdl_only=True) - def get_dtm(self, board, default=None): + def get_dtm(self, board: chess.Board, default: Optional[int] = None) -> Optional[int]: try: return self.probe_dtm(board) except KeyError: return default - def get_wdl(self, board, default=None): + def get_wdl(self, board: chess.Board, default: Optional[int] = None) -> Optional[int]: try: return self.probe_wdl(board) except KeyError: return default - def _probe_hard(self, board, wdl_only=False): + def _probe_hard(self, board: chess.Board, wdl_only: bool = False) -> int: if board.is_insufficient_material(): return 0 if board.castling_rights: - raise KeyError("gaviota tables do not contain positions with castling rights: {}".format(board.fen())) + raise KeyError(f"gaviota tables do not contain positions with castling rights: {board.fen()}") - if chess.popcount(board.occupied) > 5: - raise KeyError("gaviota tables support up to 5 pieces, not {}: {}".format(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) @@ -2009,7 +1898,7 @@ def _probe_hard(self, board, wdl_only=False): i = -1 for i, square in enumerate(chess.SquareSet(board.occupied_co[chess.WHITE])): c_ws[i] = square - c_wp[i] = board.piece_type_at(square) + c_wp[i] = typing.cast(chess.PieceType, board.piece_type_at(square)) c_ws[i + 1] = 64 c_wp[i + 1] = 0 @@ -2020,7 +1909,7 @@ def _probe_hard(self, board, wdl_only=False): i = -1 for i, square in enumerate(chess.SquareSet(board.occupied_co[chess.BLACK])): c_bs[i] = square - c_bp[i] = board.piece_type_at(square) + c_bp[i] = typing.cast(chess.PieceType, board.piece_type_at(square)) c_bs[i + 1] = 64 c_bp[i + 1] = 0 @@ -2037,39 +1926,37 @@ def _probe_hard(self, board, wdl_only=False): # Probe forbidden. if info.value == 3: - raise MissingTableError("gaviota table for {} not available".format(board.fen())) - - # Probe failed or unknown. - if not ret or info.value == 7: - raise KeyError("gaviota probe failed for {}".format(board.fen())) + raise MissingTableError(f"gaviota table for {board.fen()} not available") # Draw. - if info.value == 0: + if ret and info.value == 0: return 0 # White mates. - if info.value == 1: + if ret and info.value == 1: return dtm if board.turn == chess.WHITE else -dtm # Black mates. - if info.value == 2: + if ret and info.value == 2: return dtm if board.turn == chess.BLACK else -dtm - def close(self): + raise KeyError(f"gaviota probe failed for {board.fen()}") + + def close(self) -> None: self.paths = [] if self.libgtb.tb_is_initialized(): self.libgtb.tbcache_done() self.libgtb.tb_done() - def __enter__(self): + def __enter__(self) -> NativeTablebase: return self - def __exit__(self, exc_type, exc_value, traceback): + def __exit__(self, exc_type: Optional[Type[BaseException]], exc_value: Optional[BaseException], traceback: Optional[TracebackType]) -> None: self.close() -def open_tablebase_native(directory, *, libgtb=None, LibraryLoader=ctypes.cdll): +def open_tablebase_native(directory: str, *, libgtb: Optional[str] = None, LibraryLoader: ctypes.LibraryLoader[ctypes.CDLL] = ctypes.cdll) -> NativeTablebase: """ Opens a collection of tables for probing using libgtb. @@ -2085,7 +1972,7 @@ def open_tablebase_native(directory, *, libgtb=None, LibraryLoader=ctypes.cdll): return tables -def open_tablebase(directory, *, libgtb=None, LibraryLoader=ctypes.cdll): +def open_tablebase(directory: str, *, libgtb: Optional[str] = None, LibraryLoader: ctypes.LibraryLoader[ctypes.CDLL] = ctypes.cdll) -> Union[NativeTablebase, PythonTablebase]: """ Opens a collection of tables for probing. @@ -2094,7 +1981,7 @@ def open_tablebase(directory, *, libgtb=None, LibraryLoader=ctypes.cdll): The shared library has global state and caches, so only one instance can be open at a time. - Second pure Python probing code is tried. + Second, pure Python probing code is tried. """ try: if LibraryLoader: @@ -2105,10 +1992,3 @@ def open_tablebase(directory, *, libgtb=None, LibraryLoader=ctypes.cdll): tables = PythonTablebase() tables.add_directory(directory) return tables - - -# TODO: Deprecated -open_tablebases_native = open_tablebase_native -open_tablebases = open_tablebase -PythonTablebases = PythonTablebase -NativeTablebases = NativeTablebase diff --git a/chess/pgn.py b/chess/pgn.py index 47bb232b5..5ae5b43b0 100644 --- a/chess/pgn.py +++ b/chess/pgn.py @@ -1,28 +1,26 @@ -# -*- coding: utf-8 -*- -# -# This file is part of the python-chess library. -# Copyright (C) 2012-2018 Niklas Fiekas -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import collections.abc +from __future__ import annotations + +import abc +import dataclasses +import enum import itertools import logging import re -import weakref +import typing import chess +import chess.engine +import chess.svg + +from typing import Any, Callable, Dict, Generic, Iterable, Iterator, List, Literal, Mapping, MutableMapping, Set, TextIO, Tuple, Type, TypeVar, Optional, Union +from chess import Color, Square + +if typing.TYPE_CHECKING: + from typing_extensions import Self, override +else: + F = typing.TypeVar("F", bound=Callable[..., Any]) + def override(fn: F, /) -> F: + return fn LOGGER = logging.getLogger(__name__) @@ -80,9 +78,9 @@ NAG_NOVELTY = 146 -TAG_REGEX = re.compile(r"^\[([A-Za-z0-9_]+)\s+\"(.*)\"\]\s*$") +TAG_REGEX = re.compile(r"^\[([A-Za-z0-9][A-Za-z0-9_+#=:-]*)\s+\"([^\r]*)\"\]\s*$") -TAG_NAME_REGEX = re.compile(r"^[A-Za-z0-9_]+\Z") +TAG_NAME_REGEX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_+#=:-]*\Z") MOVETEXT_REGEX = re.compile(r""" ( @@ -90,6 +88,8 @@ |[PNBRQK]?@[a-h][1-8] |-- |Z0 + |0000 + |@@@@ |O-O(?:-O)? |0-0(?:-0)? ) @@ -104,72 +104,189 @@ SKIP_MOVETEXT_REGEX = re.compile(r""";|\{|\}""") + +CLOCK_REGEX = re.compile(r"""(?P\s?)\[%clk\s(?P\d+):(?P\d+):(?P\d+(?:\.\d*)?)\](?P\s?)""") +EMT_REGEX = re.compile(r"""(?P\s?)\[%emt\s(?P\d+):(?P\d+):(?P\d+(?:\.\d*)?)\](?P\s?)""") + +EVAL_REGEX = re.compile(r""" + (?P\s?) + \[%eval\s(?: + \#(?P[+-]?\d+) + |(?P[+-]?(?:\d{0,10}\.\d{1,2}|\d{1,10}\.?)) + )(?: + ,(?P\d+) + )?\] + (?P\s?) + """, re.VERBOSE) + +ARROWS_REGEX = re.compile(r""" + (?P\s?) + \[%(?:csl|cal)\s(?P + [RGYB][a-h][1-8](?:[a-h][1-8])? + (?:,[RGYB][a-h][1-8](?:[a-h][1-8])?)* + )\] + (?P\s?) + """, re.VERBOSE) + +def _condense_affix(infix: str) -> Callable[[typing.Match[str]], str]: + def repl(match: typing.Match[str]) -> str: + if infix: + return match.group("prefix") + infix + match.group("suffix") + else: + return match.group("prefix") and match.group("suffix") + return repl + + +def _standardize_comments(comment: Union[str, list[str]]) -> list[str]: + return [] if not comment else [comment] if isinstance(comment, str) else comment + + TAG_ROSTER = ["Event", "Site", "Date", "Round", "White", "Black", "Result"] -SKIP = object() +class SkipType(enum.Enum): + SKIP = None +SKIP = SkipType.SKIP -class GameNode: - def __init__(self): - self.parent = None - self.move = None - self.nags = set() - self.starting_comment = "" - self.comment = "" + +ResultT = TypeVar("ResultT", covariant=True) + + +class TimeControlType(enum.Enum): + UNKNOWN = 0 + UNLIMITED = 1 + STANDARD = 2 + RAPID = 3 + BLITZ = 4 + BULLET = 5 + + +@dataclasses.dataclass +class TimeControlPart: + moves: int = 0 + time: int = 0 + increment: float = 0 + delay: float = 0 + + +@dataclasses.dataclass +class TimeControl: + """ + PGN TimeControl Parser + Spec: http://www.saremba.de/chessgml/standards/pgn/pgn-complete.htm#c9.6 + + Not Yet Implemented: + - Hourglass/Sandclock ('*' prefix) + - Differentiating between Bronstein and Simple Delay (Not part of the PGN Spec) + - More Info: https://en.wikipedia.org/wiki/Chess_clock#Timing_methods + """ + + parts: list[TimeControlPart] = dataclasses.field(default_factory=list) + type: TimeControlType = TimeControlType.UNKNOWN + + +class _AcceptFrame: + def __init__(self, node: ChildNode, *, is_variation: bool = False, sidelines: bool = True): + self.state = "pre" + self.node = node + self.is_variation = is_variation + self.variations = iter(itertools.islice(node.parent.variations, 1, None) if sidelines else []) + self.in_variation = False + + +class GameNode(abc.ABC): + variations: List[ChildNode] + """A list of child nodes.""" + + comments: list[str] + """ + A comment that goes behind the move leading to this node. Comments + that occur before any moves are assigned to the root node. + """ + + starting_comments: list[str] + + nags: Set[int] + + def __init__(self, *, comment: Union[str, list[str]] = "") -> None: self.variations = [] + self.comments = _standardize_comments(comment) + + # Deprecated: These should be properties of ChildNode, but need to + # remain here for backwards compatibility. + self.starting_comments = [] + self.nags = set() - self.board_cached = None + @property + @abc.abstractmethod + def parent(self) -> Optional[GameNode]: + """The parent node or ``None`` if this is the root node of the game.""" - def board(self, *, _cache=True): + @property + @abc.abstractmethod + def move(self) -> Optional[chess.Move]: + """ + The move leading to this node or ``None`` if this is the root node of + the game. """ - Gets a board with the position of the node. - It's a copy, so modifying the board will not alter the game. + @abc.abstractmethod + def board(self) -> chess.Board: """ - if self.board_cached is not None: - board = self.board_cached() - if board is not None: - return board.copy() + Gets a board with the position of the node. - board = self.parent.board(_cache=False) - board.push(self.move) + For the root node, this is the default starting position (for the + ``Variant``) unless the ``FEN`` header tag is set. - if _cache: - self.board_cached = weakref.ref(board) - return board.copy() - else: - return board + It's a copy, so modifying the board will not alter the game. - def san(self): + Complexity is `O(n)`. """ - Gets the standard algebraic notation of the move leading to this node. - See :func:`chess.Board.san()`. - Do not call this on the root node. + @abc.abstractmethod + def ply(self) -> int: """ - return self.parent.board().san(self.move) + Returns the number of half-moves up to this node, as indicated by + fullmove number and turn of the position. + See :func:`chess.Board.ply()`. - def uci(self, *, chess960=None): + Usually this is equal to the number of parent nodes, but it may be + more if the game was started from a custom position. + + Complexity is `O(n)`. """ - Gets the UCI notation of the move leading to this node. - See :func:`chess.Board.uci()`. - Do not call this on the root node. + def turn(self) -> Color: """ - return self.parent.board().uci(self.move, chess960=chess960) + Gets the color to move at this node. See :data:`chess.Board.turn`. - def root(self): - """Gets the root node, i.e., the game.""" - node = self + Complexity is `O(n)`. + """ + return self.ply() % 2 == 0 + def root(self) -> GameNode: + node = self while node.parent: node = node.parent - return node - def end(self): - """Follows the main variation to the end and returns the last node.""" + def game(self) -> Game: + """ + Gets the root node, i.e., the game. + + Complexity is `O(n)`. + """ + root = self.root() + assert isinstance(root, Game), "GameNode not rooted in Game" + return root + + def end(self) -> GameNode: + """ + Follows the main variation to the end and returns the last node. + + Complexity is `O(n)`. + """ node = self while node.variations: @@ -177,11 +294,15 @@ def end(self): return node - def is_end(self): - """Checks if this node is the last node in the current variation.""" + def is_end(self) -> bool: + """ + Checks if this node is the last node in the current variation. + + Complexity is `O(1)`. + """ return not self.variations - def starts_variation(self): + def starts_variation(self) -> bool: """ Checks if this node starts a variation (and can thus have a starting comment). The root node does not start a variation and can have no @@ -189,14 +310,20 @@ def starts_variation(self): For example, in ``1. e4 e5 (1... c5 2. Nf3) 2. Nf3``, the node holding 1... c5 starts a variation. + + Complexity is `O(1)`. """ if not self.parent or not self.parent.variations: return False return self.parent.variations[0] != self - def is_mainline(self): - """Checks if the node is in the mainline of the game.""" + def is_mainline(self) -> bool: + """ + Checks if the node is in the mainline of the game. + + Complexity is `O(n)`. + """ node = self while node.parent: @@ -209,22 +336,21 @@ def is_mainline(self): return True - # TODO: Deprecated - is_main_line = is_mainline - - def is_main_variation(self): + def is_main_variation(self) -> bool: """ Checks if this node is the first variation from the point of view of its parent. The root node is also in the main variation. + + Complexity is `O(1)`. """ if not self.parent: return True return not self.parent.variations or self.parent.variations[0] == self - def __getitem__(self, move): + def __getitem__(self, move: Union[int, chess.Move, GameNode]) -> ChildNode: try: - return self.variations[move] + return self.variations[move] # type: ignore except TypeError: for variation in self.variations: if variation.move == move or variation == move: @@ -232,73 +358,80 @@ def __getitem__(self, move): raise KeyError(move) - def variation(self, move): + def __contains__(self, move: Union[int, chess.Move, GameNode]) -> bool: + try: + self[move] + except KeyError: + return False + else: + return True + + def variation(self, move: Union[int, chess.Move, GameNode]) -> ChildNode: """ Gets a child node by either the move or the variation index. """ return self[move] - def has_variation(self, move): - """Checks if the given *move* appears as a variation.""" - return move in (variation.move for variation in self.variations) + def has_variation(self, move: Union[int, chess.Move, GameNode]) -> bool: + """Checks if this node has the given variation.""" + return move in self - def promote_to_main(self, move): + def promote_to_main(self, move: Union[int, chess.Move, GameNode]) -> None: """Promotes the given *move* to the main variation.""" variation = self[move] self.variations.remove(variation) self.variations.insert(0, variation) - def promote(self, move): + def promote(self, move: Union[int, chess.Move, GameNode]) -> None: """Moves a variation one up in the list of variations.""" variation = self[move] i = self.variations.index(variation) if i > 0: self.variations[i - 1], self.variations[i] = self.variations[i], self.variations[i - 1] - def demote(self, move): + def demote(self, move: Union[int, chess.Move, GameNode]) -> None: """Moves a variation one down in the list of variations.""" variation = self[move] i = self.variations.index(variation) if i < len(self.variations) - 1: self.variations[i + 1], self.variations[i] = self.variations[i], self.variations[i + 1] - def remove_variation(self, move): + def remove_variation(self, move: Union[int, chess.Move, GameNode]) -> None: """Removes a variation.""" self.variations.remove(self.variation(move)) - def add_variation(self, move, *, comment="", starting_comment="", nags=()): + 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.""" - node = GameNode() - node.move = move - node.nags = set(nags) - node.parent = self - node.comment = comment - node.starting_comment = starting_comment - self.variations.append(node) - return node + # Instantiate ChildNode only in this method. + return ChildNode(self, move, comment=comment, starting_comment=starting_comment, nags=nags) - def add_main_variation(self, move, *, comment=""): + def add_main_variation(self, move: chess.Move, *, comment: str = "", nags: Iterable[int] = []) -> ChildNode: """ Creates a child node with the given attributes and promotes it to the main variation. """ - node = self.add_variation(move, comment=comment) - self.variations.remove(node) - self.variations.insert(0, node) + node = self.add_variation(move, comment=comment, nags=nags) + self.variations.insert(0, self.variations.pop()) return node - def mainline(self): - """Returns an iterator over the mainline starting after this node.""" - return Mainline(self) + def next(self) -> Optional[ChildNode]: + """ + Returns the first node of the mainline after this node, or ``None`` if + this node does not have any children. - def mainline_moves(self): - """Returns an iterator over the main moves after this node.""" - return Mainline(self, lambda node: node.move) + Complexity is `O(1)`. + """ + return self.variations[0] if self.variations else None + + def mainline(self) -> Mainline[ChildNode]: + """Returns an iterable over the mainline starting after this node.""" + return Mainline(self, lambda node: node) - # TODO: Deprecated - main_line = mainline_moves + def mainline_moves(self) -> Mainline[chess.Move]: + """Returns an iterable over the main moves after this node.""" + return Mainline(self, lambda node: node.move) - def add_line(self, moves, *, comment="", starting_comment="", nags=()): + def add_line(self, moves: Iterable[chess.Move], *, comment: Union[str, list[str]] = "", starting_comment: Union[str, list[str]] = "", nags: Iterable[int] = []) -> GameNode: """ Creates a sequence of child nodes for the given list of moves. Adds *comment* and *nags* to the last node of the line and returns it. @@ -311,61 +444,192 @@ def add_line(self, moves, *, comment="", starting_comment="", nags=()): starting_comment = "" # Merge comment and NAGs. - if node.comment: - node.comment += " " + comment - else: - node.comment = comment - + comments = _standardize_comments(comment) + node.comments.extend(comments) node.nags.update(nags) return node - def _accept_node(self, parent_board, visitor): - if self.starting_comment: - visitor.visit_comment(self.starting_comment) + def eval(self) -> Optional[chess.engine.PovScore]: + """ + Parses the first valid ``[%eval ...]`` annotation in the comment of + this node, if any. - visitor.visit_move(parent_board, self.move) + Complexity is `O(n)`. + """ + match = EVAL_REGEX.search(" ".join(self.comments)) + if not match: + return None + + turn = self.turn() + + if match.group("mate"): + mate = int(match.group("mate")) + score: chess.engine.Score = chess.engine.Mate(mate) + if mate == 0: + # Resolve this ambiguity in the specification in favor of + # standard chess: The player to move after mate is the player + # who has been mated. + return chess.engine.PovScore(score, turn) + else: + score = chess.engine.Cp(round(float(match.group("cp")) * 100)) - for nag in sorted(self.nags): - visitor.visit_nag(nag) + return chess.engine.PovScore(score if turn else -score, turn) - if self.comment: - visitor.visit_comment(self.comment) + def eval_depth(self) -> Optional[int]: + """ + Parses the first valid ``[%eval ...]`` annotation in the comment of + this node and returns the corresponding depth, if any. - def accept(self, visitor, *, _parent_board=None): + Complexity is `O(1)`. """ - Traverses game nodes in PGN order using the given *visitor*. Starts with - the move leading to this node. Returns the *visitor* result. + match = EVAL_REGEX.search(" ".join(self.comments)) + return int(match.group("depth")) if match and match.group("depth") else None + + def set_eval(self, score: Optional[chess.engine.PovScore], depth: Optional[int] = None) -> None: + """ + Replaces the first valid ``[%eval ...]`` annotation in the comment of + this node or adds a new one. """ - board = self.parent.board() if _parent_board is None else _parent_board + eval = "" + if score is not None: + depth_suffix = "" if depth is None else f",{max(depth, 0):d}" + cp = score.white().score() + if cp is not None: + eval = f"[%eval {float(cp) / 100:.2f}{depth_suffix}]" + elif score.white().mate(): + eval = f"[%eval #{score.white().mate()}{depth_suffix}]" + + self._replace_or_add_annotation(eval, EVAL_REGEX) + + def arrows(self) -> List[chess.svg.Arrow]: + """ + Parses all ``[%csl ...]`` and ``[%cal ...]`` annotations in the comment + of this node. - # First, visit the move that leads to this node. - self._accept_node(board, visitor) + Returns a list of :class:`arrows `. + """ + arrows = [] + for match in ARROWS_REGEX.finditer(" ".join(self.comments)): + for group in match.group("arrows").split(","): + arrows.append(chess.svg.Arrow.from_pgn(group)) - # Then visit sidelines. - if _parent_board is not None and self == self.parent.variations[0]: - for variation in itertools.islice(self.parent.variations, 1, None): - if visitor.begin_variation() is not SKIP: - variation.accept(visitor, _parent_board=board) - visitor.end_variation() + return arrows - # The mainline is continued last. - if self.variations: - board.push(self.move) - self.variations[0].accept(visitor, _parent_board=board) - board.pop() + def set_arrows(self, arrows: Iterable[Union[chess.svg.Arrow, Tuple[Square, Square]]]) -> None: + """ + Replaces all valid ``[%csl ...]`` and ``[%cal ...]`` annotations in + the comment of this node or adds new ones. + """ + csl: List[str] = [] + cal: List[str] = [] + + for arrow in arrows: + try: + tail, head = arrow # type: ignore + arrow = chess.svg.Arrow(tail, head) + except TypeError: + pass + (csl if arrow.tail == arrow.head else cal).append(arrow.pgn()) # type: ignore + + for index in range(len(self.comments)): + self.comments[index] = ARROWS_REGEX.sub(_condense_affix(""), self.comments[index]) - # Get the result if not called recursively. - if _parent_board is None: - return visitor.result() + self.comments = list(filter(None, self.comments)) - def accept_subgame(self, visitor): + prefix = "" + if csl: + prefix += f"[%csl {','.join(csl)}]" + if cal: + prefix += f"[%cal {','.join(cal)}]" + + if prefix: + self.comments.insert(0, prefix) + + def clock(self) -> Optional[float]: + """ + Parses the first valid ``[%clk ...]`` annotation in the comment of + this node, if any. + + Returns the player's remaining time to the next time control after this + move, in seconds. + """ + match = CLOCK_REGEX.search(" ".join(self.comments)) + if match is None: + return None + return int(match.group("hours")) * 3600 + int(match.group("minutes")) * 60 + float(match.group("seconds")) + + def set_clock(self, seconds: Optional[float]) -> None: + """ + Replaces the first valid ``[%clk ...]`` annotation in the comment of + this node or adds a new one. + """ + clk = "" + if seconds is not None: + seconds = max(0, seconds) + hours = int(seconds // 3600) + minutes = int(seconds % 3600 // 60) + seconds = seconds % 3600 % 60 + seconds_part = f"{seconds:06.3f}".rstrip("0").rstrip(".") + clk = f"[%clk {hours:d}:{minutes:02d}:{seconds_part}]" + + self._replace_or_add_annotation(clk, CLOCK_REGEX) + + def emt(self) -> Optional[float]: + """ + Parses the first valid ``[%emt ...]`` annotation in the comment of + this node, if any. + + Returns the player's elapsed move time use for the comment of this + move, in seconds. + """ + match = EMT_REGEX.search(" ".join(self.comments)) + if match is None: + return None + return int(match.group("hours")) * 3600 + int(match.group("minutes")) * 60 + float(match.group("seconds")) + + def set_emt(self, seconds: Optional[float]) -> None: + """ + Replaces the first valid ``[%emt ...]`` annotation in the comment of + this node or adds a new one. + """ + emt = "" + if seconds is not None: + seconds = max(0, seconds) + hours = int(seconds // 3600) + minutes = int(seconds % 3600 // 60) + seconds = seconds % 3600 % 60 + seconds_part = f"{seconds:06.3f}".rstrip("0").rstrip(".") + emt = f"[%emt {hours:d}:{minutes:02d}:{seconds_part}]" + + self._replace_or_add_annotation(emt, EMT_REGEX) + + def _replace_or_add_annotation(self, text: str, regex: re.Pattern[str]) -> None: + found = 0 + for index in range(len(self.comments)): + self.comments[index], found = regex.subn(_condense_affix(text), self.comments[index], count=1) + if found: + break + + self.comments = list(filter(None, self.comments)) + + if not found and text: + self.comments.append(text) + + @abc.abstractmethod + def accept(self, visitor: BaseVisitor[ResultT]) -> ResultT: + """ + Traverses game nodes in PGN order using the given *visitor*. Starts with + the move leading to this node. Returns the *visitor* result. + """ + + def accept_subgame(self, visitor: BaseVisitor[ResultT]) -> ResultT: """ Traverses headers and game nodes in PGN order, as if the game was starting after this node. Returns the *visitor* result. """ if visitor.begin_game() is not SKIP: - game = self.root() + game = self.game() board = self.board() dummy_game = Game.without_tag_roster() @@ -380,74 +644,265 @@ def accept_subgame(self, visitor): visitor.visit_header(tagname, tagvalue) if visitor.end_headers() is not SKIP: + visitor.visit_board(board) + if self.variations: - self.variations[0].accept(visitor, _parent_board=board) + self.variations[0]._accept(board, visitor) visitor.visit_result(game.headers.get("Result", "*")) visitor.end_game() return visitor.result() - def __str__(self): + def __str__(self) -> str: return self.accept(StringExporter(columns=None)) - def __repr__(self): - return "".format( - hex(id(self)), - self.parent.board().fullmove_number, - "." if self.parent.board().turn == chess.WHITE else "...", - self.san()) +class ChildNode(GameNode): + """ + A child node of a game, with the move leading to it. + Extends :class:`~chess.pgn.GameNode`. + """ + + starting_comments: list[str] + """ + A comment for the start of a variation. Only nodes that + actually start a variation (:func:`~chess.pgn.GameNode.starts_variation()` + checks this) can have a starting comment. The root node can not have + a starting comment. + """ + + nags: Set[int] + """ + A set of NAGs as integers. NAGs always go behind a move, so the root + node of the game will never have NAGs. + """ + + def __init__(self, parent: GameNode, move: chess.Move, *, comment: Union[str, list[str]] = "", starting_comment: Union[str, list[str]] = "", nags: Iterable[int] = []) -> None: + super().__init__(comment=comment) + self._parent = parent + self._move = move + self.parent.variations.append(self) + + self.nags.update(nags) + self.starting_comments = _standardize_comments(starting_comment) + + @property + @override + def parent(self) -> GameNode: + """The parent node.""" + return self._parent + + @property + @override + def move(self) -> chess.Move: + """The move leading to this node.""" + return self._move + + @override + def board(self) -> chess.Board: + stack: List[chess.Move] = [] + node: GameNode = self + + while node.move is not None and node.parent is not None: + stack.append(node.move) + node = node.parent + + board = node.game().board() + + while stack: + board.push(stack.pop()) + + return board + + @override + def ply(self) -> int: + ply = 0 + node: GameNode = self + while node.parent is not None: + ply += 1 + node = node.parent + return node.game().ply() + ply + + def san(self) -> str: + """ + Gets the standard algebraic notation of the move leading to this node. + See :func:`chess.Board.san()`. + + Do not call this on the root node. + + Complexity is `O(n)`. + """ + return self.parent.board().san(self.move) + + def uci(self, *, chess960: Optional[bool] = None) -> str: + """ + Gets the UCI notation of the move leading to this node. + See :func:`chess.Board.uci()`. + + Do not call this on the root node. + + Complexity is `O(n)`. + """ + return self.parent.board().uci(self.move, chess960=chess960) + + @override + def end(self) -> ChildNode: + """ + Follows the main variation to the end and returns the last node. + + Complexity is `O(n)`. + """ + return typing.cast(ChildNode, super().end()) + + def _accept_node(self, parent_board: chess.Board, visitor: BaseVisitor[ResultT]) -> None: + if self.starting_comments: + visitor.visit_comment(self.starting_comments) + + visitor.visit_move(parent_board, self.move) + + parent_board.push(self.move) + visitor.visit_board(parent_board) + parent_board.pop() + + for nag in sorted(self.nags): + visitor.visit_nag(nag) + + if self.comments: + visitor.visit_comment(self.comments) + + def _accept(self, parent_board: chess.Board, visitor: BaseVisitor[ResultT], *, sidelines: bool = True) -> None: + stack = [_AcceptFrame(self, sidelines=sidelines)] + + while stack: + top = stack[-1] + + if top.in_variation: + top.in_variation = False + visitor.end_variation() + + if top.state == "pre": + top.node._accept_node(parent_board, visitor) + top.state = "variations" + elif top.state == "variations": + try: + variation = next(top.variations) + except StopIteration: + if top.node.variations: + parent_board.push(top.node.move) + stack.append(_AcceptFrame(top.node.variations[0], sidelines=True)) + top.state = "post" + else: + top.state = "end" + else: + if visitor.begin_variation() is not SKIP: + stack.append(_AcceptFrame(variation, sidelines=False, is_variation=True)) + top.in_variation = True + elif top.state == "post": + parent_board.pop() + top.state = "end" + else: + stack.pop() + + @override + def accept(self, visitor: BaseVisitor[ResultT]) -> ResultT: + self._accept(self.parent.board(), visitor, sidelines=False) + return visitor.result() + + def __repr__(self) -> str: + try: + parent_board = self.parent.board() + except ValueError: + return f"<{type(self).__name__} at {id(self):#x} (dangling: {self.move})>" + else: + return "<{} at {:#x} ({}{} {} ...)>".format( + type(self).__name__, + id(self), + parent_board.fullmove_number, + "." if parent_board.turn == chess.WHITE else "...", + parent_board.san(self.move)) + + +GameT = TypeVar("GameT", bound="Game") class Game(GameNode): """ The root node of a game with extra information such as headers and the - starting position. Also has all the other properties and methods of - :class:`~chess.pgn.GameNode`. + starting position. Extends :class:`~chess.pgn.GameNode`. + """ + + headers: Headers """ + A mapping of headers. By default, the following 7 headers are provided + (Seven Tag Roster): - def __init__(self, headers=None): + >>> import chess.pgn + >>> + >>> game = chess.pgn.Game() + >>> game.headers + Headers(Event='?', Site='?', Date='????.??.??', Round='?', White='?', Black='?', Result='*') + """ + + errors: List[Exception] + """ + A list of errors (such as illegal or ambiguous moves) encountered while + parsing the game. + """ + + def __init__(self, headers: Optional[Union[Mapping[str, str], Iterable[Tuple[str, str]]]] = None) -> None: super().__init__() self.headers = Headers(headers) self.errors = [] - def board(self, *, _cache=False): - """ - Gets the starting position of the game. + @property + @override + def parent(self) -> None: + return None - Unless the ``FEN`` header tag is set, this is the default starting - position (for the ``Variant``). - """ + @property + @override + def move(self) -> None: + return None + + @override + def board(self) -> chess.Board: return self.headers.board() - def setup(self, board): + @override + def ply(self) -> int: + # Optimization: Parse FEN only for custom starting positions. + return self.board().ply() if "FEN" in self.headers else 0 + + def setup(self, board: Union[chess.Board, str]) -> None: """ Sets up a specific starting position. This sets (or resets) the ``FEN``, ``SetUp``, and ``Variant`` header tags. """ try: - fen = board.fen() + fen = board.fen() # type: ignore + setup = typing.cast(chess.Board, board) except AttributeError: - board = chess.Board(board) - board.chess960 = board.has_chess960_castling_rights() - fen = board.fen() + setup = chess.Board(board) # type: ignore + setup.chess960 = setup.has_chess960_castling_rights() + fen = setup.fen() - if fen == type(board).starting_fen: - self.headers.pop("SetUp", None) + if fen == type(setup).starting_fen: self.headers.pop("FEN", None) + self.headers.pop("SetUp", None) else: - self.headers["SetUp"] = "1" self.headers["FEN"] = fen + self.headers["SetUp"] = "1" - if type(board).aliases[0] == "Standard" and board.chess960: + if type(setup).aliases[0] == "Standard" and setup.chess960: self.headers["Variant"] = "Chess960" - elif type(board).aliases[0] != "Standard": - self.headers["Variant"] = type(board).aliases[0] - self.headers["FEN"] = board.fen() + elif type(setup).aliases[0] != "Standard": + self.headers["Variant"] = type(setup).aliases[0] + self.headers["FEN"] = fen else: self.headers.pop("Variant", None) - def accept(self, visitor): + @override + def accept(self, visitor: BaseVisitor[ResultT]) -> ResultT: """ Traverses the game in PGN order using the given *visitor*. Returns the *visitor* result. @@ -456,24 +911,35 @@ def accept(self, visitor): for tagname, tagvalue in self.headers.items(): visitor.visit_header(tagname, tagvalue) if visitor.end_headers() is not SKIP: - if self.comment: - visitor.visit_comment(self.comment) + board = self.board() + visitor.visit_board(board) + + if self.comments: + visitor.visit_comment(self.comments) if self.variations: - self.variations[0].accept(visitor, _parent_board=self.board()) + self.variations[0]._accept(board, visitor) visitor.visit_result(self.headers.get("Result", "*")) visitor.end_game() return visitor.result() + def time_control(self) -> TimeControl: + """ + Returns the time control of the game. If the game has no time control + information, the default time control ('UNKNOWN') is returned. + """ + time_control_header = self.headers.get("TimeControl", "") + return parse_time_control(time_control_header) + @classmethod - def from_board(cls, board): + def from_board(cls: Type[GameT], board: chess.Board) -> GameT: """Creates a game from the move stack of a :class:`~chess.Board()`.""" # Setup the initial position. game = cls() game.setup(board.root()) - node = game + node: GameNode = game # Replay all moves. for move in board.move_stack: @@ -483,22 +949,31 @@ def from_board(cls, board): return game @classmethod - def without_tag_roster(cls): - """Creates an empty game without the default 7 tag roster.""" + def without_tag_roster(cls: Type[GameT]) -> GameT: + """Creates an empty game without the default Seven Tag Roster.""" return cls(headers={}) - def __repr__(self): - return "".format( - hex(id(self)), - repr(self.headers.get("White", "?")), - repr(self.headers.get("Black", "?")), - self.headers.get("Date", "????.??.??")) + @classmethod + def builder(cls: Type[GameT]) -> GameBuilder[GameT]: + return GameBuilder(Game=cls) + + def __repr__(self) -> str: + return "<{} at {:#x} ({!r} vs. {!r}, {!r} at {!r}{})>".format( + type(self).__name__, + id(self), + self.headers.get("White", "?"), + self.headers.get("Black", "?"), + self.headers.get("Date", "????.??.??"), + self.headers.get("Site", "?"), + f", {len(self.errors)} errors" if self.errors else "") -class Headers(collections.abc.MutableMapping): - def __init__(self, data=None, **kwargs): - self._tag_roster = {} - self._others = {} +HeadersT = TypeVar("HeadersT", bound="Headers") + +class Headers(MutableMapping[str, str]): + def __init__(self, data: Optional[Union[Mapping[str, str], Iterable[Tuple[str, str]]]] = None, **kwargs: str) -> None: + self._tag_roster: Dict[str, str] = {} + self._others: Dict[str, str] = {} if data is None: data = { @@ -513,7 +988,7 @@ def __init__(self, data=None, **kwargs): self.update(data, **kwargs) - def is_chess960(self): + def is_chess960(self) -> bool: return self.get("Variant", "").lower() in [ "chess960", "chess 960", @@ -522,88 +997,94 @@ def is_chess960(self): "fischer random", ] - def is_wild(self): + def is_wild(self) -> bool: # http://www.freechess.org/Help/HelpFiles/wild.html return self.get("Variant", "").lower() in [ "wild/0", "wild/1", "wild/2", "wild/3", "wild/4", "wild/5", "wild/6", "wild/7", "wild/8", "wild/8a"] - def variant(self): + def variant(self) -> Type[chess.Board]: if "Variant" not in self or self.is_chess960() or self.is_wild(): return chess.Board else: from chess.variant import find_variant return find_variant(self["Variant"]) - def board(self): + def board(self) -> chess.Board: VariantBoard = self.variant() fen = self.get("FEN", VariantBoard.starting_fen) board = VariantBoard(fen, chess960=self.is_chess960()) board.chess960 = board.chess960 or board.has_chess960_castling_rights() return board - def __setitem__(self, key, value): + def __setitem__(self, key: str, value: str) -> None: if key in TAG_ROSTER: self._tag_roster[key] = value elif not TAG_NAME_REGEX.match(key): - raise ValueError("non-alphanumeric pgn header tag: {}".format(repr(key))) + raise ValueError(f"invalid pgn header tag: {key!r}") elif "\n" in value or "\r" in value: - raise ValueError("line break in pgn header {}: {}".format(key, repr(value))) + raise ValueError(f"line break in pgn header {key}: {value!r}") else: self._others[key] = value - def __getitem__(self, key): - if key in TAG_ROSTER: - return self._tag_roster[key] - else: - return self._others[key] + def __getitem__(self, key: str) -> str: + return self._tag_roster[key] if key in TAG_ROSTER else self._others[key] - def __delitem__(self, key): + def __delitem__(self, key: str) -> None: if key in TAG_ROSTER: del self._tag_roster[key] else: del self._others[key] - def __iter__(self): + def __iter__(self) -> Iterator[str]: for key in TAG_ROSTER: if key in self._tag_roster: yield key - yield from sorted(self._others) + yield from self._others - def __len__(self): + def __len__(self) -> int: return len(self._tag_roster) + len(self._others) - def copy(self): + def copy(self) -> Self: return type(self)(self) - def __copy__(self): + def __copy__(self) -> Self: return self.copy() - def __repr__(self): + def __repr__(self) -> str: return "{}({})".format( type(self).__name__, - ", ".join("{}={}".format(key, repr(value)) for key, value in self.items())) + ", ".join("{}={!r}".format(key, value) for key, value in self.items())) + + @classmethod + def builder(cls: Type[HeadersT]) -> HeadersBuilder[HeadersT]: + return HeadersBuilder(Headers=cls) -class Mainline: - def __init__(self, start, f=lambda node: node): +MainlineMapT = TypeVar("MainlineMapT") + +class Mainline(Generic[MainlineMapT]): + def __init__(self, start: GameNode, f: Callable[[ChildNode], MainlineMapT]) -> None: self.start = start self.f = f - def __bool__(self): + def __bool__(self) -> bool: return bool(self.start.variations) - def __iter__(self): + def __iter__(self) -> Iterator[MainlineMapT]: node = self.start while node.variations: node = node.variations[0] yield self.f(node) - def __reversed__(self): - return ReverseMainline(self.start, self.f) + def __reversed__(self) -> Iterator[MainlineMapT]: + node = self.start.end() + while node.parent and node != self.start: + yield self.f(typing.cast(ChildNode, node)) + node = node.parent - def accept(self, visitor): + def accept(self, visitor: BaseVisitor[ResultT]) -> ResultT: node = self.start board = self.start.board() while node.variations: @@ -612,42 +1093,14 @@ def accept(self, visitor): board.push(node.move) return visitor.result() - def __str__(self): + def __str__(self) -> str: return self.accept(StringExporter(columns=None)) - def __repr__(self): - return "".format(hex(id(self)), self.accept(StringExporter(columns=None, comments=False))) - - -class ReverseMainline: - def __init__(self, stop, f=lambda node: node): - self.stop = stop - self.f = f - - self.length = 0 - node = stop - while node.variations: - node = node.variations[0] - self.length += 1 - self.end = node - - def __len__(self): - return self.length - - def __iter__(self): - node = self.end - while node.parent and node != self.stop: - yield self.f(node) - node = node.parent - - def __reversed__(self): - return Mainline(self.stop, self.f) - - def __repr__(self): - return "".format(hex(id(self)), " ".join(ReverseMainline(self.stop, lambda node: node.move.uci()))) + def __repr__(self) -> str: + return f"" -class BaseVisitor: +class BaseVisitor(abc.ABC, Generic[ResultT]): """ Base class for visitors. @@ -657,39 +1110,30 @@ class BaseVisitor: The methods are called in PGN order. """ - def begin_game(self): + def begin_game(self) -> Optional[SkipType]: """Called at the start of a game.""" pass - def begin_headers(self): + def begin_headers(self) -> Optional[Headers]: """Called before visiting game headers.""" pass - def visit_header(self, tagname, tagvalue): + def visit_header(self, tagname: str, tagvalue: str) -> None: """Called for each game header.""" pass - def end_headers(self): + def end_headers(self) -> Optional[SkipType]: """Called after visiting game headers.""" pass - def parse_san(self, board, san): + def begin_parse_san(self, board: chess.Board, san: str) -> Optional[SkipType]: """ - When the visitor is used by a parser, this is called to parse a move - in standard algebraic notation. - - You can override the default implementation to work around specific - quirks of your input format. + When the visitor is used by a parser, this is called at the start of + each standard algebraic notation detailing a move. """ - # Replace zeros with correct castling notation. - if san == "0-0": - san = "O-O" - elif san == "0-0-0": - san = "O-O-O" - - return board.parse_san(san) + pass - def visit_move(self, board, move): + def visit_move(self, board: chess.Board, move: chess.Move) -> None: """ Called for each move. @@ -698,154 +1142,242 @@ def visit_move(self, board, move): """ pass - def visit_comment(self, comment): + def visit_board(self, board: chess.Board) -> None: + """ + Called for the starting position of the game and after each move. + + The board state must be restored before the traversal continues. + """ + pass + + def visit_comment(self, comment: list[str]) -> None: """Called for each comment.""" pass - def visit_nag(self, nag): + def visit_nag(self, nag: int) -> None: """Called for each NAG.""" pass - def begin_variation(self): + def begin_variation(self) -> Optional[SkipType]: """ Called at the start of a new variation. It is not called for the mainline of the game. """ pass - def end_variation(self): + def end_variation(self) -> None: """Concludes a variation.""" pass - def visit_result(self, result): + def visit_result(self, result: str) -> None: """ Called at the end of a game with the value from the ``Result`` header. """ pass - def end_game(self): + def end_game(self) -> None: """Called at the end of a game.""" pass - def result(self): - """Called to get the result of the visitor. Defaults to ``True``.""" - return True + @abc.abstractmethod + def result(self) -> ResultT: + """Called to get the result of the visitor.""" - def handle_error(self, error): + def handle_error(self, error: Exception) -> None: """Called for encountered errors. Defaults to raising an exception.""" raise error -class GameModelCreator(BaseVisitor): +class GameBuilder(BaseVisitor[GameT]): """ Creates a game model. Default visitor for :func:`~chess.pgn.read_game()`. """ - def begin_game(self): - self.game = Game() + @typing.overload + def __init__(self: GameBuilder[Game]) -> None: ... + @typing.overload + def __init__(self, *, Game: Type[GameT]) -> None: ... + def __init__(self, *, Game: Any = Game) -> None: + self.Game = Game + + @override + def begin_game(self) -> None: + self.game: GameT = self.Game() - self.variation_stack = [self.game] - self.starting_comment = "" + self.variation_stack: List[GameNode] = [self.game] + self.starting_comments: list[str] = [] self.in_variation = False - def visit_header(self, tagname, tagvalue): + @override + def begin_headers(self) -> Headers: + return self.game.headers + + @override + def visit_header(self, tagname: str, tagvalue: str) -> None: self.game.headers[tagname] = tagvalue - def visit_nag(self, nag): + @override + def visit_nag(self, nag: int) -> None: self.variation_stack[-1].nags.add(nag) - def begin_variation(self): - self.variation_stack.append(self.variation_stack[-1].parent) + @override + def begin_variation(self) -> None: + parent = self.variation_stack[-1].parent + assert parent is not None, "begin_variation called, but root node on top of stack" + self.variation_stack.append(parent) self.in_variation = False - def end_variation(self): + @override + def end_variation(self) -> None: self.variation_stack.pop() - def visit_result(self, result): + @override + def visit_result(self, result: str) -> None: if self.game.headers.get("Result", "*") == "*": self.game.headers["Result"] = result - def visit_comment(self, comment): + @override + def visit_comment(self, comment: Union[str, list[str]]) -> None: + comments = _standardize_comments(comment) if self.in_variation or (self.variation_stack[-1].parent is None and self.variation_stack[-1].is_end()): # Add as a comment for the current node if in the middle of # a variation. Add as a comment for the game if the comment # starts before any move. - new_comment = [self.variation_stack[-1].comment, comment] - self.variation_stack[-1].comment = "\n".join(new_comment).strip() + self.variation_stack[-1].comments.extend(comments) + self.variation_stack[-1].comments = list(filter(None, self.variation_stack[-1].comments)) else: # Otherwise, it is a starting comment. - new_comment = [self.starting_comment, comment] - self.starting_comment = "\n".join(new_comment).strip() + self.starting_comments.extend(comments) + self.starting_comments = list(filter(None, self.starting_comments)) - def visit_move(self, board, move): + @override + def visit_move(self, board: chess.Board, move: chess.Move) -> None: self.variation_stack[-1] = self.variation_stack[-1].add_variation(move) - self.variation_stack[-1].starting_comment = self.starting_comment - self.starting_comment = "" + self.variation_stack[-1].starting_comments = self.starting_comments + self.starting_comments = [] self.in_variation = True - def handle_error(self, error): + @override + def handle_error(self, error: Exception) -> None: """ Populates :data:`chess.pgn.Game.errors` with encountered errors and logs them. + + You can silence the log and handle errors yourself after parsing: + + >>> import chess.pgn + >>> import logging + >>> + >>> logging.getLogger("chess.pgn").setLevel(logging.CRITICAL) + >>> + >>> pgn = open("data/pgn/kasparov-deep-blue-1997.pgn") + >>> + >>> game = chess.pgn.read_game(pgn) + >>> game.errors # List of exceptions + [] + + You can also override this method to hook into error handling: + + >>> import chess.pgn + >>> + >>> class MyGameBuilder(chess.pgn.GameBuilder): + >>> def handle_error(self, error: Exception) -> None: + >>> pass # Ignore error + >>> + >>> pgn = open("data/pgn/kasparov-deep-blue-1997.pgn") + >>> + >>> game = chess.pgn.read_game(pgn, Visitor=MyGameBuilder) """ - LOGGER.exception("error during pgn parsing") + LOGGER.error("%s while parsing %r", error, self.game) self.game.errors.append(error) - def result(self): + @override + def result(self) -> GameT: """ Returns the visited :class:`~chess.pgn.Game()`. """ return self.game -class HeaderCreator(BaseVisitor): +class HeadersBuilder(BaseVisitor[HeadersT]): """Collects headers into a dictionary.""" - def begin_headers(self): - self.headers = Headers({}) + @typing.overload + def __init__(self: HeadersBuilder[Headers]) -> None: ... + @typing.overload + def __init__(self, *, Headers: Type[HeadersT]) -> None: ... + def __init__(self, *, Headers: Any = Headers) -> None: + self.Headers = Headers + + @override + def begin_headers(self) -> HeadersT: + self.headers: HeadersT = self.Headers({}) + return self.headers - def visit_header(self, tagname, tagvalue): + @override + def visit_header(self, tagname: str, tagvalue: str) -> None: self.headers[tagname] = tagvalue - def end_headers(self): + @override + def end_headers(self) -> SkipType: return SKIP - def result(self): + @override + def result(self) -> HeadersT: return self.headers -class SkipVisitor(BaseVisitor): - """Skips a game.""" +class BoardBuilder(BaseVisitor[chess.Board]): + """ + Returns the final position of the game. The mainline of the game is + on the move stack. + """ - def begin_game(self): - return SKIP + @override + def begin_game(self) -> None: + self.skip_variation_depth = 0 - def end_headers(self): + @override + def begin_variation(self) -> SkipType: + self.skip_variation_depth += 1 return SKIP - def begin_variation(self): - return SKIP + @override + def end_variation(self) -> None: + self.skip_variation_depth = max(self.skip_variation_depth - 1, 0) + @override + def visit_board(self, board: chess.Board) -> None: + if not self.skip_variation_depth: + self.board = board -class StringExporter(BaseVisitor): - """ - Allows exporting a game as a string. + @override + def result(self) -> chess.Board: + return self.board - >>> import chess.pgn - >>> - >>> game = chess.pgn.Game() - >>> - >>> exporter = chess.pgn.StringExporter(headers=True, variations=True, comments=True) - >>> pgn_string = game.accept(exporter) - Only *columns* characters are written per line. If *columns* is ``None``, - then the entire movetext will be on a single line. This does not affect - header tags and comments. +class SkipVisitor(BaseVisitor[Literal[True]]): + """Skips a game.""" - There will be no newline characters at the end of the string. - """ + @override + def begin_game(self) -> SkipType: + return SKIP + + @override + def end_headers(self) -> SkipType: + return SKIP + + @override + def begin_variation(self) -> SkipType: + return SKIP + + @override + def result(self) -> Literal[True]: + return True - def __init__(self, *, columns=80, headers=True, comments=True, variations=True): + +class StringExporterMixin: + def __init__(self, *, columns: Optional[int] = 80, headers: bool = True, comments: bool = True, variations: bool = True): self.columns = columns self.headers = headers self.comments = comments @@ -855,65 +1387,71 @@ def __init__(self, *, columns=80, headers=True, comments=True, variations=True): self.force_movenumber = True - self.lines = [] + self.lines: List[str] = [] self.current_line = "" self.variation_depth = 0 - def flush_current_line(self): + def flush_current_line(self) -> None: if self.current_line: self.lines.append(self.current_line.rstrip()) self.current_line = "" - def write_token(self, token): + def write_token(self, token: str) -> None: if self.columns is not None and self.columns - len(self.current_line) < len(token): self.flush_current_line() self.current_line += token - def write_line(self, line=""): + def write_line(self, line: str = "") -> None: self.flush_current_line() self.lines.append(line.rstrip()) - def end_game(self): + def end_game(self) -> None: self.write_line() - def begin_headers(self): + def begin_headers(self) -> None: self.found_headers = False - def visit_header(self, tagname, tagvalue): + def visit_header(self, tagname: str, tagvalue: str) -> None: if self.headers: self.found_headers = True - self.write_line("[{} \"{}\"]".format(tagname, tagvalue)) + self.write_line(f"[{tagname} \"{tagvalue}\"]") - def end_headers(self): + def end_headers(self) -> None: if self.found_headers: self.write_line() - def begin_variation(self): + def begin_variation(self) -> Optional[SkipType]: self.variation_depth += 1 if self.variations: self.write_token("( ") self.force_movenumber = True + return None + else: + return SKIP - def end_variation(self): + def end_variation(self) -> None: self.variation_depth -= 1 if self.variations: self.write_token(") ") self.force_movenumber = True - else: - return SKIP - def visit_comment(self, comment): + def visit_comment(self, comment: Union[str, list[str]]) -> None: if self.comments and (self.variations or not self.variation_depth): - self.write_token("{ " + comment.replace("}", "").strip() + " } ") + def pgn_format(comments: list[str]) -> str: + edit = map(lambda s: s.replace("{", "").replace("}", ""), comments) + return " ".join(f"{{ {comment} }}" for comment in edit if comment) + + comments = _standardize_comments(comment) + self.write_token(pgn_format(comments) + " ") self.force_movenumber = True - def visit_nag(self, nag): + def visit_nag(self, nag: int) -> None: if self.comments and (self.variations or not self.variation_depth): self.write_token("$" + str(nag) + " ") - def visit_move(self, board, move): + def visit_move(self, board: chess.Board, move: chess.Move) -> None: if self.variations or not self.variation_depth: # Write the move number. if board.turn == chess.WHITE: @@ -926,20 +1464,40 @@ def visit_move(self, board, move): self.force_movenumber = False - def visit_result(self, result): + def visit_result(self, result: str) -> None: self.write_token(result + " ") - def result(self): + +class StringExporter(StringExporterMixin, BaseVisitor[str]): + """ + Allows exporting a game as a string. + + >>> import chess.pgn + >>> + >>> game = chess.pgn.Game() + >>> + >>> exporter = chess.pgn.StringExporter(headers=True, variations=True, comments=True) + >>> pgn_string = game.accept(exporter) + + Only *columns* characters are written per line. If *columns* is ``None``, + then the entire movetext will be on a single line. This does not affect + header tags and comments. + + There will be no newline characters at the end of the string. + """ + + @override + def result(self) -> str: if self.current_line: return "\n".join(itertools.chain(self.lines, [self.current_line.rstrip()])).rstrip() else: return "\n".join(self.lines).rstrip() - def __str__(self): + def __str__(self) -> str: return self.result() -class FileExporter(StringExporter): +class FileExporter(StringExporterMixin, BaseVisitor[int]): """ Acts like a :class:`~chess.pgn.StringExporter`, but games are written directly into a text file. @@ -956,32 +1514,42 @@ class FileExporter(StringExporter): >>> game.accept(exporter) """ - def __init__(self, handle, *, columns=80, headers=True, comments=True, variations=True): + def __init__(self, handle: TextIO, *, columns: Optional[int] = 80, headers: bool = True, comments: bool = True, variations: bool = True): super().__init__(columns=columns, headers=headers, comments=comments, variations=variations) self.handle = handle - def flush_current_line(self): + @override + def begin_game(self) -> None: + self.written: int = 0 + super().begin_game() + + def flush_current_line(self) -> None: if self.current_line: - self.handle.write(self.current_line.rstrip()) - self.handle.write("\n") + self.written += self.handle.write(self.current_line.rstrip()) + self.written += self.handle.write("\n") self.current_line = "" - def write_line(self, line=""): + def write_line(self, line: str = "") -> None: self.flush_current_line() - self.handle.write(line.rstrip()) - self.handle.write("\n") + self.written += self.handle.write(line.rstrip()) + self.written += self.handle.write("\n") - def result(self): - return None + @override + def result(self) -> int: + return self.written - def __repr__(self): - return "".format(hex(id(self))) + def __repr__(self) -> str: + return f"" - def __str__(self): + def __str__(self) -> str: return self.__repr__() -def read_game(handle, *, Visitor=GameModelCreator): +@typing.overload +def read_game(handle: TextIO) -> Optional[Game]: ... +@typing.overload +def read_game(handle: TextIO, *, Visitor: Callable[[], BaseVisitor[ResultT]]) -> Optional[ResultT]: ... +def read_game(handle: TextIO, *, Visitor: Any = GameBuilder) -> Any: """ Reads a game from a file opened in text mode. @@ -1005,10 +1573,11 @@ def read_game(handle, *, Visitor=GameModelCreator): By using text mode, the parser does not need to handle encodings. It is the caller's responsibility to open the file with the correct encoding. - PGN files are ASCII or UTF-8 most of the time. So, the following should - cover most relevant cases (ASCII, UTF-8, UTF-8 with BOM). + PGN files are usually ASCII or UTF-8 encoded, sometimes with BOM (which + this parser automatically ignores). See :func:`open` for options to + deal with encoding errors. - >>> pgn = open("data/pgn/kasparov-deep-blue-1997.pgn", encoding="utf-8-sig") + >>> pgn = open("data/pgn/kasparov-deep-blue-1997.pgn", encoding="utf-8") Use :class:`~io.StringIO` to parse games from a string. @@ -1018,16 +1587,16 @@ def read_game(handle, *, Visitor=GameModelCreator): >>> game = chess.pgn.read_game(pgn) The end of a game is determined by a completely blank line or the end of - the file. (Of course, blank lines in comments are possible.) + the file. (Of course, blank lines in comments are possible). - According to the PGN standard, at least the usual 7 header tags are + According to the PGN standard, at least the usual seven header tags are required for a valid game. This parser also handles games without any headers just fine. The parser is relatively forgiving when it comes to errors. It skips over - tokens it can not parse. Any exceptions are logged and collected in - :data:`Game.errors `. This behavior can be - :func:`overriden `. + tokens it can not parse. By default, any exceptions are logged and + collected in :data:`Game.errors `. This behavior can + be :func:`overridden `. Returns the parsed game or ``None`` if the end of file is reached. """ @@ -1035,50 +1604,83 @@ def read_game(handle, *, Visitor=GameModelCreator): found_game = False skipping_game = False - headers = None + managed_headers: Optional[Headers] = None + unmanaged_headers: Optional[Headers] = None + board_stack: List[chess.Board] = [] - # Skip leading empty lines and comments. - line = handle.readline() + # Ignore leading empty lines and comments. + line = handle.readline().lstrip("\ufeff") while line.isspace() or line.startswith("%") or line.startswith(";"): line = handle.readline() # Parse game headers. + consecutive_empty_lines = 0 while line: - # Skip comments. + # Ignore comments. if line.startswith("%") or line.startswith(";"): line = handle.readline() continue + # Ignore up to one consecutive empty line between headers. + if consecutive_empty_lines < 1 and line.isspace(): + consecutive_empty_lines += 1 + line = handle.readline() + continue + + # First token of the game. if not found_game: found_game = True skipping_game = visitor.begin_game() is SKIP + if not skipping_game: + managed_headers = visitor.begin_headers() + if not isinstance(managed_headers, Headers): + unmanaged_headers = Headers({}) if not line.startswith("["): break + consecutive_empty_lines = 0 + if not skipping_game: tag_match = TAG_REGEX.match(line) if tag_match: - if headers is None: - headers = Headers({}) - visitor.begin_headers() - - headers[tag_match.group(1)] = tag_match.group(2) visitor.visit_header(tag_match.group(1), tag_match.group(2)) + if unmanaged_headers is not None: + unmanaged_headers[tag_match.group(1)] = tag_match.group(2) else: - break + # Ignore invalid or malformed headers. + line = handle.readline() + continue line = handle.readline() if not found_game: return None - if headers is not None: + if not skipping_game: skipping_game = visitor.end_headers() is SKIP - # Skip a single empty line after headers. - if line.isspace(): - line = handle.readline() + if not skipping_game: + # Chess variant. + headers = managed_headers if unmanaged_headers is None else unmanaged_headers + assert headers is not None, "got neither managed nor unmanaged headers" + try: + VariantBoard = headers.variant() + except ValueError as error: + visitor.handle_error(error) + VariantBoard = chess.Board + + # Initial position. + fen = headers.get("FEN", VariantBoard.starting_fen) + try: + board = VariantBoard(fen, chess960=headers.is_chess960()) + except ValueError as error: + visitor.handle_error(error) + skipping_game = True + else: + board.chess960 = board.chess960 or board.has_chess960_castling_rights() + board_stack = [board] + visitor.visit_board(board) # Fast path: Skip entire game. if skipping_game: @@ -1106,91 +1708,72 @@ def read_game(handle, *, Visitor=GameModelCreator): visitor.end_game() return visitor.result() - # Chess variant and initial position. - if headers is None: - headers = Headers({}) - - try: - VariantBoard = headers.variant() - except ValueError as error: - visitor.handle_error(error) - VariantBoard = chess.Board - - fen = headers.get("FEN", VariantBoard.starting_fen) - try: - board_stack = [VariantBoard(fen, chess960=headers.is_chess960())] - except ValueError as error: - visitor.handle_error(error) - board_stack = [VariantBoard(chess960=headers.is_chess960())] - # Parse movetext. skip_variation_depth = 0 + fresh_line = True while line: - read_next_line = True - - if line.startswith("%") or line.startswith(";"): + if fresh_line: # Ignore comments. - line = handle.readline() - continue - - # An empty line means the end of a game. - if line.isspace(): - visitor.end_game() - return visitor.result() + if line.startswith("%") or line.startswith(";"): + line = handle.readline() + continue + # An empty line means the end of a game. + if line.isspace(): + visitor.end_game() + return visitor.result() + fresh_line = True for match in MOVETEXT_REGEX.finditer(line): token = match.group(0) if token.startswith("{"): # Consume until the end of the comment. - line = token[1:] + start_index = 2 if token.startswith("{ ") else 1 + line = token[start_index:] + comment_lines = [] while line and "}" not in line: - comment_lines.append(line.rstrip()) + comment_lines.append(line) line = handle.readline() - end_index = line.find("}") - comment_lines.append(line[:end_index]) - if "}" in line: - line = line[end_index:] - else: - line = "" + + if line: + close_index = line.find("}") + end_index = close_index - 1 if close_index > 0 and line[close_index - 1] == " " else close_index + comment_lines.append(line[:end_index]) + line = line[close_index + 1:] if not skip_variation_depth: - visitor.visit_comment("\n".join(comment_lines).strip()) + visitor.visit_comment("".join(comment_lines)) - # Continue with the current or the next line. - if line: - read_next_line = False + # Continue with the current line. + fresh_line = False break - elif token == "(" and board_stack[-1].move_stack: + elif token == "(": if skip_variation_depth: skip_variation_depth += 1 - elif visitor.begin_variation() is SKIP: - skip_variation_depth = 1 - else: - board = board_stack[-1].copy() - board.pop() - board_stack.append(board) - elif token == ")" and skip_variation_depth: - skip_variation_depth -= 1 - if not skip_variation_depth: + elif board_stack[-1].move_stack: + if visitor.begin_variation() is SKIP: + skip_variation_depth = 1 + else: + board = board_stack[-1].copy() + board.pop() + board_stack.append(board) + elif token == ")": + if skip_variation_depth == 1: + skip_variation_depth = 0 visitor.end_variation() - elif token == ")" and len(board_stack) > 1: - # Always leave at least the root node on the stack. - visitor.end_variation() - board_stack.pop() + elif skip_variation_depth: + skip_variation_depth -= 1 + elif len(board_stack) > 1: + visitor.end_variation() + board_stack.pop() elif skip_variation_depth: continue elif token.startswith(";"): break elif token.startswith("$"): # Found a NAG. - try: - nag = int(token[1:]) - except ValueError as error: - visitor.handle_error(error) - else: - visitor.visit_nag(nag) + visitor.visit_nag(int(token[1:])) elif token == "?": visitor.visit_nag(NAG_MISTAKE) elif token == "??": @@ -1207,24 +1790,28 @@ def read_game(handle, *, Visitor=GameModelCreator): visitor.visit_result(token) else: # Parse SAN tokens. - try: - move = visitor.parse_san(board_stack[-1], token) - except ValueError as error: - visitor.handle_error(error) - else: - visitor.visit_move(board_stack[-1], move) - board_stack[-1].push(move) - - if read_next_line: + if visitor.begin_parse_san(board_stack[-1], token) is not SKIP: + try: + move = board_stack[-1].parse_san(token) + except ValueError as error: + visitor.handle_error(error) + skip_variation_depth = 1 + else: + visitor.visit_move(board_stack[-1], move) + board_stack[-1].push(move) + visitor.visit_board(board_stack[-1]) + + if fresh_line: line = handle.readline() visitor.end_game() return visitor.result() -def read_headers(handle): +def read_headers(handle: TextIO) -> Optional[Headers]: """ - Reads game headers from a PGN file opened in text mode. + Reads game headers from a PGN file opened in text mode. Skips the rest of + the game. Since actually parsing many games from a big file is relatively expensive, this is a better way to look only for specific games and then seek and @@ -1248,7 +1835,7 @@ def read_headers(handle): ... if "Kasparov" in headers.get("White", "?"): ... kasparov_offsets.append(offset) - Then it can later be seeked an parsed. + Then it can later be seeked and parsed. >>> for offset in kasparov_offsets: ... pgn.seek(offset) @@ -1260,11 +1847,69 @@ def read_headers(handle): 3067 """ - return read_game(handle, Visitor=HeaderCreator) + return read_game(handle, Visitor=HeadersBuilder) -def skip_game(handle): +def skip_game(handle: TextIO) -> bool: """ - Skip a game. Returns ``True`` if a game was found and skipped. + Skips a game. Returns ``True`` if a game was found and skipped. """ - return read_game(handle, Visitor=SkipVisitor) + return bool(read_game(handle, Visitor=SkipVisitor)) + + +def parse_time_control(time_control: str) -> TimeControl: + tc = TimeControl() + + if not time_control: + return tc + + if time_control.startswith("?"): + return tc + + if time_control.startswith("-"): + tc.type = TimeControlType.UNLIMITED + return tc + + def _parse_part(part: str) -> TimeControlPart: + tcp = TimeControlPart() + + moves_time, *bonus = part.split("+") + + if bonus: + _bonus = bonus[0] + if _bonus.lower().endswith("d"): + tcp.delay = float(_bonus[:-1]) + else: + tcp.increment = float(_bonus) + + moves, *time = moves_time.split("/") + if time: + tcp.moves = int(moves) + tcp.time = int(time[0]) + else: + tcp.moves = 0 + tcp.time = int(moves) + + return tcp + + tc.parts = [_parse_part(part) for part in time_control.split(":")] + + if len(tc.parts) > 1: + for part in tc.parts[:-1]: + if part.moves == 0: + raise ValueError("Only last part can be 'sudden death'.") + + # Classification according to https://www.fide.com/FIDE/handbook/LawsOfChess.pdf + # (Bullet added) + base_time = tc.parts[0].time + increment = tc.parts[0].increment + if (base_time + 60 * increment) < 3 * 60: + tc.type = TimeControlType.BULLET + elif (base_time + 60 * increment) < 15 * 60: + tc.type = TimeControlType.BLITZ + elif (base_time + 60 * increment) < 60 * 60: + tc.type = TimeControlType.RAPID + else: + tc.type = TimeControlType.STANDARD + + return tc diff --git a/chess/polyglot.py b/chess/polyglot.py index 69d3dec74..a7d6807c4 100644 --- a/chess/polyglot.py +++ b/chess/polyglot.py @@ -1,27 +1,17 @@ -# -*- coding: utf-8 -*- -# -# This file is part of the python-chess library. -# Copyright (C) 2012-2018 Niklas Fiekas -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . +from __future__ import annotations import chess -import collections import struct import os import mmap import random +import typing + +from types import TracebackType +from typing import Callable, Container, Iterator, List, NamedTuple, Optional, Type, Union + + +StrOrBytesPath = Union[str, bytes, "os.PathLike[str]", "os.PathLike[bytes]"] ENTRY_STRUCT = struct.Struct(">QHHI") @@ -228,21 +218,21 @@ class ZobristHasher: - def __init__(self, array): + def __init__(self, array: List[int]) -> None: assert len(array) >= 781 self.array = array - def hash_board(self, board): + def hash_board(self, board: chess.BaseBoard) -> int: zobrist_hash = 0 for pivot, squares in enumerate(board.occupied_co): for square in chess.scan_reversed(squares): - piece_index = (board.piece_type_at(square) - 1) * 2 + pivot + piece_index = (typing.cast(chess.PieceType, board.piece_type_at(square)) - 1) * 2 + pivot zobrist_hash ^= self.array[64 * piece_index + square] return zobrist_hash - def hash_castling(self, board): + def hash_castling(self, board: chess.Board) -> int: zobrist_hash = 0 # Hash in the castling flags. @@ -257,7 +247,7 @@ def hash_castling(self, board): return zobrist_hash - def hash_ep_square(self, board): + def hash_ep_square(self, board: chess.Board) -> int: # Hash in the en passant file. if board.ep_square: # But only if there's actually a pawn ready to capture it. Legality @@ -272,16 +262,16 @@ def hash_ep_square(self, board): return self.array[772 + chess.square_file(board.ep_square)] return 0 - def hash_turn(self, board): + def hash_turn(self, board: chess.Board) -> int: # Hash in the turn. return self.array[780] if board.turn == chess.WHITE else 0 - def __call__(self, board): + def __call__(self, board: chess.Board) -> int: return (self.hash_board(board) ^ self.hash_castling(board) ^ self.hash_ep_square(board) ^ self.hash_turn(board)) -def zobrist_hash(board, *, _hasher=ZobristHasher(POLYGLOT_RANDOM_ARRAY)): +def zobrist_hash(board: chess.Board, *, _hasher: Callable[[chess.Board], int] = ZobristHasher(POLYGLOT_RANDOM_ARRAY)) -> int: """ Calculates the Polyglot Zobrist hash of the position. @@ -293,86 +283,105 @@ def zobrist_hash(board, *, _hasher=ZobristHasher(POLYGLOT_RANDOM_ARRAY)): return _hasher(board) -class Entry(collections.namedtuple("Entry", "key raw_move weight learn")): +class Entry(NamedTuple): """An entry from a Polyglot opening book.""" - __slots__ = () + key: int + """The Zobrist hash of the position.""" - def move(self, *, chess960=False): - """Gets the move (as a :class:`~chess.Move` object).""" - # Extract source and target square. - to_square = self.raw_move & 0x3f - from_square = (self.raw_move >> 6) & 0x3f + raw_move: int + """ + The raw binary representation of the move. Use + :data:`~chess.polyglot.Entry.move` instead. + """ - # Extract the promotion type. - promotion_part = (self.raw_move >> 12) & 0x7 - promotion = promotion_part + 1 if promotion_part else None + weight: int + """An integer value that can be used as the weight for this entry.""" - # Convert castling moves. - if not chess960 and not promotion: - if from_square == chess.E1: - if to_square == chess.H1: - return chess.Move(chess.E1, chess.G1) - elif to_square == chess.A1: - return chess.Move(chess.E1, chess.C1) - elif from_square == chess.E8: - if to_square == chess.H8: - return chess.Move(chess.E8, chess.G8) - elif to_square == chess.A8: - return chess.Move(chess.E8, chess.C8) - - if promotion and from_square == to_square: - return chess.Move(from_square, to_square, drop=promotion) - else: - return chess.Move(from_square, to_square, promotion) + learn: int + """Another integer value that can be used for extra information.""" + + move: chess.Move + """The :class:`~chess.Move`.""" + + +class _EmptyMmap(bytearray): + def size(self) -> int: + return 0 + + def close(self) -> None: + pass + + def madvise(self, option: int) -> None: + pass + + +def _randint(rng: Optional[random.Random], a: int, b: int) -> int: + return random.randint(a, b) if rng is None else rng.randint(a, b) class MemoryMappedReader: """Maps a Polyglot opening book to memory.""" - def __init__(self, filename): - self.fd = os.open(filename, os.O_RDONLY | os.O_BINARY if hasattr(os, "O_BINARY") else os.O_RDONLY) + def __init__(self, filename: StrOrBytesPath) -> None: + fd = os.open(filename, os.O_RDONLY | getattr(os, "O_BINARY", 0)) + try: + self.mmap: Union[mmap.mmap, _EmptyMmap] = mmap.mmap(fd, 0, access=mmap.ACCESS_READ) + except (ValueError, OSError): + self.mmap = _EmptyMmap() # Workaround for empty opening books. + finally: + os.close(fd) + + if self.mmap.size() % ENTRY_STRUCT.size != 0: + raise IOError(f"invalid file size: ensure {filename!r} is a valid polyglot opening book") try: - self.mmap = mmap.mmap(self.fd, 0, access=mmap.ACCESS_READ) - except (ValueError, mmap.error): - # Can not memory map empty opening books. - self.mmap = None + # Unix + self.mmap.madvise(mmap.MADV_RANDOM) + except AttributeError: + pass - def __enter__(self): + def __enter__(self) -> MemoryMappedReader: return self - def __exit__(self, exc_type, exc_value, traceback): + def __exit__(self, exc_type: Optional[Type[BaseException]], exc_value: Optional[BaseException], traceback: Optional[TracebackType]) -> None: return self.close() - def __len__(self): - if self.mmap is None: - return 0 - else: - return self.mmap.size() // ENTRY_STRUCT.size - - def __getitem__(self, key): - if self.mmap is None: - raise IndexError() + def __len__(self) -> int: + return self.mmap.size() // ENTRY_STRUCT.size - if key < 0: - key = len(self) + key + def __getitem__(self, index: int) -> Entry: + if index < 0: + index = len(self) + index try: - key, raw_move, weight, learn = ENTRY_STRUCT.unpack_from(self.mmap, key * ENTRY_STRUCT.size) + key, raw_move, weight, learn = ENTRY_STRUCT.unpack_from(self.mmap, index * ENTRY_STRUCT.size) except struct.error: raise IndexError() - return Entry(key, raw_move, weight, learn) + # Extract source and target square. + to_square = raw_move & 0x3f + from_square = (raw_move >> 6) & 0x3f - def __iter__(self): - i = 0 - size = len(self) - while i < size: + # Extract the promotion type. + promotion_part = (raw_move >> 12) & 0x7 + promotion = promotion_part + 1 if promotion_part else None + + # Piece drop. + if from_square == to_square: + promotion, drop = None, promotion + else: + drop = None + + # Entry with move (not normalized). + move = chess.Move(from_square, to_square, promotion, drop) + return Entry(key, raw_move, weight, learn, move) + + def __iter__(self) -> Iterator[Entry]: + for i in range(len(self)): yield self[i] - i += 1 - def bisect_key_left(self, key): + def bisect_key_left(self, key: int) -> int: lo = 0 hi = len(self) @@ -386,16 +395,17 @@ def bisect_key_left(self, key): return lo - def __contains__(self, entry): + def __contains__(self, entry: Entry) -> bool: return any(current == entry for current in self.find_all(entry.key, minimum_weight=entry.weight)) - def find_all(self, board, *, minimum_weight=1, exclude_moves=()): + def find_all(self, board: Union[chess.Board, int], *, minimum_weight: int = 1, exclude_moves: Container[chess.Move] = []) -> Iterator[Entry]: """Seeks a specific position and yields corresponding entries.""" try: - key = int(board) - board = None + key = int(board) # type: ignore + context: Optional[chess.Board] = None except (TypeError, ValueError): - key = zobrist_hash(board) + context = typing.cast(chess.Board, board) + key = zobrist_hash(context) i = self.bisect_key_left(key) size = len(self) @@ -410,20 +420,19 @@ def find_all(self, board, *, minimum_weight=1, exclude_moves=()): if entry.weight < minimum_weight: continue - if board: - move = entry.move(chess960=board.chess960) - elif exclude_moves: - move = entry.move() + if context: + move = context._from_chess960(context.chess960, entry.move.from_square, entry.move.to_square, entry.move.promotion, entry.move.drop) + entry = Entry(entry.key, entry.raw_move, entry.weight, entry.learn, move) - if exclude_moves and move in exclude_moves: + if exclude_moves and entry.move in exclude_moves: continue - if board and not board.is_legal(move): + if context and not context.is_legal(entry.move): continue yield entry - def find(self, board, *, minimum_weight=1, exclude_moves=()): + def find(self, board: Union[chess.Board, int], *, minimum_weight: int = 1, exclude_moves: Container[chess.Move] = []) -> Entry: """ Finds the main entry for the given position or Zobrist hash. @@ -442,13 +451,13 @@ def find(self, board, *, minimum_weight=1, exclude_moves=()): except ValueError: raise IndexError() - def get(self, board, default=None, *, minimum_weight=1, exclude_moves=()): + def get(self, board: Union[chess.Board, int], default: Optional[Entry] = None, *, minimum_weight: int = 1, exclude_moves: Container[chess.Move] = []) -> Optional[Entry]: try: return self.find(board, minimum_weight=minimum_weight, exclude_moves=exclude_moves) except IndexError: return default - def choice(self, board, *, minimum_weight=1, exclude_moves=(), random=random): + def choice(self, board: Union[chess.Board, int], *, minimum_weight: int = 1, exclude_moves: Container[chess.Move] = [], random: Optional[random.Random] = None) -> Entry: """ Uniformly selects a random entry for the given position. @@ -457,7 +466,7 @@ def choice(self, board, *, minimum_weight=1, exclude_moves=(), random=random): chosen_entry = None for i, entry in enumerate(self.find_all(board, minimum_weight=minimum_weight, exclude_moves=exclude_moves)): - if chosen_entry is None or random.randint(0, i) == i: + if chosen_entry is None or _randint(random, 0, i) == i: chosen_entry = entry if chosen_entry is None: @@ -465,7 +474,7 @@ def choice(self, board, *, minimum_weight=1, exclude_moves=(), random=random): return chosen_entry - def weighted_choice(self, board, *, exclude_moves=(), random=random): + def weighted_choice(self, board: Union[chess.Board, int], *, exclude_moves: Container[chess.Move] = [], random: Optional[random.Random] = None) -> Entry: """ Selects a random entry for the given position, distributed by the weights of the entries. @@ -476,7 +485,7 @@ def weighted_choice(self, board, *, exclude_moves=(), random=random): if not total_weights: raise IndexError() - choice = random.randint(0, total_weights - 1) + choice = _randint(random, 0, total_weights - 1) current_sum = 0 for entry in self.find_all(board, exclude_moves=exclude_moves): @@ -486,18 +495,12 @@ def weighted_choice(self, board, *, exclude_moves=(), random=random): assert False - def close(self): + def close(self) -> None: """Closes the reader.""" - if self.mmap is not None: - self.mmap.close() - - try: - os.close(self.fd) - except OSError: - pass + self.mmap.close() -def open_reader(path): +def open_reader(path: StrOrBytesPath) -> MemoryMappedReader: """ Creates a reader for the file at the given path. @@ -511,7 +514,7 @@ def open_reader(path): >>> >>> with chess.polyglot.open_reader("data/polyglot/performance.bin") as reader: ... for entry in reader.find_all(board): - ... print(entry.move(), entry.weight, entry.learn) + ... print(entry.move, entry.weight, entry.learn) e2e4 1 0 d2d4 1 0 c2c4 1 0 diff --git a/chess/py.typed b/chess/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/chess/svg.py b/chess/svg.py index 14e3d9b28..7e8facf99 100644 --- a/chess/svg.py +++ b/chess/svg.py @@ -1,82 +1,158 @@ -# -*- coding: utf-8 -*- -# -# This file is part of the python-chess library. -# Copyright (C) 2016-2018 Niklas Fiekas -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -# Piece vector graphics are copyright (C) Colin M.L. Burnett -# and also licensed under the -# GNU General Public License. +from __future__ import annotations -import chess -import collections import math - import xml.etree.ElementTree as ET +import chess + +from typing import Dict, Iterable, Optional, Tuple, Union +from chess import Color, IntoSquareSet, Square + SQUARE_SIZE = 45 MARGIN = 20 PIECES = { - "b": """""", - "k": """""", - "n": """""", - "p": """""", - "q": """""", - "r": """""", - "B": """""", - "K": """""", - "N": """""", - "P": """""", - "Q": """""", - "R": """""" + "b": """""", # noqa: E501 + "k": """""", # noqa: E501 + "n": """""", # noqa: E501 + "p": """""", # noqa: E501 + "q": """""", # noqa: E501 + "r": """""", # noqa: E501 + "B": """""", # noqa: E501 + "K": """""", # noqa: E501 + "N": """""", # noqa: E501 + "P": """""", # noqa: E501 + "Q": """""", # noqa: E501 + "R": """""", # noqa: E501 +} + +COORDS = { + "1": """""", # noqa: E501 + "2": """""", # noqa: E501 + "3": """""", # noqa: E501 + "4": """""", # noqa: E501 + "5": """""", # noqa: E501 + "6": """""", # noqa: E501 + "7": """""", # noqa: E501 + "8": """""", # noqa: E501 + "a": """""", # noqa: E501 + "b": """""", # noqa: E501 + "c": """""", # noqa: E501 + "d": """""", # noqa: E501 + "e": """""", # noqa: E501 + "f": """""", # noqa: E501 + "g": """""", # noqa: E501 + "h": """""", # noqa: E501 } -XX = """""" +XX = """""" # noqa: E501 -CHECK_GRADIENT = """""" +CHECK_GRADIENT = """""" # noqa: E501 DEFAULT_COLORS = { "square light": "#ffce9e", "square dark": "#d18b47", "square dark lastmove": "#aaa23b", "square light lastmove": "#cdd16a", + "margin": "#212121", + "inner border": "#111", + "outer border": "#111", + "coord": "#e5e5e5", + "arrow green": "#15781B80", + "arrow red": "#88202080", + "arrow yellow": "#e68f00b3", + "arrow blue": "#00308880", } class Arrow: """Details of an arrow to be drawn.""" - def __init__(self, tail, head, *, color="#888"): + tail: Square + """Start square of the arrow.""" + + head: Square + """End square of the arrow.""" + + color: str + """Arrow color.""" + + def __init__(self, tail: Square, head: Square, *, color: str = "green") -> None: self.tail = tail self.head = head self.color = color + def pgn(self) -> str: + """ + Returns the arrow in the format used by ``[%csl ...]`` and + ``[%cal ...]`` PGN annotations, e.g., ``Ga1`` or ``Ya2h2``. + + Colors other than ``red``, ``yellow``, and ``blue`` default to green. + """ + if self.color == "red": + color = "R" + elif self.color == "yellow": + color = "Y" + elif self.color == "blue": + color = "B" + else: + color = "G" + + if self.tail == self.head: + return f"{color}{chess.SQUARE_NAMES[self.tail]}" + else: + return f"{color}{chess.SQUARE_NAMES[self.tail]}{chess.SQUARE_NAMES[self.head]}" + + def __str__(self) -> str: + return self.pgn() + + def __repr__(self) -> str: + return f"Arrow({chess.SQUARE_NAMES[self.tail].upper()}, {chess.SQUARE_NAMES[self.head].upper()}, color={self.color!r})" + + @classmethod + def from_pgn(cls, pgn: str) -> Arrow: + """ + Parses an arrow from the format used by ``[%csl ...]`` and + ``[%cal ...]`` PGN annotations, e.g., ``Ga1`` or ``Ya2h2``. + + Also allows skipping the color prefix, defaulting to green. + + :raises: :exc:`ValueError` if the format is invalid. + """ + if pgn.startswith("G"): + color = "green" + pgn = pgn[1:] + elif pgn.startswith("R"): + color = "red" + pgn = pgn[1:] + elif pgn.startswith("Y"): + color = "yellow" + pgn = pgn[1:] + elif pgn.startswith("B"): + color = "blue" + pgn = pgn[1:] + else: + color = "green" + + tail = chess.parse_square(pgn[:2]) + head = chess.parse_square(pgn[2:]) if len(pgn) > 2 else tail + return cls(tail, head, color=color) + class SvgWrapper(str): - def _repr_svg_(self): + def _repr_svg_(self) -> SvgWrapper: + return self + + def _repr_html_(self) -> SvgWrapper: return self -def _svg(viewbox, size): +def _svg(viewbox: int, size: Optional[int]) -> ET.Element: svg = ET.Element("svg", { "xmlns": "http://www.w3.org/2000/svg", - "version": "1.1", "xmlns:xlink": "http://www.w3.org/1999/xlink", - "viewBox": "0 0 %d %d" % (viewbox, viewbox), + "viewBox": f"0 0 {viewbox:d} {viewbox:d}", }) if size is not None: @@ -86,78 +162,139 @@ def _svg(viewbox, size): return svg -def _text(content, x, y, width, height): - t = ET.Element("text", { - "x": str(x + width // 2), - "y": str(y + height // 2), - "font-size": str(max(1, int(min(width, height) * 0.7))), - "text-anchor": "middle", - "alignment-baseline": "middle", - }) - t.text = content +def _attrs(attrs: Dict[str, Union[str, int, float, None]]) -> Dict[str, str]: + return {k: str(v) for k, v in attrs.items() if v is not None} + + +def _select_color(colors: Dict[str, str], color: str) -> Tuple[str, float]: + return _color(colors.get(color, DEFAULT_COLORS[color])) + + +def _color(color: str) -> Tuple[str, float]: + if color.startswith("#"): + try: + if len(color) == 5: + return color[:4], int(color[4], 16) / 0xf + elif len(color) == 9: + return color[:7], int(color[7:], 16) / 0xff + except ValueError: + pass # Ignore invalid hex value + return color, 1.0 + + +def _coord(text: str, x: int, y: int, width: int, height: int, horizontal: bool, margin: int, *, color: str, opacity: float) -> ET.Element: + scale = margin / MARGIN + + if horizontal: + x += int(width - scale * width) // 2 + else: + y += int(height - scale * height) // 2 + + t = ET.Element("g", _attrs({ + "transform": f"translate({x}, {y}) scale({scale}, {scale})", + "fill": color, + "stroke": color, + "opacity": opacity if opacity < 1.0 else None, + })) + t.append(ET.fromstring(COORDS[text])) return t -def piece(piece, size=None): +def piece(piece: chess.Piece, size: Optional[int] = None) -> str: """ Renders the given :class:`chess.Piece` as an SVG image. >>> import chess >>> import chess.svg >>> - >>> from IPython.display import SVG - >>> - >>> SVG(chess.svg.piece(chess.Piece.from_symbol("R"))) # doctest: +SKIP + >>> chess.svg.piece(chess.Piece.from_symbol("R")) # doctest: +SKIP .. image:: ../docs/wR.svg + :alt: R """ svg = _svg(SQUARE_SIZE, size) svg.append(ET.fromstring(PIECES[piece.symbol()])) return SvgWrapper(ET.tostring(svg).decode("utf-8")) -def board(board=None, *, squares=None, flipped=False, coordinates=True, lastmove=None, check=None, arrows=(), size=None, style=None): +def board(board: Optional[chess.BaseBoard] = None, *, + orientation: Color = chess.WHITE, + lastmove: Optional[chess.Move] = None, + check: Optional[Square] = None, + arrows: Iterable[Union[Arrow, Tuple[Square, Square]]] = [], + fill: Dict[Square, str] = {}, + squares: Optional[IntoSquareSet] = None, + size: Optional[int] = None, + coordinates: bool = True, + colors: Dict[str, str] = {}, + borders: bool = False, + style: Optional[str] = None) -> str: """ Renders a board with pieces and/or selected squares as an SVG image. - :param board: A :class:`chess.BaseBoard` for a chessboard with pieces or + :param board: A :class:`chess.BaseBoard` for a chessboard with pieces, or ``None`` (the default) for a chessboard without pieces. - :param squares: A :class:`chess.SquareSet` with selected squares. - :param flipped: Pass ``True`` to flip the board. - :param coordinates: Pass ``False`` to disable coordinates in the margin. + :param orientation: The point of view, defaulting to ``chess.WHITE``. :param lastmove: A :class:`chess.Move` to be highlighted. - :param check: A square to be marked as check. - :param arrows: A list of :class:`~chess.svg.Arrow` objects like - ``[chess.svg.Arrow(chess.E2, chess.E4)]`` or a list of tuples like + :param check: A square to be marked indicating a check. + :param arrows: A list of :class:`~chess.svg.Arrow` objects, like + ``[chess.svg.Arrow(chess.E2, chess.E4)]``, or a list of tuples, like ``[(chess.E2, chess.E4)]``. An arrow from a square pointing to the same square is drawn as a circle, like ``[(chess.E2, chess.E2)]``. + :param fill: A dictionary mapping squares to a colors that they should be + filled with. + :param squares: A :class:`chess.SquareSet` with selected squares to mark + with an X. :param size: The size of the image in pixels (e.g., ``400`` for a 400 by - 400 board) or ``None`` (the default) for no size limit. + 400 board), or ``None`` (the default) for no size limit. + :param coordinates: Pass ``False`` to disable the coordinate margin. + :param colors: A dictionary to override default colors. Possible keys are + ``square light``, ``square dark``, ``square light lastmove``, + ``square dark lastmove``, ``margin``, ``coord``, ``inner border``, + ``outer border``, ``arrow green``, ``arrow blue``, ``arrow red``, + and ``arrow yellow``. Values should look like ``#ffce9e`` (opaque), + or ``#15781B80`` (transparent). + :param borders: Pass ``True`` to enable a border around the board and, + (if *coordinates* is enabled) the coordinate margin. :param style: A CSS stylesheet to include in the SVG image. >>> import chess >>> import chess.svg >>> - >>> from IPython.display import SVG - >>> >>> board = chess.Board("8/8/8/8/4N3/8/8/8 w - - 0 1") - >>> squares = board.attacks(chess.E4) - >>> SVG(chess.svg.board(board=board, squares=squares)) # doctest: +SKIP + >>> + >>> chess.svg.board( + ... board, + ... fill=dict.fromkeys(board.attacks(chess.E4), "#cc0000cc"), + ... arrows=[chess.svg.Arrow(chess.E4, chess.F6, color="#0000cccc")], + ... squares=chess.SquareSet(chess.BB_DARK_SQUARES & chess.BB_FILE_B), + ... size=350, + ... ) # doctest: +SKIP .. image:: ../docs/Ne4.svg + :alt: 8/8/8/8/4N3/8/8/8 """ - margin = MARGIN if coordinates else 0 - svg = _svg(8 * SQUARE_SIZE + 2 * margin, size) + inner_border = 1 if borders and coordinates else 0 + outer_border = 1 if borders else 0 + margin = 15 if coordinates else 0 + board_offset = inner_border + margin + outer_border + full_size = 2 * outer_border + 2 * margin + 2 * inner_border + 8 * SQUARE_SIZE + svg = _svg(full_size, size) if style: ET.SubElement(svg, "style").text = style + if board: + desc = ET.SubElement(svg, "desc") + asciiboard = ET.SubElement(desc, "pre") + asciiboard.text = str(board) + defs = ET.SubElement(svg, "defs") if board: - for color in chess.COLORS: + for piece_color in chess.COLORS: for piece_type in chess.PIECE_TYPES: - if board.pieces_mask(piece_type, color): - defs.append(ET.fromstring(PIECES[chess.Piece(piece_type, color).symbol()])) + if board.pieces_mask(piece_type, piece_color): + defs.append(ET.fromstring(PIECES[chess.Piece(piece_type, piece_color).symbol()])) squares = chess.SquareSet(squares) if squares else chess.SquareSet() if squares: @@ -166,93 +303,177 @@ def board(board=None, *, squares=None, flipped=False, coordinates=True, lastmove if check is not None: defs.append(ET.fromstring(CHECK_GRADIENT)) + if outer_border: + outer_border_color, outer_border_opacity = _select_color(colors, "outer border") + ET.SubElement(svg, "rect", _attrs({ + "x": outer_border / 2, + "y": outer_border / 2, + "width": full_size - outer_border, + "height": full_size - outer_border, + "fill": "none", + "stroke": outer_border_color, + "stroke-width": outer_border, + "opacity": outer_border_opacity if outer_border_opacity < 1.0 else None, + })) + + if margin: + margin_color, margin_opacity = _select_color(colors, "margin") + ET.SubElement(svg, "rect", _attrs({ + "x": outer_border + margin / 2, + "y": outer_border + margin / 2, + "width": full_size - 2 * outer_border - margin, + "height": full_size - 2 * outer_border - margin, + "fill": "none", + "stroke": margin_color, + "stroke-width": margin, + "opacity": margin_opacity if margin_opacity < 1.0 else None, + })) + + if inner_border: + inner_border_color, inner_border_opacity = _select_color(colors, "inner border") + ET.SubElement(svg, "rect", _attrs({ + "x": outer_border + margin + inner_border / 2, + "y": outer_border + margin + inner_border / 2, + "width": full_size - 2 * outer_border - 2 * margin - inner_border, + "height": full_size - 2 * outer_border - 2 * margin - inner_border, + "fill": "none", + "stroke": inner_border_color, + "stroke-width": inner_border, + "opacity": inner_border_opacity if inner_border_opacity < 1.0 else None, + })) + + # Render coordinates. + if coordinates: + coord_color, coord_opacity = _select_color(colors, "coord") + for file_index, file_name in enumerate(chess.FILE_NAMES): + x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE + board_offset + # Keep some padding here to separate the ascender from the border + svg.append(_coord(file_name, x, 1, SQUARE_SIZE, margin, True, margin, color=coord_color, opacity=coord_opacity)) + svg.append(_coord(file_name, x, full_size - outer_border - margin, SQUARE_SIZE, margin, True, margin, color=coord_color, opacity=coord_opacity)) + for rank_index, rank_name in enumerate(chess.RANK_NAMES): + y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE + board_offset + svg.append(_coord(rank_name, 0, y, margin, SQUARE_SIZE, False, margin, color=coord_color, opacity=coord_opacity)) + svg.append(_coord(rank_name, full_size - outer_border - margin, y, margin, SQUARE_SIZE, False, margin, color=coord_color, opacity=coord_opacity)) + + # Render board. for square, bb in enumerate(chess.BB_SQUARES): file_index = chess.square_file(square) rank_index = chess.square_rank(square) - x = (file_index if not flipped else 7 - file_index) * SQUARE_SIZE + margin - y = (7 - rank_index if not flipped else rank_index) * SQUARE_SIZE + margin + x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE + board_offset + y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE + board_offset cls = ["square", "light" if chess.BB_LIGHT_SQUARES & bb else "dark"] if lastmove and square in [lastmove.from_square, lastmove.to_square]: cls.append("lastmove") - fill_color = DEFAULT_COLORS[" ".join(cls)] + square_color, square_opacity = _select_color(colors, " ".join(cls)) + cls.append(chess.SQUARE_NAMES[square]) - ET.SubElement(svg, "rect", { - "x": str(x), - "y": str(y), - "width": str(SQUARE_SIZE), - "height": str(SQUARE_SIZE), + ET.SubElement(svg, "rect", _attrs({ + "x": x, + "y": y, + "width": SQUARE_SIZE, + "height": SQUARE_SIZE, "class": " ".join(cls), "stroke": "none", - "fill": fill_color, - }) - - if square == check: - ET.SubElement(svg, "rect", { - "x": str(x), - "y": str(y), - "width": str(SQUARE_SIZE), - "height": str(SQUARE_SIZE), - "class": "check", - "fill": "url(#check_gradient)", - }) - - # Render pieces. + "fill": square_color, + "opacity": square_opacity if square_opacity < 1.0 else None, + })) + + try: + fill_color, fill_opacity = _color(fill[square]) + except KeyError: + pass + else: + ET.SubElement(svg, "rect", _attrs({ + "x": x, + "y": y, + "width": SQUARE_SIZE, + "height": SQUARE_SIZE, + "stroke": "none", + "fill": fill_color, + "opacity": fill_opacity if fill_opacity < 1.0 else None, + })) + + # Render check mark. + if check is not None: + file_index = chess.square_file(check) + rank_index = chess.square_rank(check) + + x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE + board_offset + y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE + board_offset + + ET.SubElement(svg, "rect", _attrs({ + "x": x, + "y": y, + "width": SQUARE_SIZE, + "height": SQUARE_SIZE, + "class": "check", + "fill": "url(#check_gradient)", + })) + + # Render pieces and selected squares. + for square, bb in enumerate(chess.BB_SQUARES): + file_index = chess.square_file(square) + rank_index = chess.square_rank(square) + + x = (file_index if orientation else 7 - file_index) * SQUARE_SIZE + board_offset + y = (7 - rank_index if orientation else rank_index) * SQUARE_SIZE + board_offset + if board is not None: piece = board.piece_at(square) if piece: + href = f"#{chess.COLOR_NAMES[piece.color]}-{chess.PIECE_NAMES[piece.piece_type]}" ET.SubElement(svg, "use", { - "xlink:href": "#%s-%s" % (chess.COLOR_NAMES[piece.color], chess.PIECE_NAMES[piece.piece_type]), - "transform": "translate(%d, %d)" % (x, y), + "href": href, + "xlink:href": href, + "transform": f"translate({x:d}, {y:d})", }) # Render selected squares. - if squares is not None and square in squares: - ET.SubElement(svg, "use", { + if square in squares: + ET.SubElement(svg, "use", _attrs({ + "href": "#xx", "xlink:href": "#xx", - "x": str(x), - "y": str(y), - }) - - if coordinates: - for file_index, file_name in enumerate(chess.FILE_NAMES): - x = (file_index if not flipped else 7 - file_index) * SQUARE_SIZE + margin - svg.append(_text(file_name, x, 0, SQUARE_SIZE, margin)) - svg.append(_text(file_name, x, margin + 8 * SQUARE_SIZE, SQUARE_SIZE, margin)) - for rank_index, rank_name in enumerate(chess.RANK_NAMES): - y = (7 - rank_index if not flipped else rank_index) * SQUARE_SIZE + margin - svg.append(_text(rank_name, 0, y, margin, SQUARE_SIZE)) - svg.append(_text(rank_name, margin + 8 * SQUARE_SIZE, y, margin, SQUARE_SIZE)) + "x": x, + "y": y, + })) + # Render arrows. for arrow in arrows: try: - tail, head, color = arrow.tail, arrow.head, arrow.color + tail, head, color = arrow.tail, arrow.head, arrow.color # type: ignore except AttributeError: - tail, head = arrow - color = "#888" + tail, head = arrow # type: ignore + color = "green" + + try: + color, opacity = _select_color(colors, " ".join(["arrow", color])) + except KeyError: + opacity = 1.0 tail_file = chess.square_file(tail) tail_rank = chess.square_rank(tail) head_file = chess.square_file(head) head_rank = chess.square_rank(head) - xtail = margin + (tail_file + 0.5 if not flipped else 7.5 - tail_file) * SQUARE_SIZE - ytail = margin + (7.5 - tail_rank if not flipped else tail_rank + 0.5) * SQUARE_SIZE - xhead = margin + (head_file + 0.5 if not flipped else 7.5 - head_file) * SQUARE_SIZE - yhead = margin + (7.5 - head_rank if not flipped else head_rank + 0.5) * SQUARE_SIZE + xtail = board_offset + (tail_file + 0.5 if orientation else 7.5 - tail_file) * SQUARE_SIZE + ytail = board_offset + (7.5 - tail_rank if orientation else tail_rank + 0.5) * SQUARE_SIZE + xhead = board_offset + (head_file + 0.5 if orientation else 7.5 - head_file) * SQUARE_SIZE + yhead = board_offset + (7.5 - head_rank if orientation else head_rank + 0.5) * SQUARE_SIZE if (head_file, head_rank) == (tail_file, tail_rank): - ET.SubElement(svg, "circle", { - "cx": str(xhead), - "cy": str(yhead), - "r": str(SQUARE_SIZE * 0.9 / 2), - "stroke-width": str(SQUARE_SIZE * 0.1), + ET.SubElement(svg, "circle", _attrs({ + "cx": xhead, + "cy": yhead, + "r": SQUARE_SIZE * 0.9 / 2, + "stroke-width": SQUARE_SIZE * 0.1, "stroke": color, + "opacity": opacity if opacity < 1.0 else None, "fill": "none", - "opacity": "0.5", - }) + "class": "circle", + })) else: marker_size = 0.75 * SQUARE_SIZE marker_margin = 0.1 * SQUARE_SIZE @@ -266,17 +487,17 @@ def board(board=None, *, squares=None, flipped=False, coordinates=True, lastmove xtip = xhead - dx * marker_margin / hypot ytip = yhead - dy * marker_margin / hypot - ET.SubElement(svg, "line", { - "x1": str(xtail), - "y1": str(ytail), - "x2": str(shaft_x), - "y2": str(shaft_y), + ET.SubElement(svg, "line", _attrs({ + "x1": xtail, + "y1": ytail, + "x2": shaft_x, + "y2": shaft_y, "stroke": color, - "stroke-width": str(SQUARE_SIZE * 0.2), - "opacity": "0.5", + "opacity": opacity if opacity < 1.0 else None, + "stroke-width": SQUARE_SIZE * 0.2, "stroke-linecap": "butt", "class": "arrow", - }) + })) marker = [(xtip, ytip), (shaft_x + dy * 0.5 * marker_size / hypot, @@ -284,11 +505,11 @@ def board(board=None, *, squares=None, flipped=False, coordinates=True, lastmove (shaft_x - dy * 0.5 * marker_size / hypot, shaft_y + dx * 0.5 * marker_size / hypot)] - ET.SubElement(svg, "polygon", { - "points": " ".join(str(x) + "," + str(y) for x, y in marker), + ET.SubElement(svg, "polygon", _attrs({ + "points": " ".join(f"{x},{y}" for x, y in marker), "fill": color, - "opacity": "0.5", + "opacity": opacity if opacity < 1.0 else None, "class": "arrow", - }) + })) return SvgWrapper(ET.tostring(svg).decode("utf-8")) diff --git a/chess/syzygy.py b/chess/syzygy.py index 327ffec1b..2250db5b5 100644 --- a/chess/syzygy.py +++ b/chess/syzygy.py @@ -1,31 +1,23 @@ -# -*- coding: utf-8 -*- -# -# This file is part of the python-chess library. -# Copyright (C) 2012-2018 Niklas Fiekas -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . +from __future__ import annotations import collections -import itertools +import math import mmap import os import re import struct import threading +import typing import chess +from types import TracebackType +from typing import Deque, Dict, Iterable, Iterator, List, Optional, Set, Tuple, Type, TypeVar, Union + +if typing.TYPE_CHECKING: + from typing_extensions import Self + + UINT64_BE = struct.Struct(">Q") UINT32 = struct.Struct("I") @@ -46,10 +38,10 @@ INVTRIANGLE = [1, 2, 3, 10, 11, 19, 0, 9, 18, 27] -def offdiag(square): +def offdiag(square: chess.Square) -> int: return chess.square_rank(square) - chess.square_file(square) -def flipdiag(square): +def flipdiag(square: chess.Square) -> chess.Square: return ((square >> 3) | (square << 3)) & 63 LOWER = [ @@ -86,7 +78,7 @@ def flipdiag(square): ] PTWIST = [ - 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 47, 35, 23, 11, 10, 22, 34, 46, 45, 33, 21, 9, 8, 20, 32, 44, 43, 31, 19, 7, 6, 18, 30, 42, @@ -286,13 +278,13 @@ def flipdiag(square): -1, -1, -1, -1, 276, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, - -1, -1, -1, -1, -1, -1, -1, -1 + -1, -1, -1, -1, -1, -1, -1, -1, ]] -def test45(sq): - return chess.BB_SQUARES[sq] & (chess.BB_A5 | chess.BB_A6 | chess.BB_A7 | - chess.BB_B5 | chess.BB_B6 | - chess.BB_C5) +def test45(sq: chess.Square) -> bool: + return bool(chess.BB_SQUARES[sq] & (chess.BB_A5 | chess.BB_A6 | chess.BB_A7 | + chess.BB_B5 | chess.BB_B6 | + chess.BB_C5)) MTWIST = [ 15, 63, 55, 47, 40, 48, 56, 12, @@ -305,16 +297,11 @@ def test45(sq): 14, 60, 52, 44, 43, 51, 59, 13, ] -BINOMIAL = [] -for i in range(5): - BINOMIAL.append([]) - for j in range(64): - f = j - l = 1 - for k in range(1, i + 1): - f *= j - k - l *= k + 1 - BINOMIAL[i].append(f // l) +def binom(x: int, y: int) -> int: + try: + return math.factorial(x) // math.factorial(y) // math.factorial(x - y) + except ValueError: + return 0 PAWNIDX = [[0 for _ in range(24)] for _ in range(5)] @@ -326,28 +313,28 @@ def test45(sq): s = 0 while j < 6: PAWNIDX[i][j] = s - s += 1 if i == 0 else BINOMIAL[i - 1][PTWIST[INVFLAP[j]]] + s += 1 if i == 0 else binom(PTWIST[INVFLAP[j]], i) j += 1 PFACTOR[i][0] = s s = 0 while j < 12: PAWNIDX[i][j] = s - s += 1 if i == 0 else BINOMIAL[i - 1][PTWIST[INVFLAP[j]]] + s += 1 if i == 0 else binom(PTWIST[INVFLAP[j]], i) j += 1 PFACTOR[i][1] = s s = 0 while j < 18: PAWNIDX[i][j] = s - s += 1 if i == 0 else BINOMIAL[i - 1][PTWIST[INVFLAP[j]]] + s += 1 if i == 0 else binom(PTWIST[INVFLAP[j]], i) j += 1 PFACTOR[i][2] = s s = 0 while j < 24: PAWNIDX[i][j] = s - s += 1 if i == 0 else BINOMIAL[i - 1][PTWIST[INVFLAP[j]]] + s += 1 if i == 0 else binom(PTWIST[INVFLAP[j]], i) j += 1 PFACTOR[i][3] = s @@ -359,7 +346,7 @@ def test45(sq): s = 0 for j in range(10): MULTIDX[i][j] = s - s += 1 if i == 0 else BINOMIAL[i - 1][MTWIST[INVTRIANGLE[j]]] + s += 1 if i == 0 else binom(MTWIST[INVTRIANGLE[j]], i) MFACTOR[i] = s WDL_TO_MAP = [1, 3, 0, 2, 0] @@ -373,14 +360,18 @@ def test45(sq): TABLENAME_REGEX = re.compile(r"^[KQRBNP]+v[KQRBNP]+\Z") -def is_table_name(name): - return len(name) <= 7 + 1 and TABLENAME_REGEX.match(name) and normalize_tablename(name) == name +def is_tablename(name: str, *, one_king: bool = True, piece_count: Optional[int] = TBPIECES, normalized: bool = True) -> bool: + return ( + (piece_count is None or len(name) <= piece_count + 1) and + bool(TABLENAME_REGEX.match(name)) and + (not normalized or normalize_tablename(name) == name) and + (not one_king or (name != "KvK" and name.startswith("K") and "vK" in name))) -def tablenames(*, one_king=True, piece_count=6): +def tablenames(*, one_king: bool = True, piece_count: int = 6) -> Iterator[str]: first = "K" if one_king else "P" - targets = [] + targets: List[str] = [] white_pieces = piece_count - 2 black_pieces = 0 @@ -392,7 +383,7 @@ def tablenames(*, one_king=True, piece_count=6): return all_dependencies(targets, one_king=one_king) -def normalize_tablename(name, *, mirror=False): +def normalize_tablename(name: str, *, mirror: bool = False) -> str: w, b = name.split("v", 1) w = "".join(sorted(w, key=PCHR.index)) b = "".join(sorted(b, key=PCHR.index)) @@ -402,7 +393,7 @@ def normalize_tablename(name, *, mirror=False): return w + "v" + b -def _dependencies(target, *, one_king=True): +def _dependencies(target: str, *, one_king: bool = True) -> Iterator[str]: w, b = target.split("v", 1) for p in PCHR: @@ -422,8 +413,8 @@ def _dependencies(target, *, one_king=True): yield normalize_tablename(w + "v" + b.replace(p, "", 1)) -def dependencies(target, *, one_king=True): - closed = set() +def dependencies(target: str, *, one_king: bool = True) -> Iterator[str]: + closed: Set[str] = set() if one_king: closed.add("KvK") @@ -433,8 +424,8 @@ def dependencies(target, *, one_king=True): closed.add(dependency) -def all_dependencies(targets, *, one_king=True): - closed = set() +def all_dependencies(targets: Iterable[str], *, one_king: bool = True) -> Iterator[str]: + closed: Set[str] = set() if one_king: closed.add("KvK") @@ -451,7 +442,7 @@ def all_dependencies(targets, *, one_king=True): open_list.extend(_dependencies(name, one_king=one_king)) -def calc_key(board, *, mirror=False): +def calc_key(board: chess.BaseBoard, *, mirror: bool = False) -> str: w = board.occupied_co[chess.WHITE ^ mirror] b = board.occupied_co[chess.BLACK ^ mirror] @@ -472,7 +463,7 @@ def calc_key(board, *, mirror=False): ]) -def recalc_key(pieces, *, mirror=False): +def recalc_key(pieces: List[chess.PieceType], *, mirror: bool = False) -> str: # Some endgames are stored with a different key than their filename # indicates: http://talkchess.com/forum/viewtopic.php?p=695509#695509 @@ -496,7 +487,7 @@ def recalc_key(pieces, *, mirror=False): ]) -def subfactor(k, n): +def subfactor(k: int, n: int) -> int: f = n l = 1 @@ -507,7 +498,7 @@ def subfactor(k, n): return f // l -def dtz_before_zeroing(wdl): +def dtz_before_zeroing(wdl: int) -> int: return ((wdl > 0) - (wdl < 0)) * (1 if abs(wdl) == 2 else 101) @@ -517,45 +508,46 @@ class MissingTableError(KeyError): class PairsData: - def __init__(self): - self.indextable = None - self.sizetable = None - self.data = None - self.offset = None - self.symlen = None - self.sympat = None - self.blocksize = None - self.idxbits = None - self.min_len = None - self.base = None + indextable: int + sizetable: int + data: int + offset: int + symlen: List[int] + sympat: int + blocksize: int + idxbits: int + min_len: int + base: List[int] class PawnFileData: - def __init__(self): - self.precomp = {} - self.factor = {} - self.pieces = {} - self.norm = {} + def __init__(self) -> None: + self.precomp: Dict[int, PairsData] = {} + self.factor: Dict[int, List[int]] = {} + self.pieces: Dict[int, List[int]] = {} + self.norm: Dict[int, List[int]] = {} class PawnFileDataDtz: - def __init__(self): - self.precomp = None - self.factor = None - self.pieces = None - self.norm = None + precomp: PairsData + factor: List[int] + pieces: List[int] + norm: List[int] class Table: + size: List[int] - def __init__(self, path, *, variant=chess.Board): + def __init__(self, path: str, *, variant: Type[chess.Board] = chess.Board) -> None: self.path = path self.variant = variant + self.write_lock = threading.RLock() self.initialized = False - self.lock = threading.Lock() - self.fd = None - self.data = None + self.data: Optional[mmap.mmap] = None + + self.read_condition = threading.Condition() + self.read_count = 0 tablename, _ = os.path.splitext(os.path.basename(path)) self.key = normalize_tablename(tablename) @@ -569,8 +561,7 @@ def __init__(self, path, *, variant=chess.Board): black_part, white_part = tablename.split("v") if self.has_pawns: - self.pawns = {0: white_part.count("P"), - 1: black_part.count("P")} + self.pawns = [white_part.count("P"), black_part.count("P")] if self.pawns[1] > 0 and (self.pawns[0] == 0 or self.pawns[1] < self.pawns[0]): self.pawns[0], self.pawns[1] = self.pawns[1], self.pawns[0] else: @@ -594,19 +585,35 @@ def __init__(self, path, *, variant=chess.Board): j = white_part.count(piece_type) self.enc_type = 1 + j - def init_mmap(self): - # Open fd. - if self.fd is None: - self.fd = os.open(self.path, os.O_RDONLY | os.O_BINARY if hasattr(os, "O_BINARY") else os.O_RDONLY) - - # Open mmap. + def init_mmap(self) -> None: if self.data is None: - self.data = mmap.mmap(self.fd, 0, access=mmap.ACCESS_READ) + fd = os.open(self.path, os.O_RDONLY | getattr(os, "O_BINARY", 0)) + try: + data = mmap.mmap(fd, 0, access=mmap.ACCESS_READ) + finally: + os.close(fd) + + if data.size() % 64 != 16: + raise IOError(f"invalid file size: ensure {self.path!r} is a valid syzygy tablebase file") + + try: + # Unix + data.madvise(mmap.MADV_RANDOM) + except AttributeError: + pass + + self.data = data + + def check_magic(self, magic: Optional[bytes], pawnless_magic: Optional[bytes]) -> None: + assert self.data + + valid_magics = [magic, self.has_pawns and pawnless_magic] + if self.data[:min(4, len(self.data))] not in valid_magics: + raise IOError(f"invalid magic header: ensure {self.path!r} is a valid syzygy tablebase file") - def check_magic(self, magic): - return self.data[:min(len(self.data), len(magic))] == magic + def setup_pairs(self, data_ptr: int, tb_size: int, size_idx: int, wdl: int) -> PairsData: + assert self.data - def setup_pairs(self, data_ptr, tb_size, size_idx, wdl): d = PairsData() self._flags = self.data[data_ptr] @@ -661,7 +668,7 @@ def setup_pairs(self, data_ptr, tb_size, size_idx, wdl): return d - def set_norm_piece(self, norm, pieces): + def set_norm_piece(self, norm: List[int], pieces: List[int]) -> None: if self.enc_type == 0: norm[0] = 3 elif self.enc_type == 2: @@ -677,11 +684,8 @@ def set_norm_piece(self, norm, pieces): j += 1 i += norm[i] - def calc_factors_piece(self, factor, order, norm): - if not self.variant.connected_kings: - PIVFAC = [31332, 28056, 462] - else: - PIVFAC = [31332, 0, 518, 278] + def calc_factors_piece(self, factor: List[int], order: int, norm: List[int]) -> int: + PIVFAC = [31332, 0, 518, 278] if self.variant.connected_kings else [31332, 28056, 462] n = 64 - norm[0] @@ -704,7 +708,7 @@ def calc_factors_piece(self, factor, order, norm): return f - def calc_factors_pawn(self, factor, order, order2, norm, f): + def calc_factors_pawn(self, factor: List[int], order: int, order2: int, norm: List[int], f: int) -> int: i = norm[0] if order2 < 0x0f: i += norm[i] @@ -728,7 +732,7 @@ def calc_factors_pawn(self, factor, order, order2, norm, f): return fac - def set_norm_pawn(self, norm, pieces): + def set_norm_pawn(self, norm: List[int], pieces: List[int]) -> None: norm[0] = self.pawns[0] if self.pawns[1]: norm[self.pawns[0]] = self.pawns[1] @@ -741,7 +745,9 @@ def set_norm_pawn(self, norm, pieces): j += 1 i += norm[i] - def calc_symlen(self, d, s, tmp): + def calc_symlen(self, d: PairsData, s: int, tmp: List[int]) -> None: + assert self.data + w = d.sympat + 3 * s s2 = (self.data[w + 2] << 4) | (self.data[w + 1] >> 4) if s2 == 0x0fff: @@ -755,14 +761,14 @@ def calc_symlen(self, d, s, tmp): d.symlen[s] = d.symlen[s1] + d.symlen[s2] + 1 tmp[s] = 1 - def pawn_file(self, pos): + 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] return FILE_TO_FILE[pos[0] & 0x07] - def encode_piece(self, norm, pos, factor): + def encode_piece(self, norm: List[int], pos: List[chess.Square], factor: List[int]) -> int: n = self.num if self.enc_type < 3: @@ -774,6 +780,7 @@ def encode_piece(self, norm, pos, factor): for i in range(n): pos[i] ^= 0x38 + i = 0 for i in range(n): if offdiag(pos[i]): break @@ -847,7 +854,7 @@ def encode_piece(self, norm, pos, factor): idx = MULTIDX[norm[0] - 1][TRIANGLE[pos[0]]] i = 1 while i < norm[0]: - idx += BINOMIAL[i - 1][MTWIST[pos[i]]] + idx += binom(MTWIST[pos[i]], i) i += 1 idx *= factor[0] @@ -868,14 +875,14 @@ def encode_piece(self, norm, pos, factor): j = 0 for l in range(i): j += int(p > pos[l]) - s += BINOMIAL[m - i][p - j] + s += binom(p - j, m - i + 1) idx += s * factor[i] i += t return idx - def encode_pawn(self, norm, pos, factor): + def encode_pawn(self, norm: List[int], pos: List[chess.Square], factor: List[int]) -> int: n = self.num if pos[0] & 0x04: @@ -890,7 +897,7 @@ def encode_pawn(self, norm, pos, factor): t = self.pawns[0] - 1 idx = PAWNIDX[t][FLAP[pos[0]]] for i in range(t, 0, -1): - idx += BINOMIAL[t - i][PTWIST[pos[i]]] + idx += binom(PTWIST[pos[i]], t - i + 1) idx *= factor[0] # Remaining pawns. @@ -907,7 +914,7 @@ def encode_pawn(self, norm, pos, factor): j = 0 for k in range(i): j += int(p > pos[k]) - s += BINOMIAL[m - i][p - j - 8] + s += binom(p - j - 8, m - i + 1) idx += s * factor[i] i = t @@ -924,14 +931,16 @@ def encode_pawn(self, norm, pos, factor): j = 0 for k in range(i): j += int(p > pos[k]) - s += BINOMIAL[m - i][p - j] + s += binom(p - j, m - i + 1) idx += s * factor[i] i += t return idx - def decompress_pairs(self, d, idx): + def decompress_pairs(self, d: PairsData, idx: int) -> int: + assert self.data + if not d.idxbits: return d.min_len @@ -996,70 +1005,61 @@ def decompress_pairs(self, d, idx): else: return self.data[w] - def read_uint64_be(self, data_ptr): - return UINT64_BE.unpack_from(self.data, data_ptr)[0] + def read_uint64_be(self, data_ptr: int) -> int: + return UINT64_BE.unpack_from(self.data, data_ptr)[0] # type: ignore - def read_uint32(self, data_ptr): - return UINT32.unpack_from(self.data, data_ptr)[0] + def read_uint32(self, data_ptr: int) -> int: + return UINT32.unpack_from(self.data, data_ptr)[0] # type: ignore - def read_uint32_be(self, data_ptr): - return UINT32_BE.unpack_from(self.data, data_ptr)[0] + def read_uint32_be(self, data_ptr: int) -> int: + return UINT32_BE.unpack_from(self.data, data_ptr)[0] # type: ignore - def read_uint16(self, data_ptr): - return UINT16.unpack_from(self.data, data_ptr)[0] + def read_uint16(self, data_ptr: int) -> int: + return UINT16.unpack_from(self.data, data_ptr)[0] # type: ignore - def close(self): - if self.data is not None: - self.data.close() + def close(self) -> None: + with self.write_lock: + with self.read_condition: + while self.read_count > 0: + self.read_condition.wait() - if self.fd is not None: - try: - os.close(self.fd) - except OSError: - pass + if self.data is not None: + self.data.close() + self.data = None - self.data = None - self.fd = None - - def __enter__(self): + def __enter__(self) -> Self: return self - def __exit__(self, exc_type, exc_value, traceback): + def __exit__(self, exc_type: Optional[Type[BaseException]], exc_value: Optional[BaseException], traceback: Optional[TracebackType]) -> None: self.close() class WdlTable(Table): + _next: int + _flags: int - def init_table_wdl(self): - with self.lock: + def init_table_wdl(self) -> None: + with self.write_lock: self.init_mmap() + assert self.data if self.initialized: return - if not self.check_magic(self.variant.tbw_magic) and (self.has_pawns or self.variant.pawnless_tbw_magic is None or not self.check_magic(self.variant.pawnless_tbw_magic)): - raise IOError("invalid magic header: ensure {} is a valid syzygy tablebase file".format(self.path)) + self.check_magic(self.variant.tbw_magic, self.variant.pawnless_tbw_magic) self.tb_size = [0 for _ in range(8)] self.size = [0 for _ in range(8 * 3)] # Used if there are only pieces. - self.precomp = {} - self.pieces = {} - - self.factor = {0: [0 for _ in range(TBPIECES)], - 1: [0 for _ in range(TBPIECES)]} - - self.norm = {0: [0 for _ in range(self.num)], - 1: [0 for _ in range(self.num)]} + self.precomp: Dict[int, PairsData] = {} + self.pieces: Dict[int, List[int]] = {} + self.factor = [[0 for _ in range(TBPIECES)] for _ in range(2)] + self.norm = [[0 for _ in range(self.num)] for _ in range(2)] # Used if there are pawns. self.files = [PawnFileData() for _ in range(4)] - self._next = None - self._flags = None - self.flags = None - split = self.data[4] & 0x01 files = 4 if self.data[4] & 0x02 else 1 @@ -1075,8 +1075,6 @@ def init_table_wdl(self): if split: self.precomp[1] = self.setup_pairs(data_ptr, self.tb_size[1], 3, True) data_ptr = self._next - else: - self.precomp[1] = None self.precomp[0].indextable = data_ptr data_ptr += self.size[0] @@ -1112,8 +1110,6 @@ def init_table_wdl(self): if split: self.files[f].precomp[1] = self.setup_pairs(data_ptr, self.tb_size[2 * f + 1], 6 * f + 3, True) data_ptr = self._next - else: - self.files[f].precomp[1] = None for f in range(files): self.files[f].precomp[0].indextable = data_ptr @@ -1140,7 +1136,9 @@ def init_table_wdl(self): self.initialized = True - def setup_pieces_pawn(self, p_data, p_tb_size, f): + def setup_pieces_pawn(self, p_data: int, p_tb_size: int, f: int) -> None: + assert self.data + j = 1 + int(self.pawns[1] > 0) order = self.data[p_data] & 0x0f order2 = self.data[p_data + 1] & 0x0f if self.pawns[1] else 0x0f @@ -1158,7 +1156,9 @@ def setup_pieces_pawn(self, p_data, p_tb_size, f): self.files[f].factor[1] = [0 for _ in range(TBPIECES)] self.tb_size[p_tb_size + 1] = self.calc_factors_pawn(self.files[f].factor[1], order, order2, self.files[f].norm[1], f) - def setup_pieces_piece(self, p_data): + def setup_pieces_piece(self, p_data: int) -> None: + assert self.data + self.pieces[0] = [self.data[p_data + i + 1] & 0x0f for i in range(self.num)] order = self.data[p_data] & 0x0f self.set_norm_piece(self.norm[0], self.pieces[0]) @@ -1169,7 +1169,17 @@ def setup_pieces_piece(self, p_data): self.set_norm_piece(self.norm[1], self.pieces[1]) self.tb_size[1] = self.calc_factors_piece(self.factor[1], order, self.norm[1]) - def probe_wdl_table(self, board): + def probe_wdl_table(self, board: chess.Board) -> int: + try: + with self.read_condition: + self.read_count += 1 + return self._probe_wdl_table(board) + finally: + with self.read_condition: + self.read_count -= 1 + self.read_condition.notify() + + def _probe_wdl_table(self, board: chess.Board) -> int: self.init_table_wdl() key = calc_key(board) @@ -1229,52 +1239,48 @@ def probe_wdl_table(self, board): return res - 2 - def close(self): - with self.lock: - super().close() - class DtzTable(Table): - def init_table_dtz(self): - with self.lock: + def init_table_dtz(self) -> None: + with self.write_lock: self.init_mmap() + assert self.data if self.initialized: return - if not self.check_magic(self.variant.tbz_magic) and (self.has_pawns or self.variant.pawnless_tbz_magic is None or not self.check_magic(self.variant.pawnless_tbz_magic)): - raise IOError("invalid magic header: ensure {} is a valid syzygy tablebase file".format(self.path)) + self.check_magic(self.variant.tbz_magic, self.variant.pawnless_tbz_magic) self.factor = [0 for _ in range(TBPIECES)] self.norm = [0 for _ in range(self.num)] self.tb_size = [0, 0, 0, 0] self.size = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] - self.files = [PawnFileDataDtz() for f in range(4)] + self.files = [PawnFileDataDtz() for _ in range(4)] files = 4 if self.data[4] & 0x02 else 1 p_data = 5 if not self.has_pawns: - self.map_idx = [0, 0, 0, 0] + self.map_idx: List[List[int]] = [[0, 0, 0, 0]] self.setup_pieces_piece_dtz(p_data, 0) p_data += self.num + 1 p_data += p_data & 0x01 self.precomp = self.setup_pairs(p_data, self.tb_size[0], 0, False) - self.flags = self._flags + self.flags: Union[int, List[int]] = self._flags p_data = self._next self.p_map = p_data if self.flags & 2: if not self.flags & 16: for i in range(4): - self.map_idx[i] = p_data + 1 - self.p_map + self.map_idx[0][i] = p_data + 1 - self.p_map p_data += 1 + self.data[p_data] else: for i in range(4): - self.map_idx[i] = (p_data + 2 - self.p_map) // 2 + self.map_idx[0][i] = (p_data + 2 - self.p_map) // 2 p_data += 2 + 2 * self.read_uint16(p_data) p_data += p_data & 0x01 @@ -1334,8 +1340,19 @@ def init_table_dtz(self): self.initialized = True - def probe_dtz_table(self, board, wdl): + def probe_dtz_table(self, board: chess.Board, wdl: int) -> Tuple[int, int]: + try: + with self.read_condition: + self.read_count += 1 + return self._probe_dtz_table(board, wdl) + finally: + with self.read_condition: + self.read_count -= 1 + self.read_condition.notify() + + def _probe_dtz_table(self, board: chess.Board, wdl: int) -> Tuple[int, int]: self.init_table_dtz() + assert self.data key = calc_key(board) @@ -1353,6 +1370,8 @@ def probe_dtz_table(self, board, wdl): bside = 0 if not self.has_pawns: + assert isinstance(self.flags, int) + if (self.flags & 1) != bside and not self.symmetric: return 0, -1 @@ -1373,13 +1392,15 @@ def probe_dtz_table(self, board, wdl): if self.flags & 2: if not self.flags & 16: - res = self.data[self.p_map + self.map_idx[WDL_TO_MAP[wdl + 2]] + res] + res = self.data[self.p_map + self.map_idx[0][WDL_TO_MAP[wdl + 2]] + res] else: - res = self.read_uint16(self.p_map + 2 * (self.map_idx[WDL_TO_MAP[wdl + 2]] + res)) + res = self.read_uint16(self.p_map + 2 * (self.map_idx[0][WDL_TO_MAP[wdl + 2]] + res)) if (not (self.flags & PA_FLAGS[wdl + 2])) or (wdl & 1): res *= 2 else: + assert isinstance(self.flags, list) + k = self.files[0].pieces[0] ^ cmirror piece_type = k & 0x07 color = k >> 3 @@ -1418,13 +1439,17 @@ def probe_dtz_table(self, board, wdl): return res, 1 - def setup_pieces_piece_dtz(self, p_data, p_tb_size): + def setup_pieces_piece_dtz(self, p_data: int, p_tb_size: int) -> None: + assert self.data + self.pieces = [self.data[p_data + i + 1] & 0x0f for i in range(self.num)] order = self.data[p_data] & 0x0f self.set_norm_piece(self.norm, self.pieces) self.tb_size[p_tb_size] = self.calc_factors_piece(self.factor, order, self.norm) - def setup_pieces_pawn_dtz(self, p_data, p_tb_size, f): + def setup_pieces_pawn_dtz(self, p_data: int, p_tb_size: int, f: int) -> None: + assert self.data + j = 1 + int(self.pawns[1] > 0) order = self.data[p_data] & 0x0f order2 = self.data[p_data + 1] & 0x0f if self.pawns[1] else 0x0f @@ -1436,42 +1461,36 @@ def setup_pieces_pawn_dtz(self, p_data, p_tb_size, f): self.files[f].factor = [0 for _ in range(TBPIECES)] self.tb_size[p_tb_size] = self.calc_factors_pawn(self.files[f].factor, order, order2, self.files[f].norm, f) - def close(self): - with self.lock: - super().close() - class Tablebase: """ Manages a collection of tablebase files for probing. - - If *max_fds* is not ``None``, will at most use *max_fds* open file - descriptors at any given time. The least recently used tables are closed, - if nescessary. """ - def __init__(self, *, max_fds=128, VariantBoard=chess.Board): + def __init__(self, *, max_fds: Optional[int] = 128, VariantBoard: Type[chess.Board] = chess.Board) -> None: self.variant = VariantBoard self.max_fds = max_fds - self.lru = collections.deque() + self.lru: Deque[Table] = collections.deque() + self.lru_lock = threading.Lock() - self.wdl = {} - self.dtz = {} + self.wdl: Dict[str, Table] = {} + self.dtz: Dict[str, Table] = {} - def _bump_lru(self, table): + def _bump_lru(self, table: Table) -> None: if self.max_fds is None: return - try: - self.lru.remove(table) - self.lru.appendleft(table) - except ValueError: - self.lru.appendleft(table) + with self.lru_lock: + try: + self.lru.remove(table) + self.lru.appendleft(table) + except ValueError: + self.lru.appendleft(table) - if len(self.lru) > self.max_fds: - self.lru.pop().close() + if len(self.lru) > self.max_fds: + self.lru.pop().close() - def _open_table(self, hashtable, Table, path): + def _open_table(self, hashtable: Dict[str, Table], Table: Type[Table], path: str) -> int: table = Table(path, variant=self.variant) if table.key in hashtable: @@ -1481,42 +1500,38 @@ def _open_table(self, hashtable, Table, path): hashtable[table.mirrored_key] = table return 1 - def add_directory(self, directory, *, load_wdl=True, load_dtz=True): + def add_directory(self, directory: str, *, load_wdl: bool = True, load_dtz: bool = True) -> int: """ - Loads tables from a directory. + Adds tables from a directory. - By default all available tables with the correct file names - (e.g. WDL files like ``KQvKN.rtbw`` and DTZ files like ``KRBvK.rtbz``) - are loaded. + By default, all available tables with the correct file names + (e.g., WDL files like ``KQvKN.rtbw`` and DTZ files like ``KRBvK.rtbz``) + are added. + + The relevant files are lazily opened when the tablebase is actually + probed. Returns the number of table files that were found. """ - num = 0 directory = os.path.abspath(directory) - - for filename in os.listdir(directory): - path = os.path.join(directory, filename) - tablename, ext = os.path.splitext(filename) - - if is_table_name(tablename) and os.path.isfile(path): - if load_wdl: - if ext == self.variant.tbw_suffix: - num += self._open_table(self.wdl, WdlTable, path) - elif "P" not in tablename and ext == self.variant.pawnless_tbw_suffix: - num += self._open_table(self.wdl, WdlTable, path) - - if load_dtz: - if ext == self.variant.tbz_suffix: - num += self._open_table(self.dtz, DtzTable, path) - elif "P" not in tablename and ext == self.variant.pawnless_tbz_suffix: - num += self._open_table(self.dtz, DtzTable, path) - - return num - - # TODO: Deprecated - open_directory = add_directory - - def probe_wdl_table(self, board): + return sum(self.add_file(os.path.join(directory, filename), load_wdl=load_wdl, load_dtz=load_dtz) for filename in os.listdir(directory)) + + def add_file(self, path: str, *, load_wdl: bool = True, load_dtz: bool = True) -> int: + tablename, ext = os.path.splitext(os.path.basename(path)) + if is_tablename(tablename, one_king=self.variant.one_king) and os.path.isfile(path): + if load_wdl: + if ext == self.variant.tbw_suffix: + return self._open_table(self.wdl, WdlTable, path) + elif "P" not in tablename and ext == self.variant.pawnless_tbw_suffix: + return self._open_table(self.wdl, WdlTable, path) + if load_dtz: + if ext == self.variant.tbz_suffix: + return self._open_table(self.dtz, DtzTable, path) + elif "P" not in tablename and ext == self.variant.pawnless_tbz_suffix: + return self._open_table(self.dtz, DtzTable, path) + return 0 + + def probe_wdl_table(self, board: chess.Board) -> int: # Test for variant end. if board.is_variant_win(): return 2 @@ -1531,15 +1546,31 @@ def probe_wdl_table(self, board): key = calc_key(board) try: - table = self.wdl[key] + table = typing.cast(WdlTable, self.wdl[key]) except KeyError: - raise MissingTableError("did not find wdl table {}".format(key)) + 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) return table.probe_wdl_table(board) - def probe_ab(self, board, alpha, beta, threats=False): + def probe_ab(self, board: chess.Board, alpha: int, beta: int, threats: bool = False) -> Tuple[int, int]: + # Check preconditions. + if board.uci_variant != self.variant.uci_variant: + raise KeyError(f"tablebase has been opened for {self.variant.uci_variant}, probed with: {board.uci_variant}") + if board.castling_rights: + raise KeyError(f"syzygy tables do not contain positions with castling rights: {board.fen()}") + + # Probing resolves captures, so sometimes we can obtain results for + # 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 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: if board.is_variant_win(): return 2, 2 @@ -1571,7 +1602,7 @@ def probe_ab(self, board, alpha, beta, threats=False): else: return v, 1 - def sprobe_ab(self, board, alpha, beta, threats=False): + def sprobe_ab(self, board: chess.Board, alpha: int, beta: int, threats: bool = False) -> Tuple[int, int]: if chess.popcount(board.occupied_co[not board.turn]) > 1: v, captures_found = self.sprobe_capts(board, alpha, beta) if captures_found: @@ -1582,7 +1613,7 @@ def sprobe_ab(self, board, alpha, beta, threats=False): 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: @@ -1603,7 +1634,7 @@ def sprobe_ab(self, board, alpha, beta, threats=False): else: return alpha, 3 if threats_found else 1 - def sprobe_capts(self, board, alpha, beta): + def sprobe_capts(self, board: chess.Board, alpha: int, beta: int) -> Tuple[int, int]: captures_found = False for move in board.generate_legal_captures(): @@ -1623,9 +1654,11 @@ def sprobe_capts(self, board, alpha, beta): return alpha, captures_found - def probe_wdl(self, board): + def probe_wdl(self, board: chess.Board) -> int: """ - Probes WDL tables for win/draw/loss-information. + Probes WDL tables for win/draw/loss information under the 50-move rule, + assuming the position has been reached directly after a capture or + pawn move. Probing is thread-safe when done with different *board* objects and if *board* objects are not modified during probing. @@ -1651,15 +1684,9 @@ def probe_wdl(self, board): be found in the tablebase. Use :func:`~chess.syzygy.Tablebase.get_wdl()` if you prefer to get ``None`` instead of an exception. - """ - # Positions with castling rights are not in the tablebase. - if board.castling_rights: - raise KeyError("syzygy tables do not contain positions with castling rights: {}".format(board.fen())) - - # Validate piece count. - if chess.popcount(board.occupied) > 7: - raise KeyError("syzygy tables support up to 6 (and experimentally 7) pieces, not {}: {}".format(chess.popcount(board.occupied), board.fen())) + Note that probing corrupted table files is undefined behavior. + """ # Probe. v, _ = self.probe_ab(board, -2, 2) @@ -1693,24 +1720,24 @@ def probe_wdl(self, board): return v - def get_wdl(self, board, default=None): + def get_wdl(self, board: chess.Board, default: Optional[int] = None) -> Optional[int]: try: return self.probe_wdl(board) except KeyError: return default - def probe_dtz_table(self, board, wdl): + def probe_dtz_table(self, board: chess.Board, wdl: int) -> Tuple[int, int]: key = calc_key(board) try: - table = self.dtz[key] + table = typing.cast(DtzTable, self.dtz[key]) except KeyError: - raise MissingTableError("did not find dtz table {}".format(key)) + raise MissingTableError(f"did not find dtz table {key}") self._bump_lru(table) return table.probe_dtz_table(board, wdl) - def probe_dtz_no_ep(self, board): + def probe_dtz_no_ep(self, board: chess.Board) -> int: wdl, success = self.probe_ab(board, -2, 2, threats=True) if wdl == 0: @@ -1782,14 +1809,20 @@ def probe_dtz_no_ep(self, board): return best - def probe_dtz(self, board): + def probe_dtz(self, board: chess.Board) -> int: """ - Probes DTZ tables for distance to zero information. + Probes DTZ tables for + `DTZ50'' information with rounding `_. - Both DTZ and WDL tables are required in order to probe for DTZ. + Minmaxing the DTZ50'' values guarantees winning a won position + (and drawing a drawn position), because it makes progress keeping the + win in hand. + However, the lines are not always the most straightforward ways to win. + Engines like Stockfish calculate themselves, checking with DTZ, but + only play according to DTZ if they can not manage on their own. Returns a positive value if the side to move is winning, ``0`` if the - position is a draw and a negative value if the side to move is losing. + position is a draw, and a negative value if the side to move is losing. More precisely: +-----+------------------+--------------------------------------------+ @@ -1817,17 +1850,16 @@ def probe_dtz(self, board): +-----+------------------+--------------------------------------------+ The return value can be off by one: a return value -n can mean a - losing zeroing move in in n + 1 plies and a return value +n can mean a + losing zeroing move in n + 1 plies and a return value +n can mean a winning zeroing move in n + 1 plies. - This is guaranteed not to happen for positions exactly on the edge of - the 50-move rule, so that (with some care) this never impacts the - result of practical play. + This implies some primary tablebase lines may waste up to 1 ply. + Rounding is never used for endgame phases where it would change the + game theoretical outcome. - Minmaxing the DTZ values guarantees winning a won position (and drawing - a drawn position), because it makes progress keeping the win in hand. - However the lines are not always the most straightforward ways to win. - Engines like Stockfish calculate themselves, checking with DTZ, but - only play according to DTZ if they can not manage on their own. + This means users need to be careful in positions that are nearly drawn + under the 50-move rule! Carelessly wasting 1 more ply by not following + the tablebase recommendation, for a total of 2 wasted plies, may + change the outcome of the game. >>> import chess >>> import chess.syzygy @@ -1841,11 +1873,15 @@ def probe_dtz(self, board): Probing is thread-safe when done with different *board* objects and if *board* objects are not modified during probing. + Both DTZ and WDL tables are required in order to probe for DTZ. + :raises: :exc:`KeyError` (or specifically :exc:`chess.syzygy.MissingTableError`) if the position could not be found in the tablebase. Use :func:`~chess.syzygy.Tablebase.get_dtz()` if you prefer to get ``None`` instead of an exception. + + Note that probing corrupted table files is undefined behavior. """ v = self.probe_dtz_no_ep(board) @@ -1854,7 +1890,7 @@ def probe_dtz(self, board): v1 = -3 - # Generate all en-passant moves. + # Generate all en passant moves. for move in board.generate_legal_ep(): board.push(move) try: @@ -1888,13 +1924,13 @@ def probe_dtz(self, board): return v - def get_dtz(self, board, default=None): + def get_dtz(self, board: chess.Board, default: Optional[int] = None) -> Optional[int]: try: return self.probe_dtz(board) except KeyError: return default - def close(self): + def close(self) -> None: """Closes all loaded tables.""" while self.wdl: _, wdl = self.wdl.popitem() @@ -1906,14 +1942,14 @@ def close(self): self.lru.clear() - def __enter__(self): + def __enter__(self) -> Tablebase: return self - def __exit__(self, exc_type, exc_value, traceback): + def __exit__(self, exc_type: Optional[Type[BaseException]], exc_value: Optional[BaseException], traceback: Optional[TracebackType]) -> None: self.close() -def open_tablebase(directory, *, load_wdl=True, load_dtz=True, max_fds=128, VariantBoard=chess.Board): +def open_tablebase(directory: str, *, load_wdl: bool = True, load_dtz: bool = True, max_fds: Optional[int] = 128, VariantBoard: Type[chess.Board] = chess.Board) -> Tablebase: """ Opens a collection of tables for probing. See :class:`~chess.syzygy.Tablebase`. @@ -1921,16 +1957,17 @@ def open_tablebase(directory, *, load_wdl=True, load_dtz=True, max_fds=128, Vari .. note:: Generally probing requires tablebase files for the specific - material composition, **as well as** tablebase files with less pieces. - This is important because 6-piece and 5-piece files are often - distributed seperately, but are both required for 6-piece positions. - Use :func:`~chess.syzygy.Tablebase.add_directory()` to load + material composition, **as well as** material compositions transitively + reachable by captures and promotions. + This is important because 6-piece and 5-piece (let alone 7-piece) files + are often distributed separately, but are both required for 6-piece + positions. Use :func:`~chess.syzygy.Tablebase.add_directory()` to load tables from additional directories. + + :param max_fds: If *max_fds* is not ``None``, will at most use *max_fds* + open file descriptors at any given time. The least recently used tables + are closed, if necessary. """ tables = Tablebase(max_fds=max_fds, VariantBoard=VariantBoard) tables.add_directory(directory, load_wdl=load_wdl, load_dtz=load_dtz) return tables - -# TODO: Deprecated -open_tablebases = open_tablebase -Tablebases = Tablebase diff --git a/chess/uci.py b/chess/uci.py deleted file mode 100644 index 7808b6c95..000000000 --- a/chess/uci.py +++ /dev/null @@ -1,1154 +0,0 @@ -# -*- coding: utf-8 -*- -# -# This file is part of the python-chess library. -# Copyright (C) 2012-2018 Niklas Fiekas -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import chess - -from chess.engine import EngineTerminatedException -from chess.engine import EngineStateException -from chess.engine import MockProcess -from chess.engine import PopenProcess -from chess.engine import SpurProcess -from chess.engine import Option -from chess.engine import OptionMap -from chess.engine import LOGGER -from chess.engine import FUTURE_POLL_TIMEOUT -from chess.engine import _popen_engine -from chess.engine import _spur_spawn_engine - -import collections -import concurrent.futures -import threading - - -class Score(collections.namedtuple("Score", "cp mate")): - """A *cp* (centipawns) or *mate* score sent by an UCI engine.""" - __slots__ = () - - -class BestMove(collections.namedtuple("BestMove", "bestmove ponder")): - """A *bestmove* and *ponder* move sent by an UCI engine.""" - __slots__ = () - - -class InfoHandler: - """ - Chess engines may send information about their calculations with the - *info* command. An :class:`~chess.uci.InfoHandler` instance can be used - to aggregate or react to this information. - - >>> import chess.uci - >>> - >>> engine = chess.uci.popen_engine("stockfish") - >>> - >>> # Register a standard info handler. - >>> info_handler = chess.uci.InfoHandler() - >>> engine.info_handlers.append(info_handler) - >>> - >>> # Start a search. - >>> engine.position(chess.Board()) - >>> engine.go(movetime=1000) - BestMove(bestmove=Move.from_uci('e2e4'), ponder=Move.from_uci('e7e6')) - >>> - >>> # Retrieve the score of the mainline (PV 1) after search is completed. - >>> # Note that the score is relative to the side to move. - >>> info_handler.info["score"][1] - Score(cp=34, mate=None) - - See :attr:`~chess.uci.InfoHandler.info` for a way to access this dictionary - in a thread-safe way during search. - - If you want to be notified whenever new information is available, - you would usually subclass the :class:`~chess.uci.InfoHandler` class: - - >>> class MyHandler(chess.uci.InfoHandler): - ... def post_info(self): - ... # Called whenever a complete info line has been processed. - ... print(self.info) - ... super().post_info() # Release the lock - """ - def __init__(self): - self.lock = threading.Lock() - - self.info = {"refutation": {}, "currline": {}, "pv": {}, "score": {}} - - def depth(self, x): - """Receives the search depth in plies.""" - self.info["depth"] = x - - def seldepth(self, x): - """Receives the selective search depth in plies.""" - self.info["seldepth"] = x - - def time(self, x): - """Receives a new time searched in milliseconds.""" - self.info["time"] = x - - def nodes(self, x): - """Receives the number of nodes searched.""" - self.info["nodes"] = x - - def pv(self, moves): - """ - Receives the principal variation as a list of moves. - - In *MultiPV* mode, this is related to the most recent *multipv* number - sent by the engine. - """ - self.info["pv"][self.info.get("multipv", 1)] = moves - - def multipv(self, num): - """ - Receives a new *multipv* number, starting at 1. - - If *multipv* occurs in an info line, this is guaranteed to be called - before *score* or *pv*. - """ - self.info["multipv"] = num - - def score(self, cp, mate, lowerbound, upperbound): - """ - Receives a new evaluation in *cp* (centipawns) or a *mate* score. - - *cp* may be ``None`` if no score in centipawns is available. - - *mate* may be ``None`` if no forced mate has been found. A negative - number means the engine thinks it will get mated. - - *lowerbound* and *upperbound* are usually ``False``. If ``True``, - the sent score is just a *lowerbound* or *upperbound*. - - In *MultiPV* mode, this is related to the most recent *multipv* number - sent by the engine. - """ - if not lowerbound and not upperbound: - self.info["score"][self.info.get("multipv", 1)] = Score(cp, mate) - - def currmove(self, move): - """ - Receives a move the engine is currently thinking about. - - The move comes directly from the engine, so the castling move - representation depends on the *UCI_Chess960* option of the engine. - """ - self.info["currmove"] = move - - def currmovenumber(self, x): - """Receives a new current move number.""" - self.info["currmovenumber"] = x - - def hashfull(self, x): - """ - Receives new information about the hash table. - - The hash table is *x* permill full. - """ - self.info["hashfull"] = x - - def nps(self, x): - """Receives a new nodes per second (nps) statistic.""" - self.info["nps"] = x - - def tbhits(self, x): - """Receives a new information about the number of tablebase hits.""" - self.info["tbhits"] = x - - def cpuload(self, x): - """Receives a new *cpuload* information in permill.""" - self.info["cpuload"] = x - - def string(self, string): - """Receives a string the engine wants to display.""" - self.info["string"] = string - - def refutation(self, move, refuted_by): - """ - Receives a new refutation of a move. - - *refuted_by* may be a list of moves representing the mainline of the - refutation or ``None`` if no refutation has been found. - - Engines should only send refutations if the *UCI_ShowRefutations* - option has been enabled. - """ - self.info["refutation"][move] = refuted_by - - def currline(self, cpunr, moves): - """ - Receives a new snapshot of a line that a specific CPU is calculating. - - *cpunr* is an integer representing a specific CPU and *moves* is a list - of moves. - """ - self.info["currline"][cpunr] = moves - - def ebf(self, ebf): - """Receives the effective branching factor.""" - self.info["ebf"] = ebf - - def pre_info(self, line): - """ - Receives new info lines before they are processed. - - When subclassing, remember to call this method on the parent class - to keep the locking intact. - """ - self.lock.acquire() - self.info.pop("multipv", None) - - def post_info(self): - """ - Processing of a new info line has been finished. - - When subclassing, remember to call this method on the parent class - to keep the locking intact. - """ - self.lock.release() - - def on_bestmove(self, bestmove, ponder): - """Receives a new *bestmove* and a new *ponder* move.""" - pass - - def on_go(self): - """ - Notified when a *go* command is beeing sent. - - Since information about the previous search is invalidated, the - dictionary with the current information will be cleared. - """ - with self.lock: - self.info.clear() - self.info["refutation"] = {} - self.info["currline"] = {} - self.info["pv"] = {} - self.info["score"] = {} - - def acquire(self, blocking=True): - return self.lock.acquire(blocking) - - def release(self): - return self.lock.release() - - def __enter__(self): - self.acquire() - return self.info - - def __exit__(self, exc_type, exc_value, traceback): - self.release() - - -class Engine: - def __init__(self, *, Executor=concurrent.futures.ThreadPoolExecutor): - self.idle = True - self.pondering = False - self.state_changed = threading.Condition() - self.semaphore = threading.Semaphore() - self.search_started = threading.Event() - - self.board = chess.Board() - self.uci_chess960 = None - self.uci_variant = None - - self.name = None - self.author = None - self.options = OptionMap() - self.uciok = threading.Event() - self.uciok_received = threading.Condition() - - self.readyok_received = threading.Condition() - - self.bestmove = None - self.ponder = None - self.bestmove_received = threading.Event() - - self.return_code = None - self.terminated = threading.Event() - - self.info_handlers = [] - - self.pool = Executor(max_workers=3) - self.process = None - - def on_process_spawned(self, process): - self.process = process - - def send_line(self, line): - LOGGER.debug("%s << %s", self.process, line) - return self.process.send_line(line) - - def on_line_received(self, buf): - LOGGER.debug("%s >> %s", self.process, buf) - - command_and_args = buf.split(None, 1) - if not command_and_args: - return - - if len(command_and_args) >= 1: - if command_and_args[0] == "uciok": - return self._uciok() - elif command_and_args[0] == "readyok": - return self._readyok() - - if len(command_and_args) >= 2: - if command_and_args[0] == "id": - return self._id(command_and_args[1]) - elif command_and_args[0] == "bestmove": - return self._bestmove(command_and_args[1]) - elif command_and_args[0] == "copyprotection": - return self._copyprotection(command_and_args[1]) - elif command_and_args[0] == "registration": - return self._registration(command_and_args[1]) - elif command_and_args[0] == "info": - return self._info(command_and_args[1]) - elif command_and_args[0] == "option": - return self._option(command_and_args[1]) - - def on_terminated(self): - self.return_code = self.process.wait_for_return_code() - self.pool.shutdown(wait=False) - self.terminated.set() - - # Wake up waiting commands. - self.bestmove_received.set() - with self.uciok_received: - self.uciok_received.notify_all() - with self.readyok_received: - self.readyok_received.notify_all() - with self.state_changed: - self.state_changed.notify_all() - - def _id(self, arg): - property_and_arg = arg.split(None, 1) - if property_and_arg[0] == "name": - if len(property_and_arg) >= 2: - self.name = property_and_arg[1] - else: - self.name = "" - return - elif property_and_arg[0] == "author": - if len(property_and_arg) >= 2: - self.author = property_and_arg[1] - else: - self.author = "" - return - - def _uciok(self): - # Set UCI_Chess960 and UCI_Variant default value. - if self.uci_chess960 is None and "UCI_Chess960" in self.options: - self.uci_chess960 = self.options["UCI_Chess960"].default - if self.uci_variant is None and "UCI_Variant" in self.options: - self.uci_variant = self.options["UCI_Variant"].default - - self.uciok.set() - - with self.uciok_received: - self.uciok_received.notify_all() - - def _readyok(self): - with self.readyok_received: - self.readyok_received.notify_all() - - def _bestmove(self, arg): - tokens = arg.split(None, 2) - - self.bestmove = None - if tokens[0] != "(none)": - try: - self.bestmove = self.board.parse_uci(tokens[0]) - except ValueError: - LOGGER.exception("exception parsing bestmove") - - self.ponder = None - if self.bestmove is not None and len(tokens) >= 3 and tokens[1] == "ponder" and tokens[2] != "(none)": - # The ponder move must be legal after the bestmove. Generally, we - # trust the engine on this. But we still have to convert - # non-UCI_Chess960 castling moves. - try: - self.ponder = chess.Move.from_uci(tokens[2]) - if self.ponder.from_square in [chess.E1, chess.E8] and self.ponder.to_square in [chess.C1, chess.C8, chess.G1, chess.G8]: - # Make a copy of the board to avoid race conditions. - board = self.board.copy(stack=False) - board.push(self.bestmove) - self.ponder = board.parse_uci(tokens[2]) - except ValueError: - LOGGER.exception("exception parsing bestmove ponder") - self.ponder = None - - self.bestmove_received.set() - - for info_handler in self.info_handlers: - info_handler.on_bestmove(self.bestmove, self.ponder) - - def _copyprotection(self, arg): - LOGGER.error("engine copyprotection not supported") - - def _registration(self, arg): - LOGGER.error("engine registration not supported") - - def _info(self, arg): - if not self.info_handlers: - return - - # Notify info handlers of start. - for info_handler in self.info_handlers: - info_handler.pre_info(arg) - - # Initialize parser state. - board = None - pv = None - score_kind = None - score_cp = None - score_mate = None - score_lowerbound = False - score_upperbound = False - refutation_move = None - refuted_by = [] - currline_cpunr = None - currline_moves = [] - string = [] - - def end_of_parameter(): - # Parameters with variable length can only be handled when the - # next parameter starts or at the end of the line. - - if pv is not None: - for info_handler in self.info_handlers: - info_handler.pv(pv) - - if score_cp is not None or score_mate is not None: - for info_handler in self.info_handlers: - info_handler.score(score_cp, score_mate, score_lowerbound, score_upperbound) - - if refutation_move is not None: - if refuted_by: - for info_handler in self.info_handlers: - info_handler.refutation(refutation_move, refuted_by) - else: - for info_handler in self.info_handlers: - info_handler.refutation(refutation_move, None) - - if currline_cpunr is not None: - for info_handler in self.info_handlers: - info_handler.currline(currline_cpunr, currline_moves) - - def handle_integer_token(token, fn): - try: - intval = int(token) - except ValueError: - LOGGER.exception("exception parsing integer token from info: %r", arg) - return - - for info_handler in self.info_handlers: - fn(info_handler, intval) - - def handle_float_token(token, fn): - try: - floatval = float(token) - except ValueError: - LOGGER.exception("exception parsing float token from info: %r", arg) - - for info_handler in self.info_handlers: - fn(info_handler, floatval) - - def handle_move_token(token, fn): - try: - move = chess.Move.from_uci(token) - except ValueError: - LOGGER.exception("exception parsing move token from info: %r", arg) - return - - for info_handler in self.info_handlers: - fn(info_handler, move) - - # Find multipv parameter first. - if "multipv" in arg: - current_parameter = None - for token in arg.split(" "): - if token == "string": - break - - if current_parameter == "multipv": - handle_integer_token(token, lambda handler, val: handler.multipv(val)) - - current_parameter = token - - # Parse all other parameters. - current_parameter = None - for token in arg.split(" "): - if current_parameter == "string": - string.append(token) - elif not token: - # Ignore extra spaces. Those can not be directly discarded, - # because they may occur in the string parameter. - pass - elif token in ["depth", "seldepth", "time", "nodes", "pv", "multipv", "score", "currmove", "currmovenumber", "hashfull", "nps", "tbhits", "cpuload", "refutation", "currline", "ebf", "string"]: - end_of_parameter() - current_parameter = token - - pv = None - score_kind = None - score_mate = None - score_cp = None - score_lowerbound = False - score_upperbound = False - refutation_move = None - refuted_by = [] - currline_cpunr = None - currline_moves = [] - - if current_parameter == "pv": - pv = [] - - if current_parameter in ["refutation", "pv", "currline"]: - board = self.board.copy(stack=False) - elif current_parameter == "depth": - handle_integer_token(token, lambda handler, val: handler.depth(val)) - elif current_parameter == "seldepth": - handle_integer_token(token, lambda handler, val: handler.seldepth(val)) - elif current_parameter == "time": - handle_integer_token(token, lambda handler, val: handler.time(val)) - elif current_parameter == "nodes": - handle_integer_token(token, lambda handler, val: handler.nodes(val)) - elif current_parameter == "pv": - try: - pv.append(board.push_uci(token)) - except ValueError: - LOGGER.exception("exception parsing pv from info: %r, position at root: %s", arg, self.board.fen()) - elif current_parameter == "multipv": - # Ignore multipv. It was already parsed before anything else. - pass - elif current_parameter == "score": - if token in ["cp", "mate"]: - score_kind = token - elif token == "lowerbound": - score_lowerbound = True - elif token == "upperbound": - score_upperbound = True - elif score_kind == "cp": - try: - score_cp = int(token) - except ValueError: - LOGGER.exception("exception parsing score cp value from info: %r", arg) - elif score_kind == "mate": - try: - score_mate = int(token) - except ValueError: - LOGGER.exception("exception parsing score mate value from info: %r", arg) - elif current_parameter == "currmove": - handle_move_token(token, lambda handler, val: handler.currmove(val)) - elif current_parameter == "currmovenumber": - handle_integer_token(token, lambda handler, val: handler.currmovenumber(val)) - elif current_parameter == "hashfull": - handle_integer_token(token, lambda handler, val: handler.hashfull(val)) - elif current_parameter == "nps": - handle_integer_token(token, lambda handler, val: handler.nps(val)) - elif current_parameter == "tbhits": - handle_integer_token(token, lambda handler, val: handler.tbhits(val)) - elif current_parameter == "cpuload": - handle_integer_token(token, lambda handler, val: handler.cpuload(val)) - elif current_parameter == "refutation": - try: - if refutation_move is None: - refutation_move = board.push_uci(token) - else: - refuted_by.append(board.push_uci(token)) - except ValueError: - LOGGER.exception("exception parsing refutation from info: %r, position at root: %s", arg, self.board.fen()) - elif current_parameter == "currline": - try: - if currline_cpunr is None: - currline_cpunr = int(token) - else: - currline_moves.append(board.push_uci(token)) - except ValueError: - LOGGER.exception("exception parsing currline from info: %r, position at root: %s", arg, self.board.fen()) - elif current_parameter == "ebf": - handle_float_token(token, lambda handler, val: handler.ebf(val)) - - end_of_parameter() - - if string: - for info_handler in self.info_handlers: - info_handler.string(" ".join(string)) - - # Notify info handlers of end. - for info_handler in self.info_handlers: - info_handler.post_info() - - def _option(self, arg): - current_parameter = None - - name = [] - type = [] - default = [] - min = None - max = None - current_var = None - var = [] - - for token in arg.split(" "): - if token == "name" and not name: - current_parameter = "name" - elif token == "type" and not type: - current_parameter = "type" - elif token == "default" and not default: - current_parameter = "default" - elif token == "min" and min is None: - current_parameter = "min" - elif token == "max" and max is None: - current_parameter = "max" - elif token == "var": - current_parameter = "var" - if current_var is not None: - var.append(" ".join(current_var)) - current_var = [] - elif current_parameter == "name": - name.append(token) - elif current_parameter == "type": - type.append(token) - elif current_parameter == "default": - default.append(token) - elif current_parameter == "var": - current_var.append(token) - elif current_parameter == "min": - try: - min = int(token) - except ValueError: - LOGGER.exception("exception parsing option min") - elif current_parameter == "max": - try: - max = int(token) - except ValueError: - LOGGER.exception("exception parsing option max") - - if current_var is not None: - var.append(" ".join(current_var)) - - type = " ".join(type) - - default = " ".join(default) - if type == "check": - if default == "true": - default = True - elif default == "false": - default = False - else: - default = None - elif type == "spin": - try: - default = int(default) - except ValueError: - LOGGER.exception("exception parsing option spin default") - default = None - - option = Option(" ".join(name), type, default, min, max, var) - self.options[option.name] = option - - def _queue_command(self, command, async_callback): - try: - future = self.pool.submit(command) - except RuntimeError: - raise EngineTerminatedException() - - if async_callback is True: - return future - elif async_callback: - future.add_done_callback(async_callback) - return future - else: - # Avoid calling future.result() without a timeout. In Python 2 - # such a call cannot be interrupted. - while True: - try: - return future.result(timeout=FUTURE_POLL_TIMEOUT) - except concurrent.futures.TimeoutError: - pass - - def uci(self, *, async_callback=None): - """ - Tells the engine to use the UCI interface. - - This is mandatory before any other command. A conforming engine will - send its name, authors and available options. - - :return: Nothing - """ - def command(): - with self.semaphore: - with self.uciok_received: - self.send_line("uci") - self.uciok_received.wait() - - if self.terminated.is_set(): - raise EngineTerminatedException() - - return self._queue_command(command, async_callback) - - def debug(self, on, *, async_callback=None): - """ - Switch the debug mode on or off. - - In debug mode, the engine should send additional information to the - GUI to help with the debugging. Usually, this mode is off by default. - - :param on: bool - - :return: Nothing - """ - def command(): - with self.semaphore: - if on: - self.send_line("debug on") - else: - self.send_line("debug off") - - return self._queue_command(command, async_callback) - - def isready(self, *, async_callback=None): - """ - Command used to synchronize with the engine. - - The engine will respond as soon as it has handled all other queued - commands. - - :return: Nothing - """ - def command(): - with self.semaphore: - with self.readyok_received: - self.send_line("isready") - self.readyok_received.wait() - - if self.terminated.is_set(): - raise EngineTerminatedException() - - return self._queue_command(command, async_callback) - - def _setoption(self, options): - option_lines = [] - - for name, value in options.items(): - if name.lower() == "uci_chess960": - self.uci_chess960 = value - if name.lower() == "uci_variant": - self.uci_variant = value.lower() - - builder = ["setoption name", name, "value"] - if value is True: - builder.append("true") - elif value is False: - builder.append("false") - elif value is None: - builder.append("none") - else: - builder.append(str(value)) - - option_lines.append(" ".join(builder)) - - return option_lines - - def setoption(self, options, *, async_callback=None): - """ - Set values for the engine's available options. - - :param options: A dictionary with option names as keys. - - :return: Nothing - """ - option_lines = self._setoption(options) - - def command(): - with self.semaphore: - with self.readyok_received: - for option_line in option_lines: - self.send_line(option_line) - - self.send_line("isready") - self.readyok_received.wait() - - if self.terminated.is_set(): - raise EngineTerminatedException() - - return self._queue_command(command, async_callback) - - def ucinewgame(self, *, async_callback=None): - """ - Tell the engine that the next search will be from a different game. - - This can be a new game the engine should play or if the engine should - analyse a position from a different game. Using this command is - recommended, but not required. - - :return: Nothing - """ - # Warn if this is called while the engine is still calculating. - with self.state_changed: - if not self.idle: - LOGGER.warning("ucinewgame while engine is busy") - - def command(): - with self.semaphore: - with self.readyok_received: - self.send_line("ucinewgame") - - self.send_line("isready") - self.readyok_received.wait() - - if self.terminated.is_set(): - raise EngineTerminatedException() - - return self._queue_command(command, async_callback) - - def position(self, board, *, async_callback=None): - """ - Set up a given position. - - Rather than sending just the final FEN, the initial FEN and all moves - leading up to the position will be sent. This will allow the engine - to use the move history (for example to detect repetitions). - - If the position is from a new game, it is recommended to use the - *ucinewgame* command before the *position* command. - - :param board: A *chess.Board*. - - :return: Nothing - - :raises: :exc:`~chess.uci.EngineStateException` if the engine is still - calculating. - """ - # Raise if this is called while the engine is still calculating. - with self.state_changed: - if not self.idle: - raise EngineStateException("position command while engine is busy") - - # Set UCI_Variant and UCI_Chess960. - options = {} - - uci_variant = type(board).uci_variant - if uci_variant != (self.uci_variant or "chess"): - if self.uci_variant is None: - LOGGER.warning("engine may not support UCI_Variant or has not been initialized with 'uci' command") - options["UCI_Variant"] = type(board).uci_variant - - if bool(self.uci_chess960) != board.chess960: - if self.uci_chess960 is None: - LOGGER.warning("engine may not support UCI_Chess960 or has not been initialized with 'uci' command") - options["UCI_Chess960"] = board.chess960 - - option_lines = self._setoption(options) - - # Send starting position. - builder = ["position"] - root = board.root() - fen = root.fen() - if uci_variant == "chess" and fen == chess.STARTING_FEN: - builder.append("startpos") - else: - builder.append("fen") - builder.append(root.shredder_fen() if self.uci_chess960 else fen) - - # Send moves. - if board.move_stack: - builder.append("moves") - builder.extend(move.uci() for move in board.move_stack) - - self.board = board.copy(stack=False) - - def command(): - with self.semaphore: - for option_line in option_lines: - self.send_line(option_line) - - self.send_line(" ".join(builder)) - - if self.terminated.is_set(): - raise EngineTerminatedException() - - return self._queue_command(command, async_callback) - - def go(self, *, searchmoves=None, ponder=False, wtime=None, btime=None, winc=None, binc=None, movestogo=None, depth=None, nodes=None, mate=None, movetime=None, infinite=False, async_callback=None): - """ - Start calculating on the current position. - - All parameters are optional, but there should be at least one of - *depth*, *nodes*, *mate*, *infinite* or some time control settings, - so that the engine knows how long to calculate. - - Note that when using *infinite* or *ponder*, the engine will not stop - until it is told to. - - :param searchmoves: Restrict search to moves in this list. - :param ponder: Bool to enable pondering mode. The engine will not stop - pondering in the background until a *stop* command is received. - :param wtime: Integer of milliseconds White has left on the clock. - :param btime: Integer of milliseconds Black has left on the clock. - :param winc: Integer of white Fisher increment. - :param binc: Integer of black Fisher increment. - :param movestogo: Number of moves to the next time control. If this is - not set, but wtime or btime are, then it is sudden death. - :param depth: Search *depth* ply only. - :param nodes: Search so many *nodes* only. - :param mate: Search for a mate in *mate* moves. - :param movetime: Integer. Search exactly *movetime* milliseconds. - :param infinite: Search in the background until a *stop* command is - received. - - :return: A tuple of two elements. The first is the best move according - to the engine. The second is the ponder move. This is the reply - as sent by the engine. Either of the elements may be ``None``. - - :raises: :exc:`~chess.uci.EngineStateException` if the engine is - already calculating. - """ - with self.state_changed: - if not self.idle: - raise EngineStateException("go command while engine is already busy") - - self.idle = False - self.search_started.clear() - self.bestmove_received.clear() - self.pondering = ponder - self.state_changed.notify_all() - - for info_handler in self.info_handlers: - info_handler.on_go() - - builder = ["go"] - - if ponder: - builder.append("ponder") - - if wtime is not None: - builder.append("wtime") - builder.append(str(int(wtime))) - - if btime is not None: - builder.append("btime") - builder.append(str(int(btime))) - - if winc is not None: - builder.append("winc") - builder.append(str(int(winc))) - - if binc is not None: - builder.append("binc") - builder.append(str(int(binc))) - - if movestogo is not None and movestogo > 0: - builder.append("movestogo") - builder.append(str(int(movestogo))) - - if depth is not None: - builder.append("depth") - builder.append(str(int(depth))) - - if nodes is not None: - builder.append("nodes") - builder.append(str(int(nodes))) - - if mate is not None: - builder.append("mate") - builder.append(str(int(mate))) - - if movetime is not None: - builder.append("movetime") - builder.append(str(int(movetime))) - - if infinite: - builder.append("infinite") - - if searchmoves: - builder.append("searchmoves") - for move in searchmoves: - builder.append(self.board.uci(move)) - - def command(): - with self.semaphore: - self.send_line(" ".join(builder)) - self.search_started.set() - - self.bestmove_received.wait() - - with self.state_changed: - self.idle = True - self.state_changed.notify_all() - - if self.terminated.is_set(): - raise EngineTerminatedException() - - return BestMove(self.bestmove, self.ponder) - - return self._queue_command(command, async_callback) - - def stop(self, *, async_callback=None): - """ - Stop calculating as soon as possible. - - :return: Nothing. - """ - # Only send stop when the engine is actually searching. - def command(): - with self.semaphore: - with self.state_changed: - if not self.idle: - self.search_started.wait() - - backoff = 0.5 - while not self.bestmove_received.is_set() and not self.terminated.is_set(): - if self.idle: - break - else: - self.send_line("stop") - self.bestmove_received.wait(backoff) - backoff *= 2 - - self.idle = True - self.state_changed.notify_all() - - if self.terminated.is_set(): - raise EngineTerminatedException() - - return self._queue_command(command, async_callback) - - def ponderhit(self, *, async_callback=None): - """ - May be sent if the expected ponder move has been played. - - The engine should continue searching, but should switch from pondering - to normal search. - - :return: Nothing. - - :raises: :exc:`~chess.uci.EngineStateException` if the engine is not - currently searching in ponder mode. - """ - with self.state_changed: - if self.idle: - raise EngineStateException("ponderhit but not searching") - if not self.pondering: - raise EngineStateException("ponderhit but not pondering") - - self.pondering = False - self.state_changed.notify_all() - - def command(): - self.search_started.wait() - with self.semaphore: - self.send_line("ponderhit") - - return self._queue_command(command, async_callback) - - def quit(self, *, async_callback=None): - """ - Quit the engine as soon as possible. - - :return: The return code of the engine process. - """ - def command(): - with self.semaphore: - self.send_line("quit") - - self.terminated.wait() - return self.return_code - - return self._queue_command(command, async_callback) - - def _queue_termination(self, async_callback): - def wait(): - self.terminated.wait() - return self.return_code - - try: - return self._queue_command(wait, async_callback) - except EngineTerminatedException: - assert self.terminated.is_set() - - future = concurrent.futures.Future() - future.set_result(self.return_code) - if async_callback is True: - return future - elif async_callback: - future.add_done_callback(async_callback) - else: - return future.result() - - def terminate(self, *, async_callback=None): - """ - Terminate the engine. - - This is not an UCI command. It instead tries to terminate the engine - on operating system level, like sending SIGTERM on Unix - systems. If possible, first try the *quit* command. - - :return: The return code of the engine process (or a Future). - """ - self.process.terminate() - return self._queue_termination(async_callback) - - def kill(self, *, async_callback=None): - """ - Kill the engine. - - Forcefully kill the engine process, like by sending SIGKILL. - - :return: The return code of the engine process (or a Future). - """ - self.process.kill() - return self._queue_termination(async_callback) - - def is_alive(self): - """Poll the engine process to check if it is alive.""" - return self.process.is_alive() - - -def popen_engine(command, *, engine_cls=Engine, setpgrp=False, **kwargs): - """ - Opens a local chess engine process. - - No initialization commands are sent, so do not forget to send the - mandatory *uci* command. - - >>> engine = chess.uci.popen_engine("/usr/bin/stockfish") - >>> engine.uci() - >>> engine.name - 'Stockfish 8 64 POPCNT' - >>> engine.author - 'T. Romstad, M. Costalba, J. Kiiski, G. Linscott' - - :param command: - :param engine_cls: - :param setpgrp: Open the engine process in a new process group. This will - stop signals (such as keyboard interrupts) from propagating from the - parent process. Defaults to ``False``. - """ - return _popen_engine(command, engine_cls, setpgrp, **kwargs) - - -def spur_spawn_engine(shell, command, *, engine_cls=Engine): - """ - Spawns a remote engine using a `Spur`_ shell. - - >>> import spur - >>> - >>> shell = spur.SshShell(hostname="localhost", username="username", password="pw") - >>> engine = chess.uci.spur_spawn_engine(shell, ["/usr/bin/stockfish"]) - >>> engine.uci() - - .. _Spur: https://pypi.python.org/pypi/spur - """ - return _spur_spawn_engine(shell, command, engine_cls) diff --git a/chess/variant.py b/chess/variant.py index b6fa52c64..ba4c0f1ce 100644 --- a/chess/variant.py +++ b/chess/variant.py @@ -1,30 +1,20 @@ -# -*- coding: utf-8 -*- -# -# This file is part of the python-chess library. -# Copyright (C) 2016-2018 Niklas Fiekas -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . +from __future__ import annotations import chess -import copy import itertools +import typing + +from typing import Dict, Generic, Hashable, Iterable, Iterator, List, Optional, Type, TypeVar, Union + +if typing.TYPE_CHECKING: + from typing_extensions import Self class SuicideBoard(chess.Board): aliases = ["Suicide", "Suicide chess"] uci_variant = "suicide" + xboard_variant = "suicide" tbw_suffix = ".stbw" tbz_suffix = ".stbz" @@ -38,86 +28,71 @@ class SuicideBoard(chess.Board): one_king = False captures_compulsory = True - def pin_mask(self, color, square): + def pin_mask(self, color: chess.Color, square: chess.Square) -> chess.Bitboard: return chess.BB_ALL - def _attacked_for_king(self, path, occupied): + def _attacked_for_king(self, path: chess.Bitboard, occupied: chess.Bitboard) -> bool: return False - def _castling_uncovers_rank_attack(self, rook_bb, king_to): - return False + def checkers_mask(self) -> chess.Bitboard: + return chess.BB_EMPTY - def is_check(self): + def gives_check(self, move: chess.Move) -> bool: return False - def is_into_check(self, move): + def is_into_check(self, move: chess.Move) -> bool: return False - def was_into_check(self): + def was_into_check(self) -> bool: return False - def _material_balance(self): + def _material_balance(self) -> int: return (chess.popcount(self.occupied_co[self.turn]) - chess.popcount(self.occupied_co[not self.turn])) - def is_variant_end(self): + def is_variant_end(self) -> bool: return not all(has_pieces for has_pieces in self.occupied_co) - def is_variant_win(self): + def is_variant_win(self) -> bool: if not self.occupied_co[self.turn]: return True else: return self.is_stalemate() and self._material_balance() < 0 - def is_variant_loss(self): + def is_variant_loss(self) -> bool: if not self.occupied_co[self.turn]: return False else: return self.is_stalemate() and self._material_balance() > 0 - def is_variant_draw(self): + def is_variant_draw(self) -> bool: if not self.occupied_co[self.turn]: return False else: return self.is_stalemate() and self._material_balance() == 0 - def is_insufficient_material(self): - # Enough material. - if self.knights or self.rooks or self.queens or self.kings: - return False - - # Must have bishops. - if not (self.occupied_co[chess.WHITE] & self.bishops and self.occupied_co[chess.BLACK] & self.bishops): - return False - - # All pawns must be blocked. - w_pawns = self.pawns & self.occupied_co[chess.WHITE] - b_pawns = self.pawns & self.occupied_co[chess.BLACK] - - b_blocked_pawns = chess.shift_up(w_pawns) & b_pawns - w_blocked_pawns = chess.shift_down(b_pawns) & w_pawns - - if (b_blocked_pawns | w_blocked_pawns) != self.pawns: - return False - - turn = self.turn - turn = chess.WHITE - if any(self.generate_pseudo_legal_moves(self.pawns)): + def has_insufficient_material(self, color: chess.Color) -> bool: + if not self.occupied_co[color]: return False - turn = chess.BLACK - if any(self.generate_pseudo_legal_moves(self.pawns)): - return False - self.turn = turn - - # Bishop and pawns of each side are on distinct color complexes. - if self.occupied_co[chess.WHITE] & chess.BB_DARK_SQUARES == 0: - return self.occupied_co[chess.BLACK] & chess.BB_LIGHT_SQUARES == 0 - elif self.occupied_co[chess.WHITE] & chess.BB_LIGHT_SQUARES == 0: - return self.occupied_co[chess.BLACK] & chess.BB_DARK_SQUARES == 0 + elif not self.occupied_co[not color]: + return True + elif self.occupied == self.bishops: + # In a position with only bishops, check if all our bishops can be + # captured. + we_some_on_light = bool(self.occupied_co[color] & chess.BB_LIGHT_SQUARES) + we_some_on_dark = bool(self.occupied_co[color] & chess.BB_DARK_SQUARES) + they_all_on_dark = not (self.occupied_co[not color] & chess.BB_LIGHT_SQUARES) + they_all_on_light = not (self.occupied_co[not color] & chess.BB_DARK_SQUARES) + return (we_some_on_light and they_all_on_dark) or (we_some_on_dark and they_all_on_light) + elif self.occupied == self.knights and chess.popcount(self.knights) == 2: + return ( + self.turn == color ^ + bool(self.occupied_co[chess.WHITE] & chess.BB_LIGHT_SQUARES) ^ + bool(self.occupied_co[chess.BLACK] & chess.BB_DARK_SQUARES)) else: return False - def generate_pseudo_legal_moves(self, from_mask=chess.BB_ALL, to_mask=chess.BB_ALL): + def generate_pseudo_legal_moves(self, from_mask: chess.Bitboard = chess.BB_ALL, to_mask: chess.Bitboard = chess.BB_ALL) -> Iterator[chess.Move]: for move in super().generate_pseudo_legal_moves(from_mask, to_mask): # Add king promotions. if move.promotion == chess.QUEEN: @@ -125,7 +100,7 @@ def generate_pseudo_legal_moves(self, from_mask=chess.BB_ALL, to_mask=chess.BB_A yield move - def generate_legal_moves(self, from_mask=chess.BB_ALL, to_mask=chess.BB_ALL): + def generate_legal_moves(self, from_mask: chess.Bitboard = chess.BB_ALL, to_mask: chess.Bitboard = chess.BB_ALL) -> Iterator[chess.Move]: if self.is_variant_end(): return @@ -143,7 +118,7 @@ def generate_legal_moves(self, from_mask=chess.BB_ALL, to_mask=chess.BB_ALL): if not self.is_en_passant(move): yield move - def is_legal(self, move): + def is_legal(self, move: chess.Move) -> bool: if not super().is_legal(move): return False @@ -152,18 +127,10 @@ def is_legal(self, move): else: return not any(self.generate_pseudo_legal_captures()) - def _transposition_key(self): - if self.has_chess960_castling_rights(): - return (super()._transposition_key(), self.kings & self.promoted) - else: - return super()._transposition_key() - - def board_fen(self, promoted=None): - 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): + def status(self) -> chess.Status: status = super().status() status &= ~chess.STATUS_NO_WHITE_KING status &= ~chess.STATUS_NO_BLACK_KING @@ -174,9 +141,9 @@ def status(self): class GiveawayBoard(SuicideBoard): - aliases = ["Giveaway", "Giveaway chess", "Anti", "Antichess", "Anti chess"] + aliases = ["Giveaway", "Giveaway chess", "Give away", "Give away chess"] uci_variant = "giveaway" - starting_fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1" + xboard_variant = "giveaway" tbw_suffix = ".gtbw" tbz_suffix = ".gtbz" @@ -187,69 +154,87 @@ class GiveawayBoard(SuicideBoard): pawnless_tbw_magic = b"\x7b\xf6\x93\x15" pawnless_tbz_magic = b"\xe4\xcf\xe7\x23" - def __init__(self, fen=starting_fen, chess960=False): - super().__init__(fen, chess960=chess960) - - def reset(self): - super().reset() - self.castling_rights = chess.BB_EMPTY - - def is_variant_win(self): + def is_variant_win(self) -> bool: return not self.occupied_co[self.turn] or self.is_stalemate() - def is_variant_loss(self): + def is_variant_loss(self) -> bool: return False - def is_variant_draw(self): + def is_variant_draw(self) -> bool: return False +class AntichessBoard(GiveawayBoard): + + aliases = ["Antichess", "Anti chess", "Anti"] + uci_variant = "antichess" # Unofficial + starting_fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1" + + def __init__(self, fen: Optional[str] = starting_fen, chess960: bool = False) -> None: + super().__init__(fen, chess960=chess960) + + def reset(self) -> None: + super().reset() + self.castling_rights = chess.BB_EMPTY + + class AtomicBoard(chess.Board): aliases = ["Atomic", "Atom", "Atomic chess"] uci_variant = "atomic" + xboard_variant = "atomic" tbw_suffix = ".atbw" tbz_suffix = ".atbz" tbw_magic = b"\x55\x8d\xa4\x49" tbz_magic = b"\x91\xa9\x5e\xeb" connected_kings = True - one_king = True - def is_variant_end(self): + def is_variant_end(self) -> bool: return not all(self.kings & side for side in self.occupied_co) - def is_variant_win(self): - return self.kings and not self.kings & self.occupied_co[not self.turn] + def is_variant_win(self) -> bool: + return bool(self.kings and not self.kings & self.occupied_co[not self.turn]) + + def is_variant_loss(self) -> bool: + return bool(self.kings and not self.kings & self.occupied_co[self.turn]) - def is_variant_loss(self): - return self.kings and not self.kings & self.occupied_co[self.turn] + def has_insufficient_material(self, color: chess.Color) -> bool: + # Remaining material does not matter if opponent's king is already + # exploded. + if not (self.occupied_co[not color] & self.kings): + return False + + # Bare king can not mate. + if not (self.occupied_co[color] & ~self.kings): + return True - def is_insufficient_material(self): - if self.is_variant_loss() or self.is_variant_win(): + # As long as the opponent's king is not alone, there is always a chance + # their own pieces explode next to it. + if self.occupied_co[not color] & ~self.kings: + # Unless there are only bishops that cannot explode each other. + if self.occupied == self.bishops | self.kings: + if not (self.bishops & self.occupied_co[chess.WHITE] & chess.BB_DARK_SQUARES): + return not (self.bishops & self.occupied_co[chess.BLACK] & chess.BB_LIGHT_SQUARES) + if not (self.bishops & self.occupied_co[chess.WHITE] & chess.BB_LIGHT_SQUARES): + return not (self.bishops & self.occupied_co[chess.BLACK] & chess.BB_DARK_SQUARES) return False - if self.pawns or self.queens: + # Queen or pawn (future queen) can give mate against bare king. + if self.queens or self.pawns: return False + # Single knight, bishop or rook cannot mate against bare king. if chess.popcount(self.knights | self.bishops | self.rooks) == 1: return True - # Only knights. - if self.occupied == (self.kings | self.knights): - return chess.popcount(self.knights) <= 2 and not all(occ & self.knights for occ in self.occupied_co) - - # Only bishops. - if self.occupied == (self.kings | self.bishops): - # All bishops on opposite colors. - if not self.pieces_mask(chess.BISHOP, chess.WHITE) & chess.BB_DARK_SQUARES: - return not self.pieces_mask(chess.BISHOP, chess.BLACK) & chess.BB_LIGHT_SQUARES - if not self.pieces_mask(chess.BISHOP, chess.WHITE) & chess.BB_LIGHT_SQUARES: - return not self.pieces_mask(chess.BISHOP, chess.BLACK) & chess.BB_DARK_SQUARES + # Two knights cannot mate against bare king. + if self.occupied == self.knights | self.kings: + return chess.popcount(self.knights) <= 2 return False - def _attacked_for_king(self, path, occupied): + def _attacked_for_king(self, path: chess.Bitboard, occupied: chess.Bitboard) -> bool: # Can castle onto attacked squares if they are connected to the # enemy king. enemy_kings = self.kings & self.occupied_co[not self.turn] @@ -258,40 +243,41 @@ def _attacked_for_king(self, path, occupied): return super()._attacked_for_king(path, occupied) - def _castling_uncovers_rank_attack(self, rook_bb, king_to): - return (not chess.BB_KING_ATTACKS[king_to] & self.kings & self.occupied_co[not self.turn] and - super()._castling_uncovers_rank_attack(rook_bb, king_to)) - - def _kings_connected(self): + def _kings_connected(self) -> bool: white_kings = self.kings & self.occupied_co[chess.WHITE] black_kings = self.kings & self.occupied_co[chess.BLACK] return any(chess.BB_KING_ATTACKS[sq] & black_kings for sq in chess.scan_forward(white_kings)) - def _push_capture(self, move, capture_square, piece_type, was_promoted): + def _push_capture(self, move: chess.Move, capture_square: chess.Square, piece_type: chess.PieceType, was_promoted: bool) -> None: + explosion_radius = chess.BB_KING_ATTACKS[move.to_square] & ~self.pawns + + # Destroy castling rights. + self.castling_rights &= ~explosion_radius + 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._effective_promoted(): + self.castling_rights &= ~chess.BB_RANK_8 + # Explode the capturing piece. self._remove_piece_at(move.to_square) # Explode all non pawns around. - explosion_radius = chess.BB_KING_ATTACKS[move.to_square] & ~self.pawns for explosion in chess.scan_forward(explosion_radius): self._remove_piece_at(explosion) - # Destroy castling rights. - self.castling_rights &= ~explosion_radius - - def is_check(self): - return not self._kings_connected() and super().is_check() + def checkers_mask(self) -> chess.Bitboard: + return chess.BB_EMPTY if self._kings_connected() else super().checkers_mask() - def was_into_check(self): + def was_into_check(self) -> bool: return not self._kings_connected() and super().was_into_check() - def is_into_check(self, move): + def is_into_check(self, move: chess.Move) -> bool: self.push(move) was_into_check = self.was_into_check() self.pop() return was_into_check - def is_legal(self, move): + def is_legal(self, move: chess.Move) -> bool: if self.is_variant_end(): return False @@ -299,47 +285,54 @@ def is_legal(self, move): return False self.push(move) - legal = self.kings and not self.is_variant_win() and (self.is_variant_loss() or not self.was_into_check()) + legal = bool(self.kings) and not self.is_variant_win() and (self.is_variant_loss() or not self.was_into_check()) self.pop() return legal - def is_stalemate(self): + def is_stalemate(self) -> bool: return not self.is_variant_loss() and super().is_stalemate() - def generate_legal_moves(self, from_mask=chess.BB_ALL, to_mask=chess.BB_ALL): + def generate_legal_moves(self, from_mask: chess.Bitboard = chess.BB_ALL, to_mask: chess.Bitboard = chess.BB_ALL) -> Iterator[chess.Move]: for move in self.generate_pseudo_legal_moves(from_mask, to_mask): if self.is_legal(move): yield move - def status(self): + def status(self) -> chess.Status: status = super().status() status &= ~chess.STATUS_OPPOSITE_CHECK if self.turn == chess.WHITE: status &= ~chess.STATUS_NO_WHITE_KING else: status &= ~chess.STATUS_NO_BLACK_KING + if chess.popcount(self.checkers_mask()) <= 14: + status &= ~chess.STATUS_TOO_MANY_CHECKERS + if self._valid_ep_square() is None: + status &= ~chess.STATUS_IMPOSSIBLE_CHECK return status class KingOfTheHillBoard(chess.Board): - aliases = ["King of the Hill", "KOTH"] + aliases = ["King of the Hill", "KOTH", "kingOfTheHill"] uci_variant = "kingofthehill" + xboard_variant = "kingofthehill" # Unofficial - tbw_suffix = tbz_suffix = None - tbw_magic = tbz_magic = None + tbw_suffix = None + tbz_suffix = None + tbw_magic = None + tbz_magic = None - def is_variant_end(self): - return self.kings & chess.BB_CENTER + def is_variant_end(self) -> bool: + return bool(self.kings & chess.BB_CENTER) - def is_variant_win(self): - return self.kings & self.occupied_co[self.turn] & chess.BB_CENTER + def is_variant_win(self) -> bool: + return bool(self.kings & self.occupied_co[self.turn] & chess.BB_CENTER) - def is_variant_loss(self): - return self.kings & self.occupied_co[not self.turn] & chess.BB_CENTER + def is_variant_loss(self) -> bool: + return bool(self.kings & self.occupied_co[not self.turn] & chess.BB_CENTER) - def is_insufficient_material(self): + def has_insufficient_material(self, color: chess.Color) -> bool: return False @@ -347,67 +340,64 @@ class RacingKingsBoard(chess.Board): aliases = ["Racing Kings", "Racing", "Race", "racingkings"] uci_variant = "racingkings" + xboard_variant = "racingkings" # Unofficial starting_fen = "8/8/8/8/8/8/krbnNBRK/qrbnNBRQ w - - 0 1" - tbw_suffix = tbz_suffix = None - tbw_magic = tbz_magic = None + tbw_suffix = None + tbz_suffix = None + tbw_magic = None + tbz_magic = None - def __init__(self, fen=starting_fen, chess960=False): + def __init__(self, fen: Optional[str] = starting_fen, chess960: bool = False) -> None: super().__init__(fen, chess960=chess960) - def reset(self): + def reset(self) -> None: self.set_fen(type(self).starting_fen) - def _gives_check(self, move): - self.push(move) - gives_check = self.is_check() - self.pop() - return gives_check - - def is_legal(self, move): - return super().is_legal(move) and not self._gives_check(move) + def is_legal(self, move: chess.Move) -> bool: + return super().is_legal(move) and not self.gives_check(move) - def generate_legal_moves(self, from_mask=chess.BB_ALL, to_mask=chess.BB_ALL): + def generate_legal_moves(self, from_mask: chess.Bitboard = chess.BB_ALL, to_mask: chess.Bitboard = chess.BB_ALL) -> Iterator[chess.Move]: for move in super().generate_legal_moves(from_mask, to_mask): - if not self._gives_check(move): + if not self.gives_check(move): yield move - def is_variant_end(self): + def is_variant_end(self) -> bool: if not self.kings & chess.BB_RANK_8: return False - if self.turn == chess.WHITE or self.kings & self.occupied_co[chess.BLACK] & chess.BB_RANK_8: - return True - black_kings = self.kings & self.occupied_co[chess.BLACK] - if not black_kings: + if self.turn == chess.WHITE or black_kings & chess.BB_RANK_8 or not black_kings: return True - black_king = chess.msb(black_kings) - # White has reached the backrank. The game is over if black can not # also reach the backrank on the next move. Check if there are any # safe squares for the king. - targets = chess.BB_KING_ATTACKS[black_king] & chess.BB_RANK_8 + black_king = chess.msb(black_kings) + targets = chess.BB_KING_ATTACKS[black_king] & chess.BB_RANK_8 & ~self.occupied_co[chess.BLACK] return all(self.attackers_mask(chess.WHITE, target) for target in chess.scan_forward(targets)) - def is_variant_draw(self): + def is_variant_draw(self) -> bool: in_goal = self.kings & chess.BB_RANK_8 return all(in_goal & side for side in self.occupied_co) - def is_variant_loss(self): + def is_variant_loss(self) -> bool: return self.is_variant_end() and not self.kings & self.occupied_co[self.turn] & chess.BB_RANK_8 - def is_variant_win(self): - return self.is_variant_end() and self.kings & self.occupied_co[self.turn] & chess.BB_RANK_8 + def is_variant_win(self) -> bool: + in_goal = self.kings & chess.BB_RANK_8 + return ( + self.is_variant_end() and + bool(in_goal & self.occupied_co[self.turn]) and + not in_goal & self.occupied_co[not self.turn]) - def is_insufficient_material(self): + def has_insufficient_material(self, color: chess.Color) -> bool: return False - def status(self): + def status(self) -> chess.Status: status = super().status() if self.is_check(): - status |= chess.STATUS_RACE_CHECK + status |= chess.STATUS_RACE_CHECK | chess.STATUS_TOO_MANY_CHECKERS | chess.STATUS_IMPOSSIBLE_CHECK if self.turn == chess.BLACK and all(self.occupied_co[co] & self.kings & chess.BB_RANK_8 for co in chess.COLORS): status |= chess.STATUS_RACE_OVER if self.pawns: @@ -428,33 +418,239 @@ class HordeBoard(chess.Board): aliases = ["Horde", "Horde chess"] uci_variant = "horde" + xboard_variant = "horde" # Unofficial starting_fen = "rnbqkbnr/pppppppp/8/1PP2PP1/PPPPPPPP/PPPPPPPP/PPPPPPPP/PPPPPPPP w kq - 0 1" - tbw_suffix = tbz_suffix = None - tbw_magic = tbz_magic = None + tbw_suffix = None + tbz_suffix = None + tbw_magic = None + tbz_magic = None - def __init__(self, fen=starting_fen, chess960=False): + def __init__(self, fen: Optional[str] = starting_fen, chess960: bool = False) -> None: super().__init__(fen, chess960=chess960) - def reset(self): + def reset(self) -> None: self.set_fen(type(self).starting_fen) - def is_variant_end(self): + def is_variant_end(self) -> bool: return not all(has_pieces for has_pieces in self.occupied_co) - def is_variant_draw(self): + def is_variant_draw(self) -> bool: return not self.occupied - def is_variant_loss(self): - return self.occupied and not self.occupied_co[self.turn] + def is_variant_loss(self) -> bool: + return bool(self.occupied) and not self.occupied_co[self.turn] - def is_variant_win(self): - return self.occupied and not self.occupied_co[not self.turn] + def is_variant_win(self) -> bool: + return bool(self.occupied) and not self.occupied_co[not self.turn] - def is_insufficient_material(self): - return False + def has_insufficient_material(self, color: chess.Color) -> bool: + # The side with the king can always win by capturing the Horde. + if color == chess.BLACK: + return False + + # See https://github.com/stevepapazis/horde-insufficient-material-tests + # for how the following has been derived. + + white = self.occupied_co[chess.WHITE] + queens = chess.popcount(white & self.queens) + pawns = chess.popcount(white & self.pawns) + rooks = chess.popcount(white & self.rooks) + bishops = chess.popcount(white & self.bishops) + knights = chess.popcount(white & self.knights) + + # Two same color bishops suffice to cover all the light and dark + # squares around the enemy king. + horde_darkb = chess.popcount(chess.BB_DARK_SQUARES & white & self.bishops) + horde_lightb = chess.popcount(chess.BB_LIGHT_SQUARES & white & self.bishops) + horde_bishop_co = chess.WHITE if horde_lightb >= 1 else chess.BLACK + horde_num = ( + pawns + knights + rooks + queens + + (horde_darkb if horde_darkb <= 2 else 2) + + (horde_lightb if horde_lightb <= 2 else 2) + ) + + pieces = self.occupied_co[chess.BLACK] + pieces_pawns = chess.popcount(pieces & self.pawns) + pieces_bishops = chess.popcount(pieces & self.bishops) + pieces_knights = chess.popcount(pieces & self.knights) + pieces_rooks = chess.popcount(pieces & self.rooks) + pieces_queens = chess.popcount(pieces & self.queens) + pieces_darkb = chess.popcount(chess.BB_DARK_SQUARES & pieces & self.bishops) + pieces_lightb = chess.popcount(chess.BB_LIGHT_SQUARES & pieces & self.bishops) + pieces_num = chess.popcount(pieces) + + def pieces_oppositeb_of(square_color: chess.Color) -> int: + return pieces_darkb if square_color == chess.WHITE else pieces_lightb + + def pieces_sameb_as(square_color: chess.Color) -> int: + return pieces_lightb if square_color == chess.WHITE else pieces_darkb + + def pieces_of_type_not(piece: int) -> int: + return pieces_num - piece + + def has_bishop_pair(side: chess.Color) -> bool: + return (horde_lightb >= 1 and horde_darkb >= 1) if side == chess.WHITE else (pieces_lightb >= 1 and pieces_darkb >= 1) + + if horde_num == 0: + return True + if horde_num >= 4: + # Four or more white pieces can always deliver mate. + return False + if (pawns >= 1 or queens >= 1) and horde_num >= 2: + # Pawns/queens are never insufficient material when paired with any other + # piece (a pawn promotes to a queen and delivers mate). + return False + if rooks >= 1 and horde_num >= 2: + # A rook is insufficient material only when it is paired with a bishop + # against a lone king. The horde can mate in any other case. + # A rook on A1 and a bishop on C3 mate a king on B1 when there is a + # friendly pawn/opposite-color-bishop/rook/queen on C2. + # A rook on B8 and a bishop C3 mate a king on A1 when there is a friendly + # knight on A2. + if not (horde_num == 2 and rooks == 1 and bishops == 1 and pieces_of_type_not(pieces_sameb_as(horde_bishop_co)) == 1): + return False - def status(self): + if horde_num == 1: + if pieces_num == 1: + # A lone piece cannot mate a lone king. + return True + elif queens == 1: + # The horde has a lone queen. + # A lone queen mates a king on A1 bounded by: + # - a pawn/rook on A2 + # - two same color bishops on A2, B1 + # We ignore every other mating case, since it can be reduced to + # the two previous cases (e.g. a black pawn on A2 and a black + # bishop on B1). + return not ( + pieces_pawns >= 1 or + pieces_rooks >= 1 or + pieces_lightb >= 2 or + pieces_darkb >= 2 + ) + elif pawns == 1: + # Promote the pawn to a queen or a knight and check whether + # white can mate. + pawn_square = chess.SquareSet(self.pawns & white).pop() + promote_to_queen = self.copy(stack=False) + promote_to_queen.set_piece_at(pawn_square, chess.Piece(chess.QUEEN, chess.WHITE)) + promote_to_knight = self.copy(stack=False) + promote_to_knight.set_piece_at(pawn_square, chess.Piece(chess.KNIGHT, chess.WHITE)) + return promote_to_queen.has_insufficient_material(chess.WHITE) and promote_to_knight.has_insufficient_material(chess.WHITE) + elif rooks == 1: + # A lone rook mates a king on A8 bounded by a pawn/rook on A7 and a + # pawn/knight on B7. We ignore every other case, since it can be + # reduced to the two previous cases. + # (e.g. three pawns on A7, B7, C7) + return not ( + pieces_pawns >= 2 or + (pieces_rooks >= 1 and pieces_pawns >= 1) or + (pieces_rooks >= 1 and pieces_knights >= 1) or + (pieces_pawns >= 1 and pieces_knights >= 1) + ) + elif bishops == 1: + # The horde has a lone bishop. + return not ( + # The king can be mated on A1 if there is a pawn/opposite-color-bishop + # on A2 and an opposite-color-bishop on B1. + # If black has two or more pawns, white gets the benefit of the doubt; + # there is an outside chance that white promotes its pawns to + # opposite-color-bishops and selfmates theirself. + # Every other case that the king is mated by the bishop requires that + # black has two pawns or two opposite-color-bishop or a pawn and an + # opposite-color-bishop. + # For example a king on A3 can be mated if there is + # a pawn/opposite-color-bishop on A4, a pawn/opposite-color-bishop on + # B3, a pawn/bishop/rook/queen on A2 and any other piece on B2. + pieces_oppositeb_of(horde_bishop_co) >= 2 or + (pieces_oppositeb_of(horde_bishop_co) >= 1 and pieces_pawns >= 1) or + pieces_pawns >= 2 + ) + elif knights == 1: + # The horde has a lone knight. + return not ( + # The king on A1 can be smother mated by a knight on C2 if there is + # a pawn/knight/bishop on B2, a knight/rook on B1 and any other piece + # on A2. + # Moreover, when black has four or more pieces and two of them are + # pawns, black can promote their pawns and selfmate theirself. + pieces_num >= 4 and ( + pieces_knights >= 2 or pieces_pawns >= 2 or + (pieces_rooks >= 1 and pieces_knights >= 1) or + (pieces_rooks >= 1 and pieces_bishops >= 1) or + (pieces_knights >= 1 and pieces_bishops >= 1) or + (pieces_rooks >= 1 and pieces_pawns >= 1) or + (pieces_knights >= 1 and pieces_pawns >= 1) or + (pieces_bishops >= 1 and pieces_pawns >= 1) or + (has_bishop_pair(chess.BLACK) and pieces_pawns >= 1) + ) and + (pieces_of_type_not(pieces_darkb) >= 3 if pieces_darkb >= 2 else True) and + (pieces_of_type_not(pieces_lightb) >= 3 if pieces_lightb >= 2 else True) + ) + elif horde_num == 2: # By this point, we only need to deal with white's minor pieces. + if pieces_num == 1: + # Two minor pieces cannot mate a lone king. + return True + elif knights == 2: + # A king on A1 is mated by two knights, if it is obstructed by a + # pawn/bishop/knight on B2. On the other hand, if black only has + # major pieces it is a draw. + return not (pieces_pawns + pieces_bishops + pieces_knights >= 1) + elif has_bishop_pair(chess.WHITE): + return not ( + # A king on A1 obstructed by a pawn/bishop on A2 is mated + # by the bishop pair. + pieces_pawns >= 1 or pieces_bishops >= 1 or + # A pawn/bishop/knight on B4, a pawn/bishop/rook/queen on + # A4 and the king on A3 enable Boden's mate by the bishop + # pair. In every other case white cannot win. + (pieces_knights >= 1 and pieces_rooks + pieces_queens >= 1) + ) + elif bishops >= 1 and knights >= 1: + # The horde has a bishop and a knight. + return not ( + # A king on A1 obstructed by a pawn/opposite-color-bishop on + # A2 is mated by a knight on D2 and a bishop on C3. + pieces_pawns >= 1 or pieces_oppositeb_of(horde_bishop_co) >= 1 or + # A king on A1 bounded by two friendly pieces on A2 and B1 is + # mated when the knight moves from D4 to C2 so that both the + # knight and the bishop deliver check. + pieces_of_type_not(pieces_sameb_as(horde_bishop_co)) >= 3 + ) + else: + # The horde has two or more bishops on the same color. + # White can only win if black has enough material to obstruct + # the squares of the opposite color around the king. + return not ( + # A king on A1 obstructed by a pawn/opposite-bishop/knight + # on A2 and a opposite-bishop/knight on B1 is mated by two + # bishops on B2 and C3. This position is theoretically + # achievable even when black has two pawns or when they + # have a pawn and an opposite color bishop. + (pieces_pawns >= 1 and pieces_oppositeb_of(horde_bishop_co) >= 1) or + (pieces_pawns >= 1 and pieces_knights >= 1) or + (pieces_oppositeb_of(horde_bishop_co) >= 1 and pieces_knights >= 1) or + (pieces_oppositeb_of(horde_bishop_co) >= 2) or + pieces_knights >= 2 or + pieces_pawns >= 2 + # In every other case, white can only draw. + ) + elif horde_num == 3: + # A king in the corner is mated by two knights and a bishop or three + # knights or the bishop pair and a knight/bishop. + if (knights == 2 and bishops == 1) or knights == 3 or has_bishop_pair(chess.WHITE): + return False + else: + # White has two same color bishops and a knight. + # A king on A1 is mated by a bishop on B2, a bishop on C1 and a + # knight on C3, as long as there is another black piece to waste + # a tempo. + return pieces_num == 1 + + return True + + def status(self) -> chess.Status: status = super().status() status &= ~chess.STATUS_NO_WHITE_KING @@ -462,7 +658,7 @@ def status(self): status &= ~chess.STATUS_TOO_MANY_WHITE_PIECES status &= ~chess.STATUS_TOO_MANY_WHITE_PAWNS - if not self.pawns & chess.BB_RANK_8 and not self.occupied_co[chess.BLACK] & chess.BB_RANK_1: + if not self.pawns & chess.BB_RANK_8 and not self.occupied_co[chess.BLACK] & self.pawns & chess.BB_RANK_1: status &= ~chess.STATUS_PAWNS_ON_BACKRANK if self.occupied_co[chess.WHITE] & self.kings: @@ -471,233 +667,269 @@ def status(self): return status +ThreeCheckBoardT = TypeVar("ThreeCheckBoardT", bound="ThreeCheckBoard") + +class _ThreeCheckBoardState: + def __init__(self, board: ThreeCheckBoard) -> None: + self.remaining_checks_w = board.remaining_checks[chess.WHITE] + self.remaining_checks_b = board.remaining_checks[chess.BLACK] + + def restore(self, board: ThreeCheckBoard) -> None: + board.remaining_checks[chess.WHITE] = self.remaining_checks_w + board.remaining_checks[chess.BLACK] = self.remaining_checks_b + class ThreeCheckBoard(chess.Board): - aliases = ["Three-check", "Three check", "Threecheck", "Three check chess"] + aliases = ["Three-check", "Three check", "Threecheck", "Three check chess", "3-check", "3 check", "3check"] uci_variant = "3check" + xboard_variant = "3check" starting_fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 3+3 0 1" - tbw_suffix = tbz_suffix = None - tbw_magic = tbz_magic = None + tbw_suffix = None + tbz_suffix = None + tbw_magic = None + tbz_magic = None - def __init__(self, fen=starting_fen, chess960=False): + def __init__(self, fen: Optional[str] = starting_fen, chess960: bool = False) -> None: self.remaining_checks = [3, 3] + self._three_check_stack: List[_ThreeCheckBoardState] = [] super().__init__(fen, chess960=chess960) - def reset_board(self): + def clear_stack(self) -> None: + super().clear_stack() + self._three_check_stack.clear() + + def reset_board(self) -> None: super().reset_board() self.remaining_checks[chess.WHITE] = 3 self.remaining_checks[chess.BLACK] = 3 - def clear_board(self): + def clear_board(self) -> None: super().clear_board() self.remaining_checks[chess.WHITE] = 3 self.remaining_checks[chess.BLACK] = 3 - def push(self, move): + def push(self, move: chess.Move) -> None: + self._three_check_stack.append(_ThreeCheckBoardState(self)) super().push(move) if self.is_check(): self.remaining_checks[not self.turn] -= 1 - def pop(self): - was_in_check = self.is_check() + def pop(self) -> chess.Move: move = super().pop() - if was_in_check: - self.remaining_checks[self.turn] += 1 + self._three_check_stack.pop().restore(self) return move - def is_insufficient_material(self): - return self.occupied == self.kings + def has_insufficient_material(self, color: chess.Color) -> bool: + # Any remaining piece can give check. + return not (self.occupied_co[color] & ~self.kings) - def set_epd(self, epd): - # Split into 5 or 6 parts. + def set_epd(self, epd: str) -> Dict[str, Union[None, str, int, float, chess.Move, List[chess.Move]]]: parts = epd.strip().rstrip(";").split(None, 5) - if len(parts) < 5: - raise ValueError("three-check epd should consist of at least 5 parts: {}".format(repr(epd))) # Parse ops. if len(parts) > 5: - operations = self._parse_epd_ops(parts.pop(), lambda: type(self)(" ".join(parts + " 0 1"))) + operations = self._parse_epd_ops(parts.pop(), lambda: type(self)(" ".join(parts) + " 0 1")) + parts.append(str(operations["hmvc"]) if "hmvc" in operations else "0") + parts.append(str(operations["fmvn"]) if "fmvn" in operations else "1") + self.set_fen(" ".join(parts)) + return operations else: - operations = {} - - # Create a full FEN an parse it. - parts.append(str(operations["hmvc"]) if "hmvc" in operations else "0") - parts.append(str(operations["fmvn"]) if "fmvn" in operations else "1") - self.set_fen(" ".join(parts)) - - return operations + self.set_fen(epd) + return {} - def set_fen(self, fen): + def set_fen(self, fen: str) -> None: parts = fen.split() - if len(parts) != 7: - raise ValueError("three-check fen should consist of 7 parts: {}".format(repr(fen))) # Extract check part. - if parts[6][0] == "+": - check_part = parts.pop()[1:] + if len(parts) >= 7 and parts[6][0] == "+": + check_part = parts.pop(6) try: - w, b = check_part.split("+", 1) + w, b = check_part[1:].split("+", 1) wc, bc = 3 - int(w), 3 - int(b) except ValueError: - raise ValueError("invalid check part in lichess three-check fen: {}".format(repr(check_part))) - else: + raise ValueError(f"invalid check part in lichess three-check fen: {check_part!r}") + elif len(parts) >= 5 and "+" in parts[4]: check_part = parts.pop(4) try: w, b = check_part.split("+", 1) wc, bc = int(w), int(b) except ValueError: - raise ValueError("invalid check part in three-check fen: {}".format(repr(check_part))) + raise ValueError(f"invalid check part in three-check fen: {check_part!r}") + else: + wc, bc = 3, 3 # Set fen. super().set_fen(" ".join(parts)) self.remaining_checks[chess.WHITE] = wc self.remaining_checks[chess.BLACK] = bc - def epd(self, shredder=False, en_passant="legal", promoted=None, **operations): + 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), - "%d+%d" % (max(self.remaining_checks[chess.WHITE], 0), - max(self.remaining_checks[chess.BLACK], 0))] + "{:d}+{:d}".format(max(self.remaining_checks[chess.WHITE], 0), + max(self.remaining_checks[chess.BLACK], 0))] if operations: epd.append(self._epd_operations(operations)) return " ".join(epd) - def is_variant_end(self): + def is_variant_end(self) -> bool: return any(remaining_checks <= 0 for remaining_checks in self.remaining_checks) - def is_variant_draw(self): + def is_variant_draw(self) -> bool: return self.remaining_checks[chess.WHITE] <= 0 and self.remaining_checks[chess.BLACK] <= 0 - def is_variant_loss(self): + def is_variant_loss(self) -> bool: return self.remaining_checks[not self.turn] <= 0 < self.remaining_checks[self.turn] - def is_variant_win(self): + def is_variant_win(self) -> bool: return self.remaining_checks[self.turn] <= 0 < self.remaining_checks[not self.turn] - def is_irreversible(self, move): - if super().is_irreversible(move): - return True - - self.push(move) - gives_check = self.is_check() - self.pop() - return gives_check + def is_irreversible(self, move: chess.Move) -> bool: + return super().is_irreversible(move) or self.gives_check(move) - def _transposition_key(self): + def _transposition_key(self) -> Hashable: return (super()._transposition_key(), self.remaining_checks[chess.WHITE], self.remaining_checks[chess.BLACK]) - def copy(self, stack=True): + def copy(self, *, stack: Union[bool, int] = True) -> Self: board = super().copy(stack=stack) - board.remaining_checks[chess.WHITE] = self.remaining_checks[chess.WHITE] - board.remaining_checks[chess.BLACK] = self.remaining_checks[chess.BLACK] + board.remaining_checks = self.remaining_checks.copy() + if stack: + stack = len(self.move_stack) if stack is True else stack + board._three_check_stack = self._three_check_stack[-stack:] return board - def mirror(self): + def root(self) -> Self: + if self._three_check_stack: + board = super().root() + self._three_check_stack[0].restore(board) + return board + else: + return self.copy(stack=False) + + def mirror(self) -> Self: board = super().mirror() board.remaining_checks[chess.WHITE] = self.remaining_checks[chess.BLACK] board.remaining_checks[chess.BLACK] = self.remaining_checks[chess.WHITE] return board +CrazyhouseBoardT = TypeVar("CrazyhouseBoardT", bound="CrazyhouseBoard") + +class _CrazyhouseBoardState: + def __init__(self, board: CrazyhouseBoard) -> None: + self.pockets_w = board.pockets[chess.WHITE].copy() + self.pockets_b = board.pockets[chess.BLACK].copy() + + def restore(self, board: CrazyhouseBoard) -> None: + board.pockets[chess.WHITE] = self.pockets_w + board.pockets[chess.BLACK] = self.pockets_b + +CrazyhousePocketT = TypeVar("CrazyhousePocketT", bound="CrazyhousePocket") + class CrazyhousePocket: + """A Crazyhouse pocket with a counter for each piece type.""" - def __init__(self, symbols=""): - self.pieces = {} + def __init__(self, symbols: Iterable[str] = "") -> None: + self.reset() for symbol in symbols: self.add(chess.PIECE_SYMBOLS.index(symbol)) - def add(self, pt): - self.pieces[pt] = self.pieces.get(pt, 0) + 1 + def reset(self) -> None: + """Clears the pocket.""" + self._pieces = [-1, 0, 0, 0, 0, 0, 0] - def remove(self, pt): - self.pieces[pt] -= 1 + def add(self, piece_type: chess.PieceType) -> None: + """Adds a piece of the given type to this pocket.""" + self._pieces[piece_type] += 1 - def count(self, piece_type): - return self.pieces.get(piece_type, 0) + def remove(self, piece_type: chess.PieceType) -> None: + """Removes a piece of the given type from this pocket.""" + assert self._pieces[piece_type], f"cannot remove {chess.piece_symbol(piece_type)} from {self!r}" + self._pieces[piece_type] -= 1 - def reset(self): - self.pieces.clear() + def count(self, piece_type: chess.PieceType) -> int: + """Returns the number of pieces of the given type in the pocket.""" + return self._pieces[piece_type] - def __str__(self): - return "".join(chess.PIECE_SYMBOLS[pt] * self.count(pt) for pt in reversed(chess.PIECE_TYPES)) + def __str__(self) -> str: + return "".join(chess.piece_symbol(pt) * self.count(pt) for pt in reversed(chess.PIECE_TYPES)) - def __len__(self): - return sum(self.pieces.values()) + def __len__(self) -> int: + return sum(self._pieces[1:]) - def __repr__(self): - return "CrazyhousePocket('{}')".format(str(self)) + def __repr__(self) -> str: + return f"CrazyhousePocket('{self}')" - def copy(self): + def copy(self) -> Self: + """Returns a copy of this pocket.""" pocket = type(self)() - pocket.pieces = copy.copy(self.pieces) + pocket._pieces = self._pieces[:] return pocket class CrazyhouseBoard(chess.Board): aliases = ["Crazyhouse", "Crazy House", "House", "ZH"] uci_variant = "crazyhouse" + xboard_variant = "crazyhouse" starting_fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR[] w KQkq - 0 1" - tbw_suffix = tbz_suffix = None - tbw_magic = tbz_magic = None + tbw_suffix = None + tbz_suffix = None + tbw_magic = None + tbz_magic = None - def __init__(self, fen=starting_fen, chess960=False): + def __init__(self, fen: Optional[str] = starting_fen, chess960: bool = False) -> None: self.pockets = [CrazyhousePocket(), CrazyhousePocket()] + self._crazyhouse_stack: List[_CrazyhouseBoardState] = [] super().__init__(fen, chess960=chess960) - def reset_board(self): + def clear_stack(self) -> None: + super().clear_stack() + self._crazyhouse_stack.clear() + + def reset_board(self) -> None: super().reset_board() self.pockets[chess.WHITE].reset() self.pockets[chess.BLACK].reset() - def clear_board(self): + def clear_board(self) -> None: super().clear_board() self.pockets[chess.WHITE].reset() self.pockets[chess.BLACK].reset() - def push(self, move): - if move.drop: - self.pockets[self.turn].remove(move.drop) - + def push(self, move: chess.Move) -> None: + self._crazyhouse_stack.append(_CrazyhouseBoardState(self)) super().push(move) - - def pop(self): - move = super().pop() if move.drop: - self.pockets[self.turn].add(move.drop) - elif self.is_capture(move): - if self.is_en_passant(move) or chess.BB_SQUARES[move.to_square] & self.promoted: - self.pockets[self.turn].remove(chess.PAWN) - else: - self.pockets[self.turn].remove(self.piece_type_at(move.to_square)) - return move + self.pockets[not self.turn].remove(move.drop) - def _push_capture(self, move, capture_square, piece_type, was_promoted): + def _push_capture(self, move: chess.Move, capture_square: chess.Square, piece_type: chess.PieceType, was_promoted: bool) -> None: if was_promoted: self.pockets[self.turn].add(chess.PAWN) else: self.pockets[self.turn].add(piece_type) - def can_claim_fifty_moves(self): - return False + def pop(self) -> chess.Move: + move = super().pop() + self._crazyhouse_stack.pop().restore(self) + return move - def is_seventyfive_moves(self): + def _is_halfmoves(self, n: int) -> bool: + # No draw by 50-move rule or 75-move rule. return False - def is_irreversible(self, move): - backrank = chess.BB_RANK_1 if self.turn == chess.WHITE else chess.BB_RANK_8 - castling_rights = self.clean_castling_rights() & backrank - return (castling_rights and chess.BB_SQUARES[move.from_square] & self.kings & ~self.promoted or - castling_rights & chess.BB_SQUARES[move.from_square] or - castling_rights & chess.BB_SQUARES[move.to_square]) + 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): + 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): + def legal_drop_squares_mask(self) -> chess.Bitboard: king = self.king(self.turn) if king is None: return ~self.occupied @@ -707,63 +939,71 @@ def legal_drop_squares_mask(self): if not king_attackers: return ~self.occupied elif chess.popcount(king_attackers) == 1: - return chess.BB_BETWEEN[king][chess.msb(king_attackers)] & ~self.occupied + return chess.between(king, chess.msb(king_attackers)) & ~self.occupied else: return chess.BB_EMPTY - def legal_drop_squares(self): - return chess.SquareSet(self.legal_drop_squares_mask()) + def legal_drop_squares(self) -> chess.SquareSet: + """ + Gets the squares where the side to move could legally drop a piece. + Does *not* check whether they actually have a suitable piece in their + pocket. - def is_pseudo_legal(self, move): - if move.drop and move.from_square == move.to_square: - if move.drop == chess.KING: - return False + It is legal to drop a checkmate. - if chess.BB_SQUARES[move.to_square] & self.occupied: - return False - - if move.drop == chess.PAWN and chess.BB_SQUARES[move.to_square] & chess.BB_BACKRANKS: - return False + Returns a :class:`set of squares `. + """ + return chess.SquareSet(self.legal_drop_squares_mask()) - return self.pockets[self.turn].count(move.drop) > 0 + def is_pseudo_legal(self, move: chess.Move) -> bool: + if move.drop and move.from_square == move.to_square: + return ( + move.drop != chess.KING and + not chess.BB_SQUARES[move.to_square] & self.occupied and + not (move.drop == chess.PAWN and chess.BB_SQUARES[move.to_square] & chess.BB_BACKRANKS) and + self.pockets[self.turn].count(move.drop) > 0) else: return super().is_pseudo_legal(move) - def is_legal(self, move): + def is_legal(self, move: chess.Move) -> bool: if move.drop: - return self.is_pseudo_legal(move) and self.legal_drop_squares_mask() & chess.BB_SQUARES[move.to_square] + return self.is_pseudo_legal(move) and bool(self.legal_drop_squares_mask() & chess.BB_SQUARES[move.to_square]) else: return super().is_legal(move) - def generate_pseudo_legal_drops(self, to_mask=chess.BB_ALL): - for to_square in chess.scan_forward(to_mask & ~self.occupied): - for pt, count in self.pockets[self.turn].pieces.items(): - if count and (pt != chess.PAWN or not chess.BB_BACKRANKS & chess.BB_SQUARES[to_square]): + def generate_pseudo_legal_drops(self, to_mask: chess.Bitboard = chess.BB_ALL) -> Iterator[chess.Move]: + for pt in chess.PIECE_TYPES: + if self.pockets[self.turn].count(pt): + for to_square in chess.scan_forward(to_mask & ~self.occupied & (~chess.BB_BACKRANKS if pt == chess.PAWN else chess.BB_ALL)): yield chess.Move(to_square, to_square, drop=pt) - def generate_legal_drops(self, to_mask=chess.BB_ALL): + def generate_legal_drops(self, to_mask: chess.Bitboard = chess.BB_ALL) -> Iterator[chess.Move]: return self.generate_pseudo_legal_drops(to_mask=self.legal_drop_squares_mask() & to_mask) - def generate_legal_moves(self, from_mask=chess.BB_ALL, to_mask=chess.BB_ALL): + def generate_legal_moves(self, from_mask: chess.Bitboard = chess.BB_ALL, to_mask: chess.Bitboard = chess.BB_ALL) -> Iterator[chess.Move]: return itertools.chain( super().generate_legal_moves(from_mask, to_mask), self.generate_legal_drops(from_mask & to_mask)) - def parse_san(self, san): + def parse_san(self, san: str) -> chess.Move: if "@" in san: - uci = san.rstrip("+# ") + uci = san.rstrip("+#") if uci[0] == "@": uci = "P" + uci move = chess.Move.from_uci(uci) if not self.is_legal(move): - raise ValueError("illegal drop san: {} in {}".format(repr(san), self.fen())) + raise chess.IllegalMoveError(f"illegal drop san: {san!r} in {self.fen()}") return move else: return super().parse_san(san) - def is_insufficient_material(self): + def has_insufficient_material(self, color: chess.Color) -> bool: + # In practice, no material can leave the game, but this is easy to + # implement, anyway. Note that bishops can be captured and put onto + # a different color complex. return ( chess.popcount(self.occupied) + sum(len(pocket) for pocket in self.pockets) <= 3 and + not self._effective_promoted() and not self.pawns and not self.rooks and not self.queens and @@ -771,13 +1011,13 @@ def is_insufficient_material(self): not any(pocket.count(chess.ROOK) for pocket in self.pockets) and not any(pocket.count(chess.QUEEN) for pocket in self.pockets)) - def set_fen(self, fen): + def set_fen(self, fen: str) -> None: position_part, info_part = fen.split(None, 1) # Transform to lichess-style ZH FEN. if position_part.endswith("]"): if position_part.count("/") != 7: - raise ValueError("expected 8 rows in position part of zh fen: {}", format(repr(fen))) + raise ValueError(f"expected 8 rows in position part of zh fen: {fen!r}") position_part = position_part[:-1].replace("[", "/", 1) # Split off pocket part. @@ -795,29 +1035,35 @@ def set_fen(self, fen): self.pockets[chess.WHITE] = white_pocket self.pockets[chess.BLACK] = black_pocket - def board_fen(self, promoted=None): - if promoted is None: - promoted = True - return super().board_fen(promoted=promoted) - - def epd(self, shredder=False, en_passant="legal", promoted=None, **operations): + 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) - return "%s[%s%s] %s" % (board_part, str(self.pockets[chess.WHITE]).upper(), str(self.pockets[chess.BLACK]), info_part) + return f"{board_part}[{str(self.pockets[chess.WHITE]).upper()}{self.pockets[chess.BLACK]}] {info_part}" - def copy(self, stack=True): + def copy(self, *, stack: Union[bool, int] = True) -> Self: board = super().copy(stack=stack) board.pockets[chess.WHITE] = self.pockets[chess.WHITE].copy() board.pockets[chess.BLACK] = self.pockets[chess.BLACK].copy() + if stack: + stack = len(self.move_stack) if stack is True else stack + board._crazyhouse_stack = self._crazyhouse_stack[-stack:] return board - def mirror(self): + def root(self) -> Self: + if self._crazyhouse_stack: + board = super().root() + self._crazyhouse_stack[0].restore(board) + return board + else: + return self.copy(stack=False) + + def mirror(self) -> Self: board = super().mirror() board.pockets[chess.WHITE] = self.pockets[chess.BLACK].copy() board.pockets[chess.BLACK] = self.pockets[chess.WHITE].copy() return board - def status(self): + def status(self) -> chess.Status: status = super().status() if chess.popcount(self.pawns) + self.pockets[chess.WHITE].count(chess.PAWN) + self.pockets[chess.BLACK].count(chess.PAWN) <= 16: @@ -831,9 +1077,9 @@ def status(self): return status -VARIANTS = [ +VARIANTS: List[Type[chess.Board]] = [ chess.Board, - SuicideBoard, GiveawayBoard, + SuicideBoard, GiveawayBoard, AntichessBoard, AtomicBoard, KingOfTheHillBoard, RacingKingsBoard, @@ -843,13 +1089,12 @@ def status(self): ] -def find_variant(name): - """Looks for a variant board class by variant name.""" +def find_variant(name: str) -> Type[chess.Board]: + """ + Looks for a variant board class by variant name. Supports many common + aliases. + """ for variant in VARIANTS: if any(alias.lower() == name.lower() for alias in variant.aliases): return variant - raise ValueError("unsupported variant: {}".format(name)) - - -# TODO: Deprecated -BB_HILL = chess.BB_CENTER + raise ValueError(f"unsupported variant: {name}") diff --git a/chess/xboard.py b/chess/xboard.py deleted file mode 100644 index c654295ca..000000000 --- a/chess/xboard.py +++ /dev/null @@ -1,1472 +0,0 @@ -# -*- coding: utf-8 -*- -# -# This file is part of the python-chess library. -# Copyright (C) 2017-2018 Manik Charan -# Copyright (C) 2017-2018 Niklas Fiekas -# Copyright (C) 2017 Cash Costello -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -import concurrent.futures -import shlex -import threading - -from chess.engine import EngineTerminatedException -from chess.engine import EngineStateException -from chess.engine import Option -from chess.engine import OptionMap -from chess.engine import LOGGER -from chess.engine import FUTURE_POLL_TIMEOUT -from chess.engine import _popen_engine -from chess.engine import _spur_spawn_engine - -import chess - - -DUMMY_RESPONSES = [ENGINE_RESIGN, GAME_DRAW] = [-1, -2] -RESULTS = [WHITE_WIN, BLACK_WIN, DRAW] = ["1-0", "0-1", "1/2-1/2"] - - -def try_move(board, move): - try: - move = board.push_uci(move) - except ValueError: - try: - move = board.push_san(move) - except ValueError: - LOGGER.exception("exception parsing pv") - return None - return move - - -class DrawHandler: - """ - Chess engines may send a draw offer after playing its move and may receive - one during an offer during its calculations. A draw handler can be used to - send, or react to, this information. - - >>> # Register a standard draw handler. - >>> draw_handler = chess.xboard.DrawHandler() - >>> engine.draw_handler = draw_handler - - >>> # Start a search. - >>> engine.setboard(board) - >>> engine.st(1) - >>> engine.go() - e2e4 - offer draw - >>> - >>> # Do some relevant work. - >>> # Check if a draw offer is pending at any given time. - >>> draw_handler.pending_offer - True - - See :attr:`~chess.xboard.DrawHandler.pending_offer` for a way to access - this flag in a thread-safe way during search. - - If you want to be notified whenever new information is available, - you would usually subclass the :class:`~chess.xboard.DrawHandler` class: - - >>> class MyHandler(chess.xboard.DrawHandler): - ... def offer_draw(self): - ... # Called whenever offer draw has been processed. - ... super().offer_draw() - ... print(self.pending_offer) - """ - def __init__(self): - self.lock = threading.Lock() - self.draw_offered = threading.Condition(self.lock) - self.pending_offer = False - - def pre_offer(self): - """ - Processes the newly received draw offer. - - When subclassing, remember to call this method of the parent class in - order to keep the locking intact. - """ - self.lock.acquire() - - def post_offer(self): - """ - Finishes processing of the newly received draw offer. - - When subclassing, remember to call this method of the parent class in - order to keep the locking intact. - """ - self.lock.release() - - def offer_draw(self): - """Offers a draw.""" - with self.lock: - self.pending_offer = True - self.draw_offered.notify_all() - - def clear_offer(self): - """Declines the draw offer.""" - with self.lock: - self.pending_offer = False - - def acquire(self, blocking=True): - return self.lock.acquire(blocking) - - def release(self): - return self.lock.release() - - def __enter__(self): - self.acquire() - return self.pending_offer - - def __exit__(self, exc_type, exc_value, traceback): - self.release() - - -class PostHandler: - """ - Chess engines may send information about their calculations if enabled - via the *post* command. Post handlers can be used to aggregate or react - to this information. - - >>> # Register a standard post handler. - >>> post_handler = chess.xboard.PostHandler() - >>> engine.post_handlers.append(post_handler) - - >>> # Start a search. - >>> engine.setboard(board) - >>> engine.st(1) - >>> engine.go() - e2e4 - >>> - >>> # Retrieve the score of the mainline (PV1) after search is completed. - >>> # Note that the score is relative to the side to move. - >>> post_handler.post["score"] - 34 - - See :attr:`~chess.xboard.PostHandler.post` for a way to access this dictionary - in a thread-safe way during search. - - If you want to be notified whenever new information is available, - you would usually subclass the :class:`~chess.xboard.PostHandler` class: - - >>> class MyHandler(chess.xboard.PostHandler): - ... def post_info(self): - ... # Called whenever a complete post line has been processed. - ... super().post_info() - ... print(self.post) - """ - def __init__(self): - self.lock = threading.Lock() - - self.post = {"pv": {}} - - def depth(self, depth): - """Receives the search depth in plies.""" - self.post["depth"] = depth - - def score(self, score): - """Receives the score in centipawns.""" - self.post["score"] = score - - def time(self, time): - """Receives the new time searched in centiseconds.""" - self.post["time"] = time - - def nodes(self, nodes): - """Receives the number of nodes searched.""" - self.post["nodes"] = nodes - - def pv(self, moves): - """Receives the principal variation as a list of moves.""" - self.post["pv"] = moves - - def pre_info(self): - """ - Receives new info lines before they are processed. - - When subclassing, remember to call this method of the parent class in - order to keep the locking intact. - """ - self.lock.acquire() - - def post_info(self): - """ - Processing of a new info line has been finished. - - When subclassing, remember to call this method of the parent class in - order to keep the locking intact. - """ - self.lock.release() - - def on_move(self, move): - """Receives a new move.""" - pass - - def on_go(self): - """Notified when a *go* command is beeing sent.""" - with self.lock: - self.post.clear() - self.post["pv"] = {} - - def acquire(self, blocking=True): - return self.lock.acquire(blocking) - - def release(self): - return self.lock.release() - - def __enter__(self): - self.acquire() - return self.post - - def __exit__(self, exc_type, exc_value, traceback): - self.release() - - -class FeatureMap: - def __init__(self): - # Populated with defaults to begin with. - self._features = { - "ping": 0, # TODO: Remove dependency of the xboard module on ping - "setboard": 0, - "playother": 0, - "san": 0, - "usermove": 0, - "time": 1, - "draw": 1, - "sigint": 1, - "sigterm": 1, - "reuse": 1, - "analyze": 1, - "myname": None, - "variants": None, - "colors": 1, - "ics": 0, - "name": None, - "pause": 0, - "nps": 1, - "debug": 0, - "memory": 0, - "smp": 0, - "egt": [], - "option": OptionMap(), - "done": None - } - - def set_feature(self, key, value): - if key == "egt": - for egt_type in value.split(","): - self._features["egt"].append(egt_type) - else: - try: - value = int(value) - except ValueError: - pass - - try: - self._features[key] = value - except KeyError: - LOGGER.exception("exception looking up feature") - - def get_option(self, key): - try: - return self._features["option"][key] - except KeyError: - LOGGER.exception("exception looking up option") - - def set_option(self, key, value): - try: - self._features["option"][key] = value - except KeyError: - LOGGER.exception("exception looking up option") - - def get(self, key): - try: - return self._features[key] - except KeyError: - LOGGER.exception("exception looking up feature") - - def supports(self, key): - return self.get(key) == 1 - - -class Engine: - def __init__(self, Executor=concurrent.futures.ThreadPoolExecutor): - self.idle = True - self.state_changed = threading.Condition() - self.semaphore = threading.Semaphore() - self.search_started = threading.Event() - - self.board = chess.Board() - - self.name = None - self.author = None - self.supported_variants = [] - self.features = FeatureMap() - self.pong = threading.Event() - self.ping_num = 123 - self.pong_received = threading.Condition() - self.auto_force = False - self.in_force = False - self.end_result = None - - self.move = None - self.move_received = threading.Event() - - self.ponder_on = None - self.ponder_move = None - - self.return_code = None - self.terminated = threading.Event() - - self.post_handlers = [] - self.draw_handler = None - self.engine_offered_draw = False - - self.pool = Executor(max_workers=3) - self.process = None - - def on_process_spawned(self, process): - self.process = process - - def send_line(self, line): - LOGGER.debug("%s << %s", self.process, line) - return self.process.send_line(line) - - def on_line_received(self, buf): - LOGGER.debug("%s >> %s", self.process, buf) - - if buf.startswith("feature"): - return self._feature(buf[8:]) - elif buf.startswith("Illegal"): - split_buf = buf.split() - illegal_move = split_buf[-1] - exception_msg = "Engine received an illegal move: {}".format(illegal_move) - if len(split_buf) == 4: - reason = split_buf[2][1:-2] - exception_msg = " ".join([exception_msg, reason]) - raise EngineStateException(exception_msg) - elif buf.startswith("Error"): - err_msg = buf.split()[1][1:-2] - raise EngineStateException("Engine produced an error: {}".format(err_msg)) - elif buf.startswith("#"): - return - - command_and_args = buf.split() - if not command_and_args: - return - - if len(command_and_args) == 1: - if command_and_args[0] == "resign": - return self._resign() - elif len(command_and_args) == 2: - if command_and_args[0] == "pong": - return self._pong(command_and_args[1]) - elif command_and_args[0] == "move": - return self._move(command_and_args[1]) - elif command_and_args[0] == "offer" and command_and_args[1] == "draw": - return self._offer_draw() - elif command_and_args[0] == "Hint:": - return self._hint(command_and_args[1]) - elif len(command_and_args) >= 5: - return self._post(buf) - - def on_terminated(self): - self.return_code = self.process.wait_for_return_code() - self.pool.shutdown(wait=False) - self.terminated.set() - - # Wake up waiting commands. - self.move_received.set() - with self.pong_received: - self.pong_received.notify_all() - with self.state_changed: - self.state_changed.notify_all() - - def _resign(self): - # TODO: Logic is a bit hacky, needs clearer code. - self.result(RESULTS[int(self.idle) ^ int(self.board.turn)]) - self.move = ENGINE_RESIGN - self.move_received.set() - - def _offer_draw(self): - if self.draw_handler: - if self.draw_handler.pending_offer and not self.engine_offered_draw: - self.result(DRAW) - self.move = GAME_DRAW - self.move_received.set() - else: - self.engine_offered_draw = True - self.draw_handler.offer_draw() - - def _feature(self, features): - """ - Does not conform to the CECP spec regarding `done` and instead reads all - the features atomically. - """ - def _option(feature): - params = feature.split() - name = params[0] - type = params[1][1:] - default = None - min = None - max = None - var = [] - if type == "combo": - choices = params[2:] - for choice in choices: - if choice == "///": - continue - elif choice[0] == "*": - default = choice[1:] - var.append(choice[1:]) - else: - var.append(choice) - elif type == "check": - default = int(params[2]) - elif type in ("string", "file", "path"): - if len(params) > 2: - default = params[2] - else: - default = "" - elif type == "spin": - default = int(params[2]) - min = int(params[3]) - max = int(params[4]) - option = Option(name, type, default, min, max, var) - self.features.set_option(option.name, option) - return - - features = shlex.split(features) - feature_map = [feature.split("=") for feature in features] - for (key, value) in feature_map: - if key == "variants": - self.supported_variants = value.split(",") - elif key == "option": - _option(value) - else: - self.features.set_feature(key, value) - - def _pong(self, pong_arg): - try: - pong_num = int(pong_arg) - except ValueError: - LOGGER.exception("exception parsing pong") - - if self.ping_num == pong_num: - self.pong.set() - with self.pong_received: - self.pong_received.notify_all() - - def _move(self, arg): - self.move = None - try: - self.move = self.board.parse_uci(arg) - except ValueError: - try: - self.move = self.board.parse_san(arg) - except ValueError: - LOGGER.exception("exception parsing move") - - self.move_received.set() - if self.draw_handler: - self.draw_handler.clear_offer() - self.engine_offered_draw = False - for post_handler in self.post_handlers: - post_handler.on_move(self.move) - - def _hint(self, arg): - # If we have finished search and received a best move, - # the Hint tells us the ponder move for supported engines - if self.move_received.is_set(): - self.ponder_move = arg - - def _post(self, arg): - if not self.post_handlers: - return - - # Notify post handlers of start. - for post_handler in self.post_handlers: - post_handler.pre_info() - - def handle_integer_token(token, fn): - try: - intval = int(token) - except ValueError: - LOGGER.exception("exception parsing integer token") - return - - for post_handler in self.post_handlers: - fn(post_handler, intval) - - pv = [] - board = self.board.copy(stack=False) - - # Ponder may be handled in one (or both) of two ways according to the - # spec. Either through a 'Hint: ' or through '5. ... () pv'. - # It is unclear whether the 'Hint: ' variation is persistent - # until changed or whether it must be given before each ponder post. - - # Assumption: The hint ponder overrides the pv ponder. - # They should be the same in a normal scenario. - - making_pv_ponder = False # For the '()' variation - hint_ponder_played = False # For the 'Hint: ' variation - if self.ponder_move: - try_move(board, self.ponder_move) - hint_ponder_played = True - - tokens = arg.split() - # Order: