Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement api/tournament endpoints #50

Merged
merged 20 commits into from
Nov 2, 2023
Merged
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
13 changes: 11 additions & 2 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ Changelog
To be released
--------------

* Add::

client.tournaments.get_team_standings
client.tournaments.update_team_battle
client.tournaments.join_arena
client.tournaments.terminate_arena
client.tournaments.withdraw_arena


* Add::

client.challenges.get_mine
Expand All @@ -30,7 +39,7 @@ To be released
client.tournaments.schedule_swiss_next_round
client.tournaments.terminate_swiss
client.tournaments.withdraw_swiss

* Add ``client.puzzles.create_race``
* Add ``client.users.get_by_autocomplete``

Expand All @@ -39,7 +48,7 @@ Thanks to @handsamtw and @Anupya for their contributions to this release.
v0.13 (2023-09-29)
--------------------

* Corretly forward that the library is typed (now following PEP-0561)
* Correctly forward that the library is typed (now following PEP-0561)
* Added `broadcast.stream_round` endpoint
* Improve type safety, remove `enum.py` and use typed dicts instead, this is a breaking change if you relied on these enums

Expand Down
5 changes: 5 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -191,18 +191,23 @@ Most of the API is available:
client.tournaments.get
client.tournaments.get_tournament
client.tournaments.get_swiss
client.tournaments.get_team_standings
client.tournaments.update_team_battle
client.tournaments.create_arena
client.tournaments.create_swiss
client.tournaments.export_arena_games
client.tournaments.export_swiss_games
client.tournaments.arena_by_team
client.tournaments.swiss_by_team
client.tournaments.join_arena
client.tournaments.join_swiss
client.tournaments.terminate_arena
client.tournaments.terminate_swiss
client.tournaments.tournaments_by_user
client.tournaments.stream_results
client.tournaments.stream_swiss_results
client.tournaments.stream_by_creator
client.tournaments.withdraw_arena
client.tournaments.withdraw_swiss
client.tournaments.schedule_swiss_next_round

Expand Down
89 changes: 80 additions & 9 deletions berserk/clients/tournaments.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from ..formats import NDJSON, NDJSON_LIST, PGN
from .base import FmtClient
from ..types import ArenaResult, CurrentTournaments, SwissInfo, SwissResult
from ..types.tournaments import TeamBattleResult


class Tournaments(FmtClient):
Expand All @@ -25,12 +26,65 @@ def get(self) -> CurrentTournaments:
def get_tournament(self, tournament_id: str, page: int = 1) -> Dict[str, Any]:
"""Get information about an arena.

:param tournament_id
:param tournament_id: tournament ID
:param page: the page number of the player standings to view
:return: tournament information
"""
path = f"/api/tournament/{tournament_id}?page={page}"
return self._r.get(path, converter=models.Tournament.convert)

def join_arena(
self,
tournament_id: str,
password: str | None = None,
team: str | None = None,
should_pair_immediately: bool = False,
) -> None:
"""Join an Arena tournament. Also, unpauses if you had previously paused the tournament.

Requires OAuth2 authorization with tournament:write scope.

:param tournament_id: tournament ID
:param password: tournament password or user-specific entry code generated and shared by the organizer
Anupya marked this conversation as resolved.
Show resolved Hide resolved
:param team: team with which to join the team battle Arena tournament
:param should_pair_immediately: if the tournament is started, attempt to pair the user, even if they are not
connected to the tournament page. This expires after one minute, to avoid pairing a user who is long gone.
You may call "join" again to extend the waiting.
"""
path = f"/api/tournament/{tournament_id}/join"
params = {
"password": password,
"team": team,
"pairMeAsap": should_pair_immediately,
}
self._r.post(path=path, params=params, converter=models.Tournament.convert)

def get_team_standings(self, tournament_id: str) -> TeamBattleResult:
"""Get team standing of a team battle tournament, with their respective top players.

:param tournament_id: tournament ID
:return: information about teams in the team battle tournament
"""
path = f"/api/tournament/{tournament_id}/teams"
return cast(TeamBattleResult, self._r.get(path))

def update_team_battle(
self,
tournament_id: str,
team_ids: str | None = None,
team_leader_count_per_team: int | None = None,
) -> Dict[str, Any]:
"""Set the teams and number of leaders of a team battle tournament.

:param tournament_id: tournament ID
:param team_ids: all team IDs of the team battle, separated by commas
:param team_leader_count_per_team: number of team leaders per team
:return: updated team battle information
"""
path = f"/api/tournament/team-battle/{tournament_id}"
params = {"teams": team_ids, "nbLeaders": team_leader_count_per_team}
return self._r.post(path=path, params=params)

def create_arena(
self,
clockTime: int,
Expand Down Expand Up @@ -78,7 +132,7 @@ def create_arena(
:param hasChat: whether players can discuss in a chat
:param description: anything you want to tell players about the tournament
:param password: password
:param teamBattleByTeam: Id of a team you lead to create a team battle
:param teamBattleByTeam: ID of a team you lead to create a team battle
:param teamId: Restrict entry to members of team
:param minRating: Minimum rating to join
:param maxRating: Maximum rating to join
Expand Down Expand Up @@ -134,7 +188,7 @@ def create_swiss(
If ``startsAt`` is left blank then the tournament begins 10 minutes after
creation

:param teamId: team Id, required for swiss tournaments
:param teamId: team ID, required for swiss tournaments
:param clockLimit: initial clock time in seconds
:param clockIncrement: clock increment in seconds
:param nbRounds: maximum number of rounds to play
Expand Down Expand Up @@ -173,14 +227,14 @@ def export_arena_games(
evals: bool = True,
opening: bool = False,
) -> Iterator[str] | Iterator[Dict[str, Any]]:
"""Export games from a arena tournament.
"""Export games from an arena tournament.

:param id: tournament ID
:param as_pgn: whether to return PGN instead of JSON
:param moves: include moves
:param tags: include tags
:param clocks: include clock comments in the PGN moves, when available
:param evals: include analysis evalulation comments in the PGN moves, when
:param evals: include analysis evaluation comments in the PGN moves, when
available
:param opening: include the opening name
:return: iterator over the exported games, as JSON or PGN
Expand Down Expand Up @@ -270,7 +324,7 @@ def arenas_by_team(
) -> List[Dict[str, Any]]:
"""Get arenas created for a team.

:param teamId: Id of the team
:param teamId: team ID
:param maxT: how many tournaments to download
:return: arena tournaments
"""
Expand All @@ -287,7 +341,7 @@ def swiss_by_team(
) -> List[Dict[str, Any]]:
"""Get swiss tournaments created for a team.

:param teamId: Id of the team
:param teamId: team ID
:param maxT: how many tournaments to download
:return: swiss tournaments
"""
Expand Down Expand Up @@ -371,7 +425,7 @@ def edit_swiss(
nbRatedGame: int | None = None,
allowList: str | None = None,
) -> Dict[str, SwissInfo]:
"""Updata a swiss tournament.
"""Update a swiss tournament.

:param tournamentId : The unique identifier of the tournament to be updated.
:param clockLimit : The time limit for each player's clock.
Expand Down Expand Up @@ -420,11 +474,20 @@ def join_swiss(self, tournament_id: str, password: str | None = None) -> None:
"""Join a Swiss tournament, possibly with a password.

:param tournament_id: the Swiss tournament ID.
:param password: the Swiss tournament password, if one is required.
"""
path = f"/api/swiss/{tournament_id}/join"
payload = {"password": password}
self._r.post(path, json=payload)

def terminate_arena(self, tournament_id: str) -> None:
"""Terminate an Arena tournament.

:param tournament_id: tournament ID
"""
path = f"/api/tournament/{tournament_id}/terminate"
self._r.post(path)

def terminate_swiss(self, tournament_id: str) -> None:
"""Terminate a Swiss tournament.

Expand All @@ -433,6 +496,14 @@ def terminate_swiss(self, tournament_id: str) -> None:
path = f"/api/swiss/{tournament_id}/terminate"
self._r.post(path)

def withdraw_arena(self, tournament_id: str) -> None:
"""Leave an upcoming Arena tournament, or take a break on an ongoing Arena tournament.

:param tournament_id: tournament ID
"""
path = f"/api/tournament/{tournament_id}/withdraw"
self._r.post(path)

def withdraw_swiss(self, tournament_id: str) -> None:
"""Withdraw a Swiss tournament.

Expand All @@ -445,7 +516,7 @@ def schedule_swiss_next_round(self, tournament_id: str, schedule_time: int) -> N
"""Manually schedule the next round date and time of a Swiss tournament.

:param tournament_id: the Swiss tournament ID.
:schedule_time: Timestamp in milliseconds to start the next round at a given date and time.
:param schedule_time: Timestamp in milliseconds to start the next round at a given date and time.
"""
path = f"/api/swiss/{tournament_id}/schedule-next-round"
payload = {"date": schedule_time}
Expand Down
19 changes: 18 additions & 1 deletion berserk/types/tournaments.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Any, List, Dict, Optional

from .common import Title
from .common import Title, LightUser
from typing_extensions import TypedDict, NotRequired


Expand Down Expand Up @@ -57,3 +57,20 @@ class ArenaResult(TournamentResult):
class SwissResult(TournamentResult):
points: float # can be .5 in case of draw
tieBreak: float


class PlayerTeamResult(TypedDict):
user: LightUser
score: int


class TeamResult(TypedDict):
rank: int
id: str
score: int
players: List[PlayerTeamResult]


class TeamBattleResult(TypedDict):
id: str
teams: List[TeamResult]
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
interactions:
- request:
body: null
headers:
Accept:
- application/json
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
User-Agent:
- python-requests/2.31.0
method: GET
uri: https://lichess.org/api/tournament/Qv0dRqml/teams
response:
body:
string: '{"id":"Qv0dRqml","teams":[{"rank":1,"id":"EAtFBeZ8","score":230,"players":[{"user":{"name":"vladtok","id":"vladtok"},"score":38},{"user":{"name":"DVlad","id":"dvlad"},"score":31},{"user":{"name":"DmitryK1","title":"FM","id":"dmitryk1"},"score":30},{"user":{"name":"Jhonsmit","id":"jhonsmit"},"score":27},{"user":{"name":"Yacek1111","id":"yacek1111"},"score":21},{"user":{"name":"Hard_Utilizator","title":"FM","id":"hard_utilizator"},"score":19},{"user":{"name":"Andrey-1-razryd","id":"andrey-1-razryd"},"score":18},{"user":{"name":"GuruDomino","id":"gurudomino"},"score":17},{"user":{"name":"D_L88","id":"d_l88"},"score":17},{"user":{"name":"kvmikhed","id":"kvmikhed"},"score":12}]},{"rank":2,"id":"guillon-chess","score":210,"players":[{"user":{"name":"Alexr58","title":"IM","id":"alexr58"},"score":33},{"user":{"name":"BaleevK","id":"baleevk"},"score":26},{"user":{"name":"offspring1476","id":"offspring1476"},"score":25},{"user":{"name":"denis-rucoba-tuanama","id":"denis-rucoba-tuanama"},"score":23},{"user":{"name":"jmcapoi","id":"jmcapoi"},"score":21},{"user":{"name":"borisv007","id":"borisv007"},"score":21},{"user":{"name":"Eribertoperez","id":"eribertoperez"},"score":17},{"user":{"name":"Robbertus","id":"robbertus"},"score":15},{"user":{"name":"FX-DANGER","id":"fx-danger"},"score":15},{"user":{"name":"mjuanchini","id":"mjuanchini"},"score":14}]},{"rank":3,"id":"bZ9dPpbL","score":201,"players":[{"user":{"name":"Wollukav","id":"wollukav"},"score":42},{"user":{"name":"Konek_Gorbunok","title":"FM","id":"konek_gorbunok"},"score":34},{"user":{"name":"RichardRapportGOAT","id":"richardrapportgoat"},"score":29},{"user":{"name":"zadvinski","id":"zadvinski"},"score":24},{"user":{"name":"stasOR","title":"FM","id":"stasor"},"score":22},{"user":{"name":"Tatschess","id":"tatschess"},"score":15},{"user":{"name":"IZhuk","id":"izhuk"},"score":12},{"user":{"name":"cot3","id":"cot3"},"score":10},{"user":{"name":"Rassvet","id":"rassvet"},"score":7},{"user":{"name":"Denisik_Sergei","id":"denisik_sergei"},"score":6}]},{"rank":4,"id":"kingkondor--friends","score":195,"players":[{"user":{"name":"Lozhnonozhka","id":"lozhnonozhka"},"score":38},{"user":{"name":"Igrok_V_Proshlom","title":"FM","id":"igrok_v_proshlom"},"score":35},{"user":{"name":"CblBOPOTKA","id":"cblbopotka"},"score":21},{"user":{"name":"Advokat343","id":"advokat343"},"score":20},{"user":{"name":"Chelcity","id":"chelcity"},"score":19},{"user":{"name":"KingKondor","id":"kingkondor"},"score":16},{"user":{"name":"Rapid2021","id":"rapid2021"},"score":15},{"user":{"name":"RomanIlyin","id":"romanilyin"},"score":14},{"user":{"name":"Andrew_Cc","id":"andrew_cc"},"score":11},{"user":{"name":"Devastator999","id":"devastator999"},"score":6}]},{"rank":5,"id":"fm-andro-team","score":192,"players":[{"user":{"name":"VLAJKO18","id":"vlajko18"},"score":27},{"user":{"name":"Mesatr","id":"mesatr"},"score":27},{"user":{"name":"MK_Juic_R","id":"mk_juic_r"},"score":26},{"user":{"name":"Divos","id":"divos"},"score":24},{"user":{"name":"BlixLT","id":"blixlt"},"score":24},{"user":{"name":"Crni_Konj","id":"crni_konj"},"score":16},{"user":{"name":"Nezh2","id":"nezh2"},"score":16},{"user":{"name":"bujka","id":"bujka"},"score":12},{"user":{"name":"erroras","id":"erroras"},"score":12},{"user":{"name":"Merzo19","id":"merzo19"},"score":8}]},{"rank":6,"id":"rochade-europa-schachzeitung","score":187,"players":[{"user":{"name":"Karlo0300","id":"karlo0300"},"score":28},{"user":{"name":"RapidHector","id":"rapidhector"},"score":28},{"user":{"name":"jeffforever","title":"FM","patron":true,"id":"jeffforever"},"score":24},{"user":{"name":"Apo-Wuff","id":"apo-wuff"},"score":20},{"user":{"name":"GORA-70","id":"gora-70"},"score":19},{"user":{"name":"Coolplay","id":"coolplay"},"score":17},{"user":{"name":"Birsch","id":"birsch"},"score":16},{"user":{"name":"Springteufel","id":"springteufel"},"score":13},{"user":{"name":"a4crest","id":"a4crest"},"score":11},{"user":{"name":"A-HF","id":"a-hf"},"score":11}]},{"rank":7,"id":"ulugbek-company","score":179,"players":[{"user":{"name":"Shihaliev_O","id":"shihaliev_o"},"score":29},{"user":{"name":"turkmenchess2023","id":"turkmenchess2023"},"score":28},{"user":{"name":"umatyakubow1977","id":"umatyakubow1977"},"score":19},{"user":{"name":"Bayramogly1975","id":"bayramogly1975"},"score":18},{"user":{"name":"HG811137AH","id":"hg811137ah"},"score":17},{"user":{"name":"Aymakowa-Mahri","id":"aymakowa-mahri"},"score":16},{"user":{"name":"BayramowBayram","id":"bayramowbayram"},"score":16},{"user":{"name":"chesslbpgm","id":"chesslbpgm"},"score":15},{"user":{"name":"suleymantmdz","id":"suleymantmdz"},"score":11},{"user":{"name":"Ezizovjumamuhammet","id":"ezizovjumamuhammet"},"score":10}]},{"rank":8,"id":"euskal-herria-combinado-vasco-navarro-on-line","score":143,"players":[{"user":{"name":"Edusanzna","id":"edusanzna"},"score":21},{"user":{"name":"ZB_G","id":"zb_g"},"score":19},{"user":{"name":"mikelbenaito","id":"mikelbenaito"},"score":18},{"user":{"name":"GrosXakeTaldea","id":"grosxaketaldea"},"score":16},{"user":{"name":"Kepaketon","id":"kepaketon"},"score":16},{"user":{"name":"DiablucoChess","patron":true,"id":"diablucochess"},"score":14},{"user":{"name":"Athletic_club","id":"athletic_club"},"score":14},{"user":{"name":"Serantes","id":"serantes"},"score":10},{"user":{"name":"ZB_IC","id":"zb_ic"},"score":9},{"user":{"name":"glchakal","id":"glchakal"},"score":6}]},{"rank":9,"id":"schach-club-kreuzberg-e-v","score":119,"players":[{"user":{"name":"bert6209","id":"bert6209"},"score":35},{"user":{"name":"Drunkenstyle36","id":"drunkenstyle36"},"score":23},{"user":{"name":"zonk123","id":"zonk123"},"score":16},{"user":{"name":"Minfrad","id":"minfrad"},"score":15},{"user":{"name":"libby1","id":"libby1"},"score":8},{"user":{"name":"schoasch","patron":true,"id":"schoasch"},"score":8},{"user":{"name":"hopfrog64","id":"hopfrog64"},"score":8},{"user":{"name":"DavidMerck","id":"davidmerck"},"score":2},{"user":{"name":"LTH1","id":"lth1"},"score":2},{"user":{"name":"hiroki6","id":"hiroki6"},"score":2}]},{"rank":10,"id":"moscow-karpov-school","score":102,"players":[{"user":{"name":"SuperLoop","id":"superloop"},"score":30},{"user":{"name":"Fotty_1338","id":"fotty_1338"},"score":27},{"user":{"name":"Pes_V_Sapogah","id":"pes_v_sapogah"},"score":19},{"user":{"name":"clash-of-clans-01","id":"clash-of-clans-01"},"score":13},{"user":{"name":"Timon89","id":"timon89"},"score":13},{"user":{"name":"Grizzly51","id":"grizzly51"},"score":0}]}]}'
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:
- Thu, 02 Nov 2023 21:25:59 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:
- '6450'
status:
code: 200
message: OK
version: 1
10 changes: 9 additions & 1 deletion tests/clients/test_tournaments.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import pytest

from berserk import ArenaResult, Client, SwissInfo, SwissResult
from berserk import ArenaResult, Client, SwissResult
from typing import List

from berserk.types.tournaments import TeamBattleResult
from utils import validate, skip_if_older_3_dot_10


Expand All @@ -17,3 +19,9 @@ def test_swiss_result(self):
def test_arenas_result(self):
res = list(Client().tournaments.stream_results("hallow23", limit=3))
validate(List[ArenaResult], res)

@skip_if_older_3_dot_10
@pytest.mark.vcr
def test_team_standings(self):
res = Client().tournaments.get_team_standings("Qv0dRqml")
validate(TeamBattleResult, res)
4 changes: 3 additions & 1 deletion tests/clients/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pytest
import pprint
import sys

from pydantic import TypeAdapter, ConfigDict, PydanticUserError
Expand All @@ -17,7 +18,8 @@ def validate(t: type, value: any):
class TWithConfig(t):
__pydantic_config__ = config

print("value", value)
print("value")
pprint.PrettyPrinter(indent=2).pprint(value)
try:
# In case `t` is a `TypedDict`
return TypeAdapter(TWithConfig).validate_python(value)
Expand Down