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
82 changes: 82 additions & 0 deletions .github/workflows/live-api-healthcheck.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
name: Live API healthcheck

on:
schedule:
- cron: '0 2 * * 1' # Every Monday at 2 AM UTC
workflow_dispatch:

permissions:
issues: write

jobs:
live-api-healthcheck:
# Workflow logic:
# 1. Run tests against the live API (skip pytest-recording / vcr behavior)
# 2. IF tests pass -> Do nothing
# 3. IF tests fail -> Create issue (if no open issue with same title exists)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Install uv
uses: astral-sh/setup-uv@v7
with:
python-version: "3.13"

- name: Install dependencies
run: make setup

- name: Run tests against live API
# Streaming endpoints can intermittently hang or time out for many
# minutes. To keep the healthcheck fast and reliable we skip tests
# marked with the `streaming` marker in this workflow and run the
# non-streaming test target instead.
run: make test_live_no_streaming
id: test
continue-on-error: true

- name: Check for existing issue
if: steps.test.outcome != 'success'
id: existing_issue
env:
GH_TOKEN: ${{ github.token }}
run: |
# Search for open issues with the specific title to avoid duplicates
issues=$(gh issue list --search "in:title Live API healthcheck - Test failures detected" --state open --json number)
issue_count=$(echo "$issues" | jq 'length')
if [ "$issue_count" -gt 0 ]; then
issue_number=$(echo "$issues" | jq -r '.[0].number')
echo "exists=true" >> $GITHUB_OUTPUT
echo "Existing issue #${issue_number} found, skipping creation"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "No existing issue found"
fi

- name: Create issue (tests failed - needs review)
if: steps.test.outcome != 'success' && steps.existing_issue.outputs.exists == 'false'
env:
GH_TOKEN: ${{ github.token }}
run: |
run_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"

gh issue create \
--title "Live API healthcheck - Test failures detected" \
--body "## Summary
Test failures detected when running tests against the live API.

## Test Results
The live-run produced failing tests; the exact failure mode may vary (schema change, data mismatch, timeout, auth error, flaky test, etc.).

## Action Required
1. Review test failures in the [workflow run](${run_url})
2. Reproduce locally: \`make test_live_api\`
3. If API changes are confirmed, either:
- Update the code/tests to match the new API
- Re-record affected cassettes manually
4. To re-record cassettes, run locally: \`uv run pytest --record-mode=rewrite\`
5. Run \`make test\` to verify fixes against recorded cassettes before merging

---

**Note**: This issue is automatically created when the live API healthcheck detects possible API changes. Close this issue once the problems are resolved."
11 changes: 10 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,16 @@ To be released


* 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.

* Added test_live_no_streaming Makefile target and updated live-api-healthcheck GitHub Actions workflow to use it.
* Updated typing and client behavior for the ``opening_explorer``:
- Added new typed dicts ``PlayerOpeningStatistic`` and ``MastersOpeningStatistic`` in ``berserk.types.opening_explorer``.
- Updated ``client.opening_explorer`` method signatures to return the new types.
* Updated ``PuzzleUser`` type to include patron status, title, and flair information to match current Lichess API schema.
* Updated ``Team`` type to include flair information to match current Lichess API schema.
* Updated ``TournamentResult`` type to include patron color information to match current Lichess API schema.
* Added ``test_live_api`` target in Makefile to run tests against the live Lichess API and bypass VCR.py recordings.
* Added GitHub Actions workflow ``live-api-healthcheck`` to validate changes against the API schema.
* Added pytest ``--live-api-throttle`` argument to add throttling between live API tests.
* Added ``pgn_in_json`` parameter to ``client.games.export``.
* Implement `broadcasts.get_top()` endpoint; typing fixes and validation.
* Added ``client.broadcasts.search`` to search for broadcasts.
Expand Down
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,13 @@ test: ## run tests with pytest
uv run pytest tests

test_record: ## run tests with pytest and record http requests
uv run pytest --record-mode=once
uv run pytest --record-mode=once tests

test_live_api: ## run tests with live API (no cassettes)
uv run pytest --disable-recording --throttle-time=5.0 tests

test_live_no_streaming: ## run live tests but skip streaming-marked tests
uv run pytest --disable-recording --throttle-time=5.0 -m "not streaming" tests

typecheck: ## run type checking with pyright
uv run pyright berserk integration/local.py $(ARGS)
Expand Down
4 changes: 4 additions & 0 deletions berserk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
ChapterIdName,
OnlineLightUser,
OpeningStatistic,
PlayerOpeningStatistic,
MastersOpeningStatistic,
PaginatedTeams,
PuzzleData,
PuzzleRace,
Expand All @@ -44,10 +46,12 @@
"JSON_LIST",
"LightUser",
"LIJSON",
"MastersOpeningStatistic",
"NDJSON",
"NDJSON_LIST",
"OnlineLightUser",
"OpeningStatistic",
"PlayerOpeningStatistic",
"PaginatedTeams",
"PGN",
"PuzzleData",
Expand Down
12 changes: 7 additions & 5 deletions berserk/clients/opening_explorer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from .base import BaseClient
from ..types import (
OpeningStatistic,
PlayerOpeningStatistic,
MastersOpeningStatistic,
VariantKey,
Speed,
OpeningExplorerRating,
Expand Down Expand Up @@ -78,7 +80,7 @@ def get_masters_games(
until: int | None = None,
moves: int | None = None,
top_games: int | None = None,
) -> OpeningStatistic:
) -> MastersOpeningStatistic:
"""Get most played move from a position based on masters games."""

path = "/masters"
Expand All @@ -91,7 +93,7 @@ def get_masters_games(
"moves": moves,
"topGames": top_games,
}
return cast(OpeningStatistic, self._r.get(path, params=params))
return cast(MastersOpeningStatistic, self._r.get(path, params=params))

def get_player_games(
self,
Expand All @@ -109,7 +111,7 @@ def get_player_games(
recent_games: int | None = None,
history: bool | None = None,
wait_for_indexing: bool = True,
) -> OpeningStatistic:
) -> PlayerOpeningStatistic:
"""Get most played move from a position based on player games.

The complete statistics for a player may not immediately be available at the
Expand Down Expand Up @@ -156,7 +158,7 @@ def stream_player_games(
top_games: int | None = None,
recent_games: int | None = None,
history: bool | None = None,
) -> Iterator[OpeningStatistic]:
) -> Iterator[PlayerOpeningStatistic]:
"""Get most played move from a position based on player games.

The complete statistics for a player may not immediately be available at the
Expand Down Expand Up @@ -196,7 +198,7 @@ def stream_player_games(
}

for response in self._r.get(path, params=params, stream=True):
yield cast(OpeningStatistic, response)
yield cast(PlayerOpeningStatistic, response)

def get_otb_master_game(self, game_id: str) -> str:
"""Get PGN representation of an over-the-board master game.
Expand Down
4 changes: 4 additions & 0 deletions berserk/types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
from .opening_explorer import (
OpeningExplorerRating,
OpeningStatistic,
PlayerOpeningStatistic,
MastersOpeningStatistic,
Speed,
)
from .studies import ChapterIdName
Expand All @@ -47,9 +49,11 @@
"ExternalEngine",
"FidePlayer",
"LightUser",
"MastersOpeningStatistic",
"OnlineLightUser",
"OpeningExplorerRating",
"OpeningStatistic",
"PlayerOpeningStatistic",
"PaginatedBroadcasts",
"PaginatedTeams",
"Perf",
Expand Down
99 changes: 90 additions & 9 deletions berserk/types/opening_explorer.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
from __future__ import annotations

from typing import Literal, List
from typing_extensions import TypedDict
from .common import Speed
from typing import Generic, Literal, List, TypeVar
from typing_extensions import TypedDict, NotRequired
from .common import Color, Speed

OpeningExplorerRating = Literal[
"0", "1000", "1200", "1400", "1600", "1800", "2000", "2200", "2500"
]


MoveT = TypeVar("MoveT")
GameT = TypeVar("GameT")


class Opening(TypedDict):
# The eco code of this opening
eco: str
Expand All @@ -27,7 +31,7 @@ class GameWithoutUci(TypedDict):
# The id of the game
id: str
# The winner of the game. Draw if None
winner: Literal["white"] | Literal["black"] | None
winner: Color | None
# The speed of the game
speed: Speed
# The type of game
Expand All @@ -42,11 +46,31 @@ class GameWithoutUci(TypedDict):
month: str


class MastersGameWithoutUci(TypedDict):
# The id of the OTB master game
id: str
# The winner of the game. Draw if None
winner: Color | None
# The black player
black: Player
# The white player
white: Player
# The year of the game
year: int
# The month and year of the game. For example "2023-06"
month: NotRequired[str]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this would be not required

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am using this schemas doc as a reference, is there another reference I should be looking at?



class Game(GameWithoutUci):
# The move in Universal Chess Interface notation
uci: str


class MastersGame(MastersGameWithoutUci):
# The move in Universal Chess Interface notation
uci: str


class Move(TypedDict):
# The move in Universal Chess Interface notation
uci: str
Expand All @@ -62,9 +86,51 @@ class Move(TypedDict):
draws: int
# The game where the move was played
game: GameWithoutUci | None
# The opening info for this move
opening: Opening | None
Comment on lines 88 to +90
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

most likely not required

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at this schema doc have it has required.



class PlayerMove(TypedDict):
# The move in Universal Chess Interface notation
uci: str
# The move in algebraic notation
san: str
# The average opponent rating in games with this move
averageOpponentRating: int
# The performance rating for this move
performance: int
# The number of white winners after this move
white: int
# The number of black winners after this move
black: int
# The number of draws after this move
draws: int
# The game where the move was played
game: GameWithoutUci | None
# The opening info for this move
opening: Opening | None
Comment on lines +109 to +111
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at this schema doc have it has required.



class OpeningStatistic(TypedDict):
class MastersMove(TypedDict):
# The move in Universal Chess Interface notation
uci: str
# The move in algebraic notation
san: str
# The average rating of games in the position after this move
averageRating: int
# The number of white winners after this move
white: int
# The number of black winners after this move
black: int
# The number of draws after this move
draws: int
# The OTB master game where the move was played
game: MastersGameWithoutUci | None
# The opening info for this move
opening: Opening | None
Comment on lines +128 to +130
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at this schema doc have it has required.



class BaseOpeningStatistic(TypedDict, Generic[MoveT, GameT]):
# Number of game won by white from this position
white: int
# Number of game won by black from this position
Expand All @@ -73,9 +139,24 @@ class OpeningStatistic(TypedDict):
black: int
# Opening info of this position
opening: Opening | None
# The list of moves played by players from this position
moves: List[Move]
# recent games with this opening
recentGames: List[Game]
# The list of moves played from this position
moves: List[MoveT]


class OpeningStatistic(BaseOpeningStatistic[Move, Game]):
# top rating games with this opening
topGames: List[Game]
# recent games with this opening (optional per schema)
recentGames: NotRequired[List[Game]]


class PlayerOpeningStatistic(BaseOpeningStatistic[PlayerMove, Game]):
# Queue position for indexing (present when wait_for_indexing parameter used)
queuePosition: int
# recent games with this opening
recentGames: List[Game]


class MastersOpeningStatistic(BaseOpeningStatistic[MastersMove, MastersGame]):
# top rating OTB master games with this opening
topGames: List[MastersGame]
6 changes: 2 additions & 4 deletions berserk/types/puzzles.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,13 @@
from typing import Literal, List
from typing_extensions import TypedDict

from .common import Color
from .common import Color, LightUser


DifficultyLevel = Literal["easiest", "easier", "normal", "harder", "hardest"]


class PuzzleUser(TypedDict):
id: str
name: str
class PuzzleUser(LightUser):
color: Color
rating: int

Expand Down
2 changes: 2 additions & 0 deletions berserk/types/team.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ class Team(TypedDict):
leaders: List[LightUser]
# The number of members of the team
nbMembers: int
# The flair of the team
flair: NotRequired[str]
# Has the user asssociated with the token (if any) joined the team
joined: NotRequired[bool]
# Has the user asssociated with the token (if any) requested to join the team
Expand Down
1 change: 1 addition & 0 deletions berserk/types/tournaments.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class TournamentResult(TypedDict):
performance: int
title: NotRequired[Title]
flair: NotRequired[str]
patronColor: NotRequired[int]


class ArenaSheet(TypedDict):
Expand Down
Loading