diff --git a/CHANGELOG.rst b/CHANGELOG.rst index aed7f8f..569335c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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 @@ -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`` @@ -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 diff --git a/README.rst b/README.rst index bcf7137..65c671d 100644 --- a/README.rst +++ b/README.rst @@ -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 diff --git a/berserk/clients/tournaments.py b/berserk/clients/tournaments.py index 91e4fc3..c748f85 100644 --- a/berserk/clients/tournaments.py +++ b/berserk/clients/tournaments.py @@ -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): @@ -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 + :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, @@ -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 @@ -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 @@ -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 @@ -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 """ @@ -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 """ @@ -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. @@ -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. @@ -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. @@ -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} diff --git a/berserk/types/tournaments.py b/berserk/types/tournaments.py index 91aec00..429a5a3 100644 --- a/berserk/types/tournaments.py +++ b/berserk/types/tournaments.py @@ -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 @@ -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] diff --git a/tests/clients/cassettes/test_tournaments/TestLichessGames.test_team_standings.yaml b/tests/clients/cassettes/test_tournaments/TestLichessGames.test_team_standings.yaml new file mode 100644 index 0000000..a158bca --- /dev/null +++ b/tests/clients/cassettes/test_tournaments/TestLichessGames.test_team_standings.yaml @@ -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 diff --git a/tests/clients/test_tournaments.py b/tests/clients/test_tournaments.py index 409673d..a0534fb 100644 --- a/tests/clients/test_tournaments.py +++ b/tests/clients/test_tournaments.py @@ -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 @@ -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) diff --git a/tests/clients/utils.py b/tests/clients/utils.py index 1a52dd6..b9f5082 100644 --- a/tests/clients/utils.py +++ b/tests/clients/utils.py @@ -1,4 +1,5 @@ import pytest +import pprint import sys from pydantic import TypeAdapter, ConfigDict, PydanticUserError @@ -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)