Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions berserk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
OnlineLightUser,
OpeningStatistic,
PaginatedTeams,
PuzzleBatchResponse,
PuzzleData,
PuzzleRace,
SwissInfo,
Expand Down Expand Up @@ -50,6 +51,7 @@
"OpeningStatistic",
"PaginatedTeams",
"PGN",
"PuzzleBatchResponse",
"PuzzleData",
"PuzzleRace",
"Requestor",
Expand Down
31 changes: 30 additions & 1 deletion berserk/clients/puzzles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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]]:
Expand Down
3 changes: 2 additions & 1 deletion berserk/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -55,6 +55,7 @@
"Perf",
"Preferences",
"Profile",
"PuzzleBatchResponse",
"PuzzleData",
"PuzzleRace",
"Speed",
Expand Down
9 changes: 8 additions & 1 deletion berserk/types/puzzles.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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):
Expand Down Expand Up @@ -44,6 +47,10 @@ class PuzzleData(TypedDict):
puzzle: PuzzleInfo


class PuzzleBatchResponse(TypedDict):
puzzles: List[PuzzleData]


class PuzzleRace(TypedDict):
# Puzzle race ID
id: str
Expand Down
Original file line number Diff line number Diff line change
@@ -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
19 changes: 18 additions & 1 deletion tests/clients/test_puzzles.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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")