diff --git a/CHANGELOG.rst b/CHANGELOG.rst index a95bc88d..d172cdbf 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,7 @@ Changelog To be released -------------- +* Added ``client.puzzles.get_batch`` for GET ``/api/puzzle/batch/{angle}`` (CHESS-122). * Deprecate Python 3.9 support - minimum required version is now Python 3.10+. This does not mean the library will not work with Python 3.9, but it will not be tested against it anymore. diff --git a/README.rst b/README.rst index c627f5e6..92608171 100644 --- a/README.rst +++ b/README.rst @@ -175,6 +175,7 @@ Most of the API is available: client.puzzles.get_daily client.puzzles.get client.puzzles.get_next + client.puzzles.get_batch client.puzzles.get_puzzle_activity client.puzzles.get_puzzle_dashboard client.puzzles.get_storm_dashboard diff --git a/berserk/__init__.py b/berserk/__init__.py index b791cf23..9faca1e1 100644 --- a/berserk/__init__.py +++ b/berserk/__init__.py @@ -20,6 +20,7 @@ OnlineLightUser, OpeningStatistic, PaginatedTeams, + PuzzleBatchResponse, PuzzleData, PuzzleRace, SwissInfo, @@ -50,6 +51,7 @@ "OpeningStatistic", "PaginatedTeams", "PGN", + "PuzzleBatchResponse", "PuzzleData", "PuzzleRace", "Requestor", diff --git a/berserk/clients/puzzles.py b/berserk/clients/puzzles.py index 966f9ef6..84bf0ce1 100644 --- a/berserk/clients/puzzles.py +++ b/berserk/clients/puzzles.py @@ -5,7 +5,8 @@ from .. import models from ..formats import NDJSON from .base import BaseClient -from ..types.puzzles import DifficultyLevel, PuzzleData, PuzzleRace +from ..types.common import Color +from ..types.puzzles import DifficultyLevel, PuzzleBatchResponse, PuzzleData, PuzzleRace class Puzzles(BaseClient): @@ -45,6 +46,34 @@ def get_next( params = {"angle": angle, "difficulty": difficulty} return cast(PuzzleData, self._r.get(path, params=params)) + def get_batch( + self, + angle: str, + difficulty: DifficultyLevel | None = None, + nb: int | None = None, + color: Color | None = None, + ) -> PuzzleBatchResponse: + """Get multiple puzzles at once by theme/angle. + + If authenticated, only returns puzzles that the user has never seen before. + DO NOT use this endpoint to enumerate puzzles for mass download; use the + full public puzzle database instead. + + :param angle: the theme or opening (e.g. ``mix``, ``opening``). See Lichess puzzle themes. + :param difficulty: desired difficulty relative to user rating, or 1500 if anonymous + :param nb: how many puzzles to fetch (1-50). Default 15. + :param color: color to play (white/black). Only applies when nb=1. + :return: response containing a list of puzzles + """ + path = f"/api/puzzle/batch/{angle}" + params = { + "difficulty": difficulty, + "nb": nb, + "color": color, + } + params = {k: v for k, v in params.items() if v is not None} + return cast(PuzzleBatchResponse, self._r.get(path, params=params)) + def get_puzzle_activity( self, max: int | None = None, before: int | None = None ) -> Iterator[Dict[str, Any]]: diff --git a/berserk/types/__init__.py b/berserk/types/__init__.py index 670ba27c..83955811 100644 --- a/berserk/types/__init__.py +++ b/berserk/types/__init__.py @@ -21,7 +21,7 @@ from .challenges import ChallengeJson from .common import ClockConfig, LightUser, OnlineLightUser, VariantKey from .fide import FidePlayer -from .puzzles import PuzzleData, PuzzleRace +from .puzzles import PuzzleBatchResponse, PuzzleData, PuzzleRace from .opening_explorer import ( OpeningExplorerRating, OpeningStatistic, @@ -55,6 +55,7 @@ "Perf", "Preferences", "Profile", + "PuzzleBatchResponse", "PuzzleData", "PuzzleRace", "Speed", diff --git a/berserk/types/puzzles.py b/berserk/types/puzzles.py index 6805cb1f..ca060e12 100644 --- a/berserk/types/puzzles.py +++ b/berserk/types/puzzles.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import Literal, List -from typing_extensions import TypedDict +from typing_extensions import NotRequired, TypedDict from .common import Color @@ -14,6 +14,9 @@ class PuzzleUser(TypedDict): name: str color: Color rating: int + flair: NotRequired[str] + patron: NotRequired[bool] + patronColor: NotRequired[int] class PuzzlePerf(TypedDict): @@ -44,6 +47,10 @@ class PuzzleData(TypedDict): puzzle: PuzzleInfo +class PuzzleBatchResponse(TypedDict): + puzzles: List[PuzzleData] + + class PuzzleRace(TypedDict): # Puzzle race ID id: str diff --git a/tests/clients/cassettes/test_puzzles/TestPuzzles.test_get_batch.yaml b/tests/clients/cassettes/test_puzzles/TestPuzzles.test_get_batch.yaml new file mode 100644 index 00000000..84848fdc --- /dev/null +++ b/tests/clients/cassettes/test_puzzles/TestPuzzles.test_get_batch.yaml @@ -0,0 +1,50 @@ +interactions: +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - python-requests/2.32.5 + method: GET + uri: https://lichess.org/api/puzzle/batch/opening?nb=1 + response: + body: + string: '{"puzzles":[{"game":{"id":"HTglVXdv","perf":{"key":"rapid","name":"Rapid"},"rated":true,"players":[{"name":"tackiness","flair":"smileys.cowboy-hat-face","patron":true,"patronColor":1,"id":"tackiness","color":"white","rating":1941},{"name":"MedvedTolik","id":"medvedtolik","color":"black","rating":1910}],"pgn":"e4 + e6 d4 d5 Bd3 Bb4+ c3 dxe4 Bxe4 Nf6 Bf3 Ba5 Bg5 O-O Nd2 h6 Bh4 g5 Bg3 c5 Ne2 + cxd4 Nxd4 Bb6 N2b3 e5 Bxe5","clock":"15+10"},"puzzle":{"id":"JHm74","rating":1544,"plays":10054,"solution":["f8e8","e1g1","e8e5"],"themes":["pin","short","advantage","opening"],"initialPly":26}}]}' + headers: + Access-Control-Allow-Headers: + - Origin, Authorization, If-Modified-Since, Cache-Control, Content-Type + Access-Control-Allow-Methods: + - OPTIONS, GET, POST, PUT, DELETE + Access-Control-Allow-Origin: + - '*' + Connection: + - keep-alive + Content-Type: + - application/json + Date: + - Wed, 25 Feb 2026 19:35:00 GMT + Permissions-Policy: + - interest-cohort=() + Server: + - nginx + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + Transfer-Encoding: + - chunked + Vary: + - Origin + X-Frame-Options: + - DENY + content-length: + - '586' + status: + code: 200 + message: OK +version: 1 diff --git a/tests/clients/test_puzzles.py b/tests/clients/test_puzzles.py index 634be5df..346be62a 100644 --- a/tests/clients/test_puzzles.py +++ b/tests/clients/test_puzzles.py @@ -1,6 +1,7 @@ import pytest +import requests_mock -from berserk import Client, PuzzleData +from berserk import Client, PuzzleBatchResponse, PuzzleData from utils import validate, skip_if_older_3_dot_10 @@ -11,3 +12,19 @@ def test_get_next(self): """Validate that the response matches the typed-dict""" res = Client().puzzles.get_next(angle="anastasiaMate", difficulty="hardest") validate(PuzzleData, res) + + @skip_if_older_3_dot_10 + @pytest.mark.vcr + def test_get_batch(self): + """Validate that the batch response matches the typed-dict""" + res = Client().puzzles.get_batch("opening", nb=1) + validate(PuzzleBatchResponse, res) + + def test_get_batch_params(self): + """Verify that difficulty, nb, and color are passed correctly in query params.""" + with requests_mock.Mocker() as m: + m.get( + "https://lichess.org/api/puzzle/batch/mix?difficulty=hardest&nb=2&color=white", + json={"puzzles": []}, + ) + Client().puzzles.get_batch("mix", difficulty="hardest", nb=2, color="white")