python-reckless 0.1.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_reckless-0.1.6/.gitignore +5 -0
- python_reckless-0.1.6/LICENSE +21 -0
- python_reckless-0.1.6/PKG-INFO +86 -0
- python_reckless-0.1.6/README.md +58 -0
- python_reckless-0.1.6/__init__.py +6 -0
- python_reckless-0.1.6/engine.py +163 -0
- python_reckless-0.1.6/pyproject.toml +49 -0
- python_reckless-0.1.6/src/reckless/__init__.py +6 -0
- python_reckless-0.1.6/src/reckless/engine.py +163 -0
- python_reckless-0.1.6/test_engine.py +36 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Your Name
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-reckless
|
|
3
|
+
Version: 0.1.6
|
|
4
|
+
Summary: A lightweight Python wrapper for the Reckless UCI chess engine
|
|
5
|
+
Project-URL: Homepage, https://github.com/DaveP80/python-reckless
|
|
6
|
+
Project-URL: Repository, https://github.com/DaveP80/python-reckless
|
|
7
|
+
Project-URL: Issues, https://github.com/DaveP80/python-reckless/issues
|
|
8
|
+
Author-email: David Paquette <davidpaq1@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: chess,chess-engine,reckless,uci
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Games/Entertainment :: Board Games
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: build; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: twine; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# python-reckless
|
|
30
|
+
|
|
31
|
+
A lightweight, dependency-free Python wrapper for the
|
|
32
|
+
[Reckless](https://github.com/codedeliveryservice/Reckless) UCI chess engine.
|
|
33
|
+
|
|
34
|
+
`python-reckless` does **not** bundle the engine. Reckless is a standalone
|
|
35
|
+
compiled executable; this package launches it as a subprocess and speaks the
|
|
36
|
+
UCI protocol to it. You supply the binary.
|
|
37
|
+
|
|
38
|
+
## Installing the engine
|
|
39
|
+
|
|
40
|
+
Download a prebuilt binary for your platform from the
|
|
41
|
+
[Reckless releases page](https://github.com/codedeliveryservice/Reckless/releases),
|
|
42
|
+
then make it discoverable in one of these ways:
|
|
43
|
+
|
|
44
|
+
- Put it on your `PATH` as `reckless`, **or**
|
|
45
|
+
- set the `RECKLESS_PATH` environment variable to its full path, **or**
|
|
46
|
+
- pass the path directly: `Reckless(path="/path/to/reckless")`.
|
|
47
|
+
|
|
48
|
+
## Installing this package
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install python-reckless
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The import name is `reckless` (the distribution name is `python-reckless`).
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from reckless import Reckless
|
|
60
|
+
|
|
61
|
+
with Reckless() as engine:
|
|
62
|
+
engine.new_game()
|
|
63
|
+
engine.set_position(moves=["e2e4", "e7e5"])
|
|
64
|
+
best = engine.best_move(depth=12)
|
|
65
|
+
print("Reckless suggests:", best)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
You can also search by time instead of depth:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
move = engine.best_move(movetime=1000) # think for 1 second
|
|
72
|
+
```
|
|
73
|
+
## Development
|
|
74
|
+
|
|
75
|
+
Make changes on a feature branch and put up to date `engine.py` files and other helper files in the `src` directory.
|
|
76
|
+
|
|
77
|
+
## Note
|
|
78
|
+
|
|
79
|
+
For a fully featured UCI client (analysis info, evaluations, multipv, async),
|
|
80
|
+
consider the mature [`python-chess`](https://pypi.org/project/chess/) library,
|
|
81
|
+
whose `chess.engine.SimpleEngine.popen_uci()` works with Reckless out of the
|
|
82
|
+
box. This package is intentionally tiny and focused.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# python-reckless
|
|
2
|
+
|
|
3
|
+
A lightweight, dependency-free Python wrapper for the
|
|
4
|
+
[Reckless](https://github.com/codedeliveryservice/Reckless) UCI chess engine.
|
|
5
|
+
|
|
6
|
+
`python-reckless` does **not** bundle the engine. Reckless is a standalone
|
|
7
|
+
compiled executable; this package launches it as a subprocess and speaks the
|
|
8
|
+
UCI protocol to it. You supply the binary.
|
|
9
|
+
|
|
10
|
+
## Installing the engine
|
|
11
|
+
|
|
12
|
+
Download a prebuilt binary for your platform from the
|
|
13
|
+
[Reckless releases page](https://github.com/codedeliveryservice/Reckless/releases),
|
|
14
|
+
then make it discoverable in one of these ways:
|
|
15
|
+
|
|
16
|
+
- Put it on your `PATH` as `reckless`, **or**
|
|
17
|
+
- set the `RECKLESS_PATH` environment variable to its full path, **or**
|
|
18
|
+
- pass the path directly: `Reckless(path="/path/to/reckless")`.
|
|
19
|
+
|
|
20
|
+
## Installing this package
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install python-reckless
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The import name is `reckless` (the distribution name is `python-reckless`).
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from reckless import Reckless
|
|
32
|
+
|
|
33
|
+
with Reckless() as engine:
|
|
34
|
+
engine.new_game()
|
|
35
|
+
engine.set_position(moves=["e2e4", "e7e5"])
|
|
36
|
+
best = engine.best_move(depth=12)
|
|
37
|
+
print("Reckless suggests:", best)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
You can also search by time instead of depth:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
move = engine.best_move(movetime=1000) # think for 1 second
|
|
44
|
+
```
|
|
45
|
+
## Development
|
|
46
|
+
|
|
47
|
+
Make changes on a feature branch and put up to date `engine.py` files and other helper files in the `src` directory.
|
|
48
|
+
|
|
49
|
+
## Note
|
|
50
|
+
|
|
51
|
+
For a fully featured UCI client (analysis info, evaluations, multipv, async),
|
|
52
|
+
consider the mature [`python-chess`](https://pypi.org/project/chess/) library,
|
|
53
|
+
whose `chess.engine.SimpleEngine.popen_uci()` works with Reckless out of the
|
|
54
|
+
box. This package is intentionally tiny and focused.
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
MIT
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""A minimal, dependency-free wrapper around the Reckless UCI chess engine.
|
|
2
|
+
|
|
3
|
+
Reckless is a standalone compiled executable that speaks the Universal Chess
|
|
4
|
+
Interface (UCI) protocol over stdin/stdout. This module launches that
|
|
5
|
+
executable as a subprocess and exchanges UCI text commands with it.
|
|
6
|
+
|
|
7
|
+
It does NOT bundle the engine binary. You must have `reckless` available:
|
|
8
|
+
download a prebuilt binary from
|
|
9
|
+
https://github.com/codedeliveryservice/Reckless/releases and either put it on
|
|
10
|
+
your PATH, set the RECKLESS_PATH environment variable, or pass its path to
|
|
11
|
+
``Reckless(path=...)``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
from typing import Iterable, Optional
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RecklessError(RuntimeError):
|
|
23
|
+
"""Raised when the engine cannot be found or behaves unexpectedly."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _locate_engine(path: Optional[str]) -> str:
|
|
27
|
+
"""Resolve the engine executable: explicit arg -> env var -> PATH."""
|
|
28
|
+
candidate = path or os.environ.get("RECKLESS_PATH") or shutil.which("reckless")
|
|
29
|
+
if not candidate:
|
|
30
|
+
raise RecklessError(
|
|
31
|
+
"Could not find the Reckless engine. Pass path=..., set the "
|
|
32
|
+
"RECKLESS_PATH environment variable, or put 'reckless' on your PATH. "
|
|
33
|
+
"Prebuilt binaries: "
|
|
34
|
+
"https://github.com/codedeliveryservice/Reckless/releases"
|
|
35
|
+
)
|
|
36
|
+
if not os.path.isfile(candidate) and not shutil.which(candidate):
|
|
37
|
+
raise RecklessError(f"Engine path does not point to a file: {candidate!r}")
|
|
38
|
+
return candidate
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Reckless:
|
|
42
|
+
"""A simple handle to a running Reckless engine process.
|
|
43
|
+
|
|
44
|
+
Example
|
|
45
|
+
-------
|
|
46
|
+
>>> with Reckless() as engine:
|
|
47
|
+
... move = engine.best_move(depth=12)
|
|
48
|
+
... print(move)
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, path: Optional[str] = None, *, start: bool = True) -> None:
|
|
52
|
+
self.path = _locate_engine(path)
|
|
53
|
+
self._proc: Optional[subprocess.Popen] = None
|
|
54
|
+
if start:
|
|
55
|
+
self.start()
|
|
56
|
+
|
|
57
|
+
# -- process lifecycle -------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def start(self) -> "Reckless":
|
|
60
|
+
if self._proc is not None:
|
|
61
|
+
return self
|
|
62
|
+
self._proc = subprocess.Popen(
|
|
63
|
+
[self.path],
|
|
64
|
+
stdin=subprocess.PIPE,
|
|
65
|
+
stdout=subprocess.PIPE,
|
|
66
|
+
stderr=subprocess.STDOUT,
|
|
67
|
+
text=True,
|
|
68
|
+
bufsize=1, # line-buffered
|
|
69
|
+
)
|
|
70
|
+
self._handshake()
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def quit(self) -> None:
|
|
74
|
+
if self._proc is None:
|
|
75
|
+
return
|
|
76
|
+
try:
|
|
77
|
+
self._send("quit")
|
|
78
|
+
self._proc.wait(timeout=5)
|
|
79
|
+
except Exception:
|
|
80
|
+
self._proc.kill()
|
|
81
|
+
finally:
|
|
82
|
+
self._proc = None
|
|
83
|
+
|
|
84
|
+
def __enter__(self) -> "Reckless":
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def __exit__(self, *exc) -> None:
|
|
88
|
+
self.quit()
|
|
89
|
+
|
|
90
|
+
# -- low-level UCI I/O -------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def _send(self, command: str) -> None:
|
|
93
|
+
if self._proc is None or self._proc.stdin is None:
|
|
94
|
+
raise RecklessError("Engine is not running.")
|
|
95
|
+
self._proc.stdin.write(command + "\n")
|
|
96
|
+
self._proc.stdin.flush()
|
|
97
|
+
|
|
98
|
+
def _read_until(self, token: str) -> str:
|
|
99
|
+
"""Read lines until one starts with `token`; return that line."""
|
|
100
|
+
if self._proc is None or self._proc.stdout is None:
|
|
101
|
+
raise RecklessError("Engine is not running.")
|
|
102
|
+
while True:
|
|
103
|
+
line = self._proc.stdout.readline()
|
|
104
|
+
if not line:
|
|
105
|
+
raise RecklessError("Engine closed the connection unexpectedly.")
|
|
106
|
+
if line.strip().startswith(token):
|
|
107
|
+
return line.strip()
|
|
108
|
+
|
|
109
|
+
def _handshake(self) -> None:
|
|
110
|
+
self._send("uci")
|
|
111
|
+
self._read_until("uciok")
|
|
112
|
+
self.is_ready()
|
|
113
|
+
|
|
114
|
+
# -- public commands ---------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def is_ready(self) -> bool:
|
|
117
|
+
"""Block until the engine reports it is ready."""
|
|
118
|
+
self._send("isready")
|
|
119
|
+
self._read_until("readyok")
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
def new_game(self) -> None:
|
|
123
|
+
self._send("ucinewgame")
|
|
124
|
+
self.is_ready()
|
|
125
|
+
|
|
126
|
+
def set_position(
|
|
127
|
+
self,
|
|
128
|
+
fen: Optional[str] = None,
|
|
129
|
+
moves: Optional[Iterable[str]] = None,
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Set the board. Defaults to the standard start position.
|
|
132
|
+
|
|
133
|
+
`moves` is a sequence of UCI moves (e.g. ["e2e4", "e7e5"]).
|
|
134
|
+
"""
|
|
135
|
+
base = f"fen {fen}" if fen else "startpos"
|
|
136
|
+
move_str = ""
|
|
137
|
+
if moves:
|
|
138
|
+
move_str = " moves " + " ".join(moves)
|
|
139
|
+
self._send(f"position {base}{move_str}")
|
|
140
|
+
|
|
141
|
+
def best_move(
|
|
142
|
+
self,
|
|
143
|
+
*,
|
|
144
|
+
depth: Optional[int] = None,
|
|
145
|
+
movetime: Optional[int] = None,
|
|
146
|
+
) -> str:
|
|
147
|
+
"""Search the current position and return the best move (UCI string).
|
|
148
|
+
|
|
149
|
+
Provide either `depth` (plies) or `movetime` (milliseconds).
|
|
150
|
+
Defaults to depth 10 if neither is given.
|
|
151
|
+
"""
|
|
152
|
+
if depth is None and movetime is None:
|
|
153
|
+
depth = 10
|
|
154
|
+
go = "go"
|
|
155
|
+
if depth is not None:
|
|
156
|
+
go += f" depth {depth}"
|
|
157
|
+
if movetime is not None:
|
|
158
|
+
go += f" movetime {movetime}"
|
|
159
|
+
self._send(go)
|
|
160
|
+
line = self._read_until("bestmove")
|
|
161
|
+
# Format: "bestmove e2e4 ponder e7e5"
|
|
162
|
+
parts = line.split()
|
|
163
|
+
return parts[1] if len(parts) > 1 else ""
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "python-reckless"
|
|
7
|
+
version = "0.1.6"
|
|
8
|
+
description = "A lightweight Python wrapper for the Reckless UCI chess engine"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [
|
|
14
|
+
{ name = "David Paquette", email = "davidpaq1@gmail.com" },
|
|
15
|
+
]
|
|
16
|
+
keywords = ["chess", "uci", "reckless", "chess-engine"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.9",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Topic :: Games/Entertainment :: Board Games",
|
|
28
|
+
]
|
|
29
|
+
dependencies = [] # zero runtime deps — we talk UCI ourselves
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=7.0",
|
|
34
|
+
"build",
|
|
35
|
+
"twine",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/DaveP80/python-reckless"
|
|
40
|
+
Repository = "https://github.com/DaveP80/python-reckless"
|
|
41
|
+
Issues = "https://github.com/DaveP80/python-reckless/issues"
|
|
42
|
+
|
|
43
|
+
# Distribution is "python-reckless"; the import package is "reckless".
|
|
44
|
+
# This mirrors python-dateutil -> import dateutil.
|
|
45
|
+
# IMPORTANT: this MUST point at where the package actually lives on disk.
|
|
46
|
+
# Files are in src/reckless/, so the path is "src/reckless" (hatchling strips
|
|
47
|
+
# the src/ prefix automatically, so it still imports as `reckless`).
|
|
48
|
+
[tool.hatch.build.targets.wheel]
|
|
49
|
+
packages = ["src/reckless"]
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""A minimal, dependency-free wrapper around the Reckless UCI chess engine.
|
|
2
|
+
|
|
3
|
+
Reckless is a standalone compiled executable that speaks the Universal Chess
|
|
4
|
+
Interface (UCI) protocol over stdin/stdout. This module launches that
|
|
5
|
+
executable as a subprocess and exchanges UCI text commands with it.
|
|
6
|
+
|
|
7
|
+
It does NOT bundle the engine binary. You must have `reckless` available:
|
|
8
|
+
download a prebuilt binary from
|
|
9
|
+
https://github.com/codedeliveryservice/Reckless/releases and either put it on
|
|
10
|
+
your PATH, set the RECKLESS_PATH environment variable, or pass its path to
|
|
11
|
+
``Reckless(path=...)``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
from typing import Iterable, Optional
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RecklessError(RuntimeError):
|
|
23
|
+
"""Raised when the engine cannot be found or behaves unexpectedly."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _locate_engine(path: Optional[str]) -> str:
|
|
27
|
+
"""Resolve the engine executable: explicit arg -> env var -> PATH."""
|
|
28
|
+
candidate = path or os.environ.get("RECKLESS_PATH") or shutil.which("reckless")
|
|
29
|
+
if not candidate:
|
|
30
|
+
raise RecklessError(
|
|
31
|
+
"Could not find the Reckless engine. Pass path=..., set the "
|
|
32
|
+
"RECKLESS_PATH environment variable, or put 'reckless' on your PATH. "
|
|
33
|
+
"Prebuilt binaries: "
|
|
34
|
+
"https://github.com/codedeliveryservice/Reckless/releases"
|
|
35
|
+
)
|
|
36
|
+
if not os.path.isfile(candidate) and not shutil.which(candidate):
|
|
37
|
+
raise RecklessError(f"Engine path does not point to a file: {candidate!r}")
|
|
38
|
+
return candidate
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Reckless:
|
|
42
|
+
"""A simple handle to a running Reckless engine process.
|
|
43
|
+
|
|
44
|
+
Example
|
|
45
|
+
-------
|
|
46
|
+
>>> with Reckless() as engine:
|
|
47
|
+
... move = engine.best_move(depth=12)
|
|
48
|
+
... print(move)
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, path: Optional[str] = None, *, start: bool = True) -> None:
|
|
52
|
+
self.path = _locate_engine(path)
|
|
53
|
+
self._proc: Optional[subprocess.Popen] = None
|
|
54
|
+
if start:
|
|
55
|
+
self.start()
|
|
56
|
+
|
|
57
|
+
# -- process lifecycle -------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def start(self) -> "Reckless":
|
|
60
|
+
if self._proc is not None:
|
|
61
|
+
return self
|
|
62
|
+
self._proc = subprocess.Popen(
|
|
63
|
+
[self.path],
|
|
64
|
+
stdin=subprocess.PIPE,
|
|
65
|
+
stdout=subprocess.PIPE,
|
|
66
|
+
stderr=subprocess.STDOUT,
|
|
67
|
+
text=True,
|
|
68
|
+
bufsize=1, # line-buffered
|
|
69
|
+
)
|
|
70
|
+
self._handshake()
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def quit(self) -> None:
|
|
74
|
+
if self._proc is None:
|
|
75
|
+
return
|
|
76
|
+
try:
|
|
77
|
+
self._send("quit")
|
|
78
|
+
self._proc.wait(timeout=5)
|
|
79
|
+
except Exception:
|
|
80
|
+
self._proc.kill()
|
|
81
|
+
finally:
|
|
82
|
+
self._proc = None
|
|
83
|
+
|
|
84
|
+
def __enter__(self) -> "Reckless":
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
def __exit__(self, *exc) -> None:
|
|
88
|
+
self.quit()
|
|
89
|
+
|
|
90
|
+
# -- low-level UCI I/O -------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def _send(self, command: str) -> None:
|
|
93
|
+
if self._proc is None or self._proc.stdin is None:
|
|
94
|
+
raise RecklessError("Engine is not running.")
|
|
95
|
+
self._proc.stdin.write(command + "\n")
|
|
96
|
+
self._proc.stdin.flush()
|
|
97
|
+
|
|
98
|
+
def _read_until(self, token: str) -> str:
|
|
99
|
+
"""Read lines until one starts with `token`; return that line."""
|
|
100
|
+
if self._proc is None or self._proc.stdout is None:
|
|
101
|
+
raise RecklessError("Engine is not running.")
|
|
102
|
+
while True:
|
|
103
|
+
line = self._proc.stdout.readline()
|
|
104
|
+
if not line:
|
|
105
|
+
raise RecklessError("Engine closed the connection unexpectedly.")
|
|
106
|
+
if line.strip().startswith(token):
|
|
107
|
+
return line.strip()
|
|
108
|
+
|
|
109
|
+
def _handshake(self) -> None:
|
|
110
|
+
self._send("uci")
|
|
111
|
+
self._read_until("uciok")
|
|
112
|
+
self.is_ready()
|
|
113
|
+
|
|
114
|
+
# -- public commands ---------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def is_ready(self) -> bool:
|
|
117
|
+
"""Block until the engine reports it is ready."""
|
|
118
|
+
self._send("isready")
|
|
119
|
+
self._read_until("readyok")
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
def new_game(self) -> None:
|
|
123
|
+
self._send("ucinewgame")
|
|
124
|
+
self.is_ready()
|
|
125
|
+
|
|
126
|
+
def set_position(
|
|
127
|
+
self,
|
|
128
|
+
fen: Optional[str] = None,
|
|
129
|
+
moves: Optional[Iterable[str]] = None,
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Set the board. Defaults to the standard start position.
|
|
132
|
+
|
|
133
|
+
`moves` is a sequence of UCI moves (e.g. ["e2e4", "e7e5"]).
|
|
134
|
+
"""
|
|
135
|
+
base = f"fen {fen}" if fen else "startpos"
|
|
136
|
+
move_str = ""
|
|
137
|
+
if moves:
|
|
138
|
+
move_str = " moves " + " ".join(moves)
|
|
139
|
+
self._send(f"position {base}{move_str}")
|
|
140
|
+
|
|
141
|
+
def best_move(
|
|
142
|
+
self,
|
|
143
|
+
*,
|
|
144
|
+
depth: Optional[int] = None,
|
|
145
|
+
movetime: Optional[int] = None,
|
|
146
|
+
) -> str:
|
|
147
|
+
"""Search the current position and return the best move (UCI string).
|
|
148
|
+
|
|
149
|
+
Provide either `depth` (plies) or `movetime` (milliseconds).
|
|
150
|
+
Defaults to depth 10 if neither is given.
|
|
151
|
+
"""
|
|
152
|
+
if depth is None and movetime is None:
|
|
153
|
+
depth = 10
|
|
154
|
+
go = "go"
|
|
155
|
+
if depth is not None:
|
|
156
|
+
go += f" depth {depth}"
|
|
157
|
+
if movetime is not None:
|
|
158
|
+
go += f" movetime {movetime}"
|
|
159
|
+
self._send(go)
|
|
160
|
+
line = self._read_until("bestmove")
|
|
161
|
+
# Format: "bestmove e2e4 ponder e7e5"
|
|
162
|
+
parts = line.split()
|
|
163
|
+
return parts[1] if len(parts) > 1 else ""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Tests for python-reckless.
|
|
2
|
+
|
|
3
|
+
These tests need a real `reckless` binary to run the engine-dependent cases.
|
|
4
|
+
If one isn't available they skip rather than fail, so the suite is green in CI
|
|
5
|
+
even without the engine installed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import shutil
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from reckless import Reckless, RecklessError
|
|
13
|
+
|
|
14
|
+
HAS_ENGINE = shutil.which("reckless") is not None
|
|
15
|
+
needs_engine = pytest.mark.skipif(not HAS_ENGINE, reason="reckless not on PATH")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_missing_engine_raises():
|
|
19
|
+
with pytest.raises(RecklessError):
|
|
20
|
+
Reckless(path="/definitely/not/a/real/engine", start=False)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@needs_engine
|
|
24
|
+
def test_handshake_and_ready():
|
|
25
|
+
with Reckless() as engine:
|
|
26
|
+
assert engine.is_ready() is True
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@needs_engine
|
|
30
|
+
def test_best_move_from_start():
|
|
31
|
+
with Reckless() as engine:
|
|
32
|
+
engine.new_game()
|
|
33
|
+
engine.set_position() # start position
|
|
34
|
+
move = engine.best_move(depth=8)
|
|
35
|
+
# A legal opening move is 4 chars of UCI, e.g. "e2e4".
|
|
36
|
+
assert len(move) >= 4
|