diff --git a/README.rst b/README.rst index 1d4f910..65003cb 100644 --- a/README.rst +++ b/README.rst @@ -138,6 +138,10 @@ Most of the API is available: client.external_engine.update client.external_engine.delete + client.external_engine_analysis.analyse + client.external_engine_analysis.acquire_request + client.external_engine_analysis.answer_request + client.games.export client.games.export_ongoing_by_player client.games.export_by_player diff --git a/berserk/clients/__init__.py b/berserk/clients/__init__.py index e7c83ac..eddd311 100644 --- a/berserk/clients/__init__.py +++ b/berserk/clients/__init__.py @@ -5,6 +5,7 @@ from .analysis import Analysis from .base import BaseClient from .account import Account +from .external_engine_analysis import ExternalEngineAnalysis from .users import Users from .relations import Relations from .teams import Teams @@ -35,6 +36,7 @@ "Challenges", "Client", "ExternalEngine", + "ExternalEngineAnalysis", "Games", "Messaging", "OAuth", @@ -73,6 +75,7 @@ class Client(BaseClient): - :class:`tablebase ` - lookup endgame tablebase - :class:`bulk_pairings ` - manage bulk pairings - :class: `external_engine ` - manage external engines + - :class: `external_engine_analysis ` - manage external engine analysis :param session: request session, authenticated as needed :param base_url: base API URL to use (if other than the default) @@ -80,6 +83,8 @@ class Client(BaseClient): when possible. This defaults to ``False`` and is used as a fallback when ``as_pgn`` is left as ``None`` for methods that support it. :param tablebase_url: URL for tablebase lookups + :param explorer_url: URL for opening explorer + :param engine_url: URL for external engine analysis """ def __init__( @@ -90,6 +95,7 @@ def __init__( *, tablebase_url: str | None = None, explorer_url: str | None = None, + engine_url: str | None = None, ): session = session or requests.Session() super().__init__(session, base_url) @@ -114,3 +120,4 @@ def __init__( self.opening_explorer = OpeningExplorer(session, explorer_url) self.bulk_pairings = BulkPairings(session, base_url) self.external_engine = ExternalEngine(session, base_url) + self.external_engine_analysis = ExternalEngineAnalysis(session, engine_url) diff --git a/berserk/clients/external_engine_analysis.py b/berserk/clients/external_engine_analysis.py new file mode 100644 index 0000000..8eae1d3 --- /dev/null +++ b/berserk/clients/external_engine_analysis.py @@ -0,0 +1,87 @@ +from typing import List, Iterator, cast, Optional + +import requests + +from .base import BaseClient +from ..formats import NDJSON, TEXT +from ..types.external_engine import ExternalEngineRequest, EngineAnalysisOutput + +ENGINE_URL = "https://engine.lichess.ovh" + + +class ExternalEngineAnalysis(BaseClient): + """Client for external engine analysis related endpoints.""" + + def __init__(self, session: requests.Session, engine_url: Optional[str] = None): + super().__init__(session, engine_url or ENGINE_URL) + + def analyse( + self, + engine_id: str, + client_secret: str, + session_id: str, + threads: int, + hash_table_size: int, + num_variations: int, + variant: str, + initial_fen: str, + moves: List[str], + infinite: Optional[bool] = None, + ) -> Iterator[EngineAnalysisOutput]: + """Request analysis from an external engine. + + Response content is streamed as newline delimited JSON. The properties are based on the UCI specification. + Analysis stops when the client goes away, the requested limit is reached, or the provider goes away. + + :param engine_id: engine ID + :param client_secret: engine credentials + :param session_id: arbitrary string that identifies the analysis session + :param threads: number of threads to use for analysis + :param hash_table_size: hash table size to use for analysis, in MiB + :param num_variations: requested number of principle variation + :param variant: chess UCI variant + :param initial_fen: initial position of the game + :param moves: list of moves played from the initial position, in UCI notation + :param infinite: request an infinite search (rather than roughly aiming for defaultDepth) + :return: iterator over the request analysis from the external engine + """ + path = f"/api/external-engine/{engine_id}/analyse" + payload = { + "clientSecret": client_secret, + "work": { + "sessionId": session_id, + "threads": threads, + "hash": hash_table_size, + "infinite": infinite, + "multiPv": num_variations, + "variant": variant, + "initialFen": initial_fen, + "moves": moves, + }, + } + for response in self._r.post( + path=path, payload=payload, stream=True, fmt=NDJSON + ): + yield cast(EngineAnalysisOutput, response) + + def acquire_request(self, provider_secret: str) -> ExternalEngineRequest: + """Wait for an analysis request to any of the external engines that have been registered with the given secret. + + :param provider_secret: provider credentials + :return: the requested analysis + """ + path = "/api/external-engine/work" + payload = {"providerSecret": provider_secret} + return cast(ExternalEngineRequest, self._r.post(path=path, payload=payload)) + + def answer_request(self, engine_id: str) -> str: + """Submit a stream of analysis as UCI output. + + The server may close the connection at any time, indicating that the requester has gone away and analysis + should be stopped. + + :param engine_id: engine ID + :return: the requested analysis + """ + path = f"/api/external-engine/work/{engine_id}" + return self._r.post(path=path, fmt=TEXT) diff --git a/berserk/types/__init__.py b/berserk/types/__init__.py index 46a132f..a7bf12c 100644 --- a/berserk/types/__init__.py +++ b/berserk/types/__init__.py @@ -3,7 +3,8 @@ from .account import AccountInformation, Perf, Preferences, Profile, StreamerInfo from .bulk_pairings import BulkPairing, BulkPairingGame from .challenges import Challenge -from .common import ClockConfig, ExternalEngine, LightUser, OnlineLightUser, Variant +from .common import ClockConfig, LightUser, OnlineLightUser, Variant +from .external_engine import ExternalEngine, EngineAnalysisOutput from .puzzles import PuzzleRace from .opening_explorer import ( OpeningExplorerRating, @@ -21,6 +22,7 @@ "Challenge", "ClockConfig", "CurrentTournaments", + "EngineAnalysisOutput", "ExternalEngine", "LightUser", "OnlineLightUser", diff --git a/berserk/types/common.py b/berserk/types/common.py index 0ad2b95..4d87eab 100644 --- a/berserk/types/common.py +++ b/berserk/types/common.py @@ -12,27 +12,6 @@ class ClockConfig(TypedDict): increment: int -class ExternalEngine(TypedDict): - # Engine ID - id: str - # Engine display name - name: str - # Secret token that can be used to request analysis - clientSecret: str - # User this engine has been registered for - userId: str - # Max number of available threads - maxThreads: int - # Max available hash table size, in MiB - maxHash: int - # Estimated depth of normal search - defaultDepth: int - # List of supported chess variants - variants: str - # Arbitrary data that engine provider can use for identification or bookkeeping - providerData: NotRequired[str] - - Color: TypeAlias = Literal["white", "black"] GameType: TypeAlias = Literal[ diff --git a/berserk/types/external_engine.py b/berserk/types/external_engine.py new file mode 100644 index 0000000..b9428ab --- /dev/null +++ b/berserk/types/external_engine.py @@ -0,0 +1,71 @@ +from typing import List + +from typing_extensions import TypedDict, NotRequired + + +class ExternalEngine(TypedDict): + # Engine ID + id: str + # Engine display name + name: str + # Secret token that can be used to request analysis + clientSecret: str + # User this engine has been registered for + userId: str + # Max number of available threads + maxThreads: int + # Max available hash table size, in MiB + maxHash: int + # Estimated depth of normal search + defaultDepth: int + # List of supported chess variants + variants: str + # Arbitrary data that engine provider can use for identification or bookkeeping + providerData: NotRequired[str] + + +class ExternalEngineWork(TypedDict): + # Arbitrary string that identifies the analysis session. Providers may clear the hash table between sessions + sessionId: str + # Number of threads to use for analysis + threads: int + # Hash table size to use for analysis, in MiB + hash: int + # Requested number of principle variations + multiPv: List[int] + # Uci variant + variant: str + # Initial position of the game + initialFen: str + # List of moves played from the initial position, in UCI notation + moves: List[str] + # Request an infinite search (rather than roughly aiming for defaultDepth) + infinite: NotRequired[bool] + + +class ExternalEngineRequest(TypedDict): + id: str + work: ExternalEngineWork + engine: ExternalEngine + + +class PrincipleVariationAnalysis(TypedDict): + # Current search depth of the pv + depth: int + # Variation in UCI notation + moves: List[str] + # Evaluation in centi-pawns, from White's point of view + cp: NotRequired[int] + # Evaluation in signed moves to mate, from White's point of view + mate: NotRequired[int] + + +class EngineAnalysisOutput(TypedDict): + # Number of milliseconds the search has been going on + time: int + # Current search depth + depth: int + # Number of nodes visited so far + nodes: int + # Information about up to 5 pvs, with the primary pv at index 0 + pvs: List[PrincipleVariationAnalysis]