diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 752d401..b8fa42d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,13 @@ Changelog ========= +To be released +-------------- + +* Add `client.users.get_by_autocomplete` + +Thanks to @handsamtw and @Anupya for their contributions to this release. + v0.13 (2023-09-29) -------------------- diff --git a/README.rst b/README.rst index 32e49e1..766a6dd 100644 --- a/README.rst +++ b/README.rst @@ -200,5 +200,6 @@ Most of the API is available: client.users.get_rating_history client.users.get_crosstable client.users.get_user_performance + client.users.get_by_autocomplete Details for each function can be found in the `documentation `_. diff --git a/berserk/__init__.py b/berserk/__init__.py index 8f0cff5..20f95e2 100644 --- a/berserk/__init__.py +++ b/berserk/__init__.py @@ -11,7 +11,7 @@ from .clients import Client -from .types import Team, OpeningStatistic, PaginatedTeams +from .types import Team, OpeningStatistic, PaginatedTeams, LightUser, OnlineLightUser from .session import TokenSession from .session import Requestor from .formats import JSON @@ -23,6 +23,8 @@ __all__ = [ "Client", + "LightUser", + "OnlineLightUser", "TokenSession", "Team", "PaginatedTeams", diff --git a/berserk/clients/users.py b/berserk/clients/users.py index 393b919..e60f1ed 100644 --- a/berserk/clients/users.py +++ b/berserk/clients/users.py @@ -1,12 +1,12 @@ from __future__ import annotations -from typing import Iterator, Dict, List, Any +from typing import Iterator, Dict, List, Any, cast from deprecated import deprecated from .. import models from .base import BaseClient from ..formats import JSON_LIST, LIJSON, NDJSON -from ..types.common import PerfType +from ..types.common import OnlineLightUser, PerfType from ..session import Params @@ -55,6 +55,30 @@ def get_all_top_10(self) -> Dict[str, Any]: path = "/api/player" return self._r.get(path, fmt=LIJSON) + def get_by_autocomplete( + self, + partial_username: str, + only_followed_players: bool = False, + as_object: bool = False, + ) -> List[str] | List[OnlineLightUser]: + """Provides autocompletion options for an incomplete username. + + :param partial_username: the beginning of a username, must provide >= 3 characters + :param only_followed_players: whether to return matching followed players only, if any exist + :param as_object: if false, returns an array of usernames else, returns an object with matching users + :return: followed players matching term if any, else returns other players. Requires OAuth. + """ + path = "/api/player/autocomplete" + params: Params = { + "term": partial_username, + "object": as_object, + "friend": only_followed_players, + } + response = self._r.get(path, fmt=LIJSON, params=params) + if as_object: + return cast(List[OnlineLightUser], response.get("result", [])) + return cast(List[str], response) + def get_leaderboard(self, perf_type: PerfType, count: int = 10): """Get the leaderboard for one speed or variant. diff --git a/berserk/types/__init__.py b/berserk/types/__init__.py index 79047f0..f0bf292 100644 --- a/berserk/types/__init__.py +++ b/berserk/types/__init__.py @@ -2,7 +2,7 @@ from .account import AccountInformation, Perf, Preferences, Profile, StreamerInfo from .bulk_pairings import BulkPairing, BulkPairingGame -from .common import ClockConfig +from .common import ClockConfig, LightUser, OnlineLightUser from .opening_explorer import ( OpeningExplorerRating, OpeningExplorerVariant, @@ -16,6 +16,8 @@ "BulkPairing", "BulkPairingGame", "ClockConfig", + "LightUser", + "OnlineLightUser", "OpeningExplorerRating", "OpeningExplorerVariant", "OpeningStatistic", diff --git a/berserk/types/common.py b/berserk/types/common.py index 9d1c24d..1a0463a 100644 --- a/berserk/types/common.py +++ b/berserk/types/common.py @@ -1,8 +1,8 @@ from __future__ import annotations -from typing import Union +from typing import Union, Literal -from typing_extensions import Literal, TypedDict, TypeAlias +from typing_extensions import Literal, TypedDict, TypeAlias, NotRequired class ClockConfig(TypedDict): @@ -26,6 +26,27 @@ class ClockConfig(TypedDict): "fromPosition", ] +Title = Literal[ + "GM", "WGM", "IM", "WIM", "FM", "WFM", "NM", "CM", "WCM", "WNM", "LM", "BOT" +] + + +class LightUser(TypedDict): + # The id of the user + id: str + # The name of the user + name: str + # The title of the user + title: NotRequired[Title] + # The patron of the user + patron: NotRequired[bool] + + +class OnlineLightUser(LightUser): + # Whether the user is online + online: NotRequired[bool] + + Variant: TypeAlias = Union[GameType, Literal["standard"]] PerfType: TypeAlias = Union[ diff --git a/berserk/types/team.py b/berserk/types/team.py index e9bb527..d993b2f 100644 --- a/berserk/types/team.py +++ b/berserk/types/team.py @@ -1,11 +1,9 @@ from __future__ import annotations -from typing import Literal, List +from typing import List from typing_extensions import TypedDict, NotRequired -Title = Literal[ - "GM", "WGM", "IM", "WIM", "FM", "WFM", "NM", "CM", "WCM", "WNM", "LM", "BOT" -] +from .common import LightUser class Team(TypedDict): @@ -29,17 +27,6 @@ class Team(TypedDict): requested: NotRequired[bool] -class LightUser(TypedDict): - # The id of the user - id: str - # The name of the user - name: str - # The title of the user - title: NotRequired[Title] - # The patron of the user - patron: NotRequired[bool] - - class PaginatedTeams(TypedDict): # The current page currentPage: int diff --git a/tests/clients/cassettes/test_users/TestLichessGames.test_get_by_autocomplete.yaml b/tests/clients/cassettes/test_users/TestLichessGames.test_get_by_autocomplete.yaml new file mode 100644 index 0000000..4c66e38 --- /dev/null +++ b/tests/clients/cassettes/test_users/TestLichessGames.test_get_by_autocomplete.yaml @@ -0,0 +1,48 @@ +interactions: +- request: + body: null + headers: + Accept: + - application/vnd.lichess.v3+json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - python-requests/2.31.0 + method: GET + uri: https://lichess.org/api/player/autocomplete?term=thisisatest&object=False&friend=False + response: + body: + string: '["thisisatest","thisisatesty","thisisatest24","thisisatesttt","thisisatest333","thisisatest666","thisisatest1234","thisisatest8083","thisisatest12345","thisisatestlololol","thisisatestaccount13"]' + 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: + - Sat, 28 Oct 2023 17:17:24 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: + - '195' + status: + code: 200 + message: OK +version: 1 diff --git a/tests/clients/cassettes/test_users/TestLichessGames.test_get_by_autocomplete_as_object.yaml b/tests/clients/cassettes/test_users/TestLichessGames.test_get_by_autocomplete_as_object.yaml new file mode 100644 index 0000000..ae2ed0e --- /dev/null +++ b/tests/clients/cassettes/test_users/TestLichessGames.test_get_by_autocomplete_as_object.yaml @@ -0,0 +1,48 @@ +interactions: +- request: + body: null + headers: + Accept: + - application/vnd.lichess.v3+json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - python-requests/2.31.0 + method: GET + uri: https://lichess.org/api/player/autocomplete?term=thisisatest&object=True&friend=False + response: + body: + string: '{"result":[{"name":"Thisisatest","id":"thisisatest"},{"name":"thisisatesty","id":"thisisatesty"},{"name":"thisisatest24","id":"thisisatest24"},{"name":"Thisisatesttt","id":"thisisatesttt"},{"name":"thisisatest333","id":"thisisatest333"},{"name":"thisisatest666","id":"thisisatest666"},{"name":"Thisisatest1234","id":"thisisatest1234"},{"name":"thisisatest8083","id":"thisisatest8083"},{"name":"ThisIsATest12345","id":"thisisatest12345"},{"name":"thisisatestlololol","id":"thisisatestlololol"},{"name":"thisisatestaccount13","id":"thisisatestaccount13"}]}' + 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: + - Sat, 28 Oct 2023 17:17:23 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: + - '554' + status: + code: 200 + message: OK +version: 1 diff --git a/tests/clients/cassettes/test_users/TestLichessGames.test_get_by_autocomplete_as_object_not_found.yaml b/tests/clients/cassettes/test_users/TestLichessGames.test_get_by_autocomplete_as_object_not_found.yaml new file mode 100644 index 0000000..0e00e13 --- /dev/null +++ b/tests/clients/cassettes/test_users/TestLichessGames.test_get_by_autocomplete_as_object_not_found.yaml @@ -0,0 +1,46 @@ +interactions: +- request: + body: null + headers: + Accept: + - application/vnd.lichess.v3+json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - python-requests/2.31.0 + method: GET + uri: https://lichess.org/api/player/autocomplete?term=username_not_found__&object=True&friend=False + response: + body: + string: '{"result":[]}' + 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-Length: + - '13' + Content-Type: + - application/json + Date: + - Sat, 28 Oct 2023 19:26:35 GMT + Permissions-Policy: + - interest-cohort=() + Server: + - nginx + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + Vary: + - Origin + X-Frame-Options: + - DENY + status: + code: 200 + message: OK +version: 1 diff --git a/tests/clients/cassettes/test_users/TestLichessGames.test_get_by_autocomplete_not_found.yaml b/tests/clients/cassettes/test_users/TestLichessGames.test_get_by_autocomplete_not_found.yaml new file mode 100644 index 0000000..02ad406 --- /dev/null +++ b/tests/clients/cassettes/test_users/TestLichessGames.test_get_by_autocomplete_not_found.yaml @@ -0,0 +1,46 @@ +interactions: +- request: + body: null + headers: + Accept: + - application/vnd.lichess.v3+json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - python-requests/2.31.0 + method: GET + uri: https://lichess.org/api/player/autocomplete?term=username_not_found__&object=False&friend=False + response: + body: + string: '[]' + 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-Length: + - '2' + Content-Type: + - application/json + Date: + - Sat, 28 Oct 2023 19:26:34 GMT + Permissions-Policy: + - interest-cohort=() + Server: + - nginx + Strict-Transport-Security: + - max-age=63072000; includeSubDomains; preload + Vary: + - Origin + X-Frame-Options: + - DENY + status: + code: 200 + message: OK +version: 1 diff --git a/tests/clients/test_users.py b/tests/clients/test_users.py new file mode 100644 index 0000000..10bd57a --- /dev/null +++ b/tests/clients/test_users.py @@ -0,0 +1,31 @@ +import pytest + +from berserk import Client, OnlineLightUser +from typing import List, Dict +from utils import validate, skip_if_older_3_dot_10 + + +class TestLichessGames: + @skip_if_older_3_dot_10 + @pytest.mark.vcr + def test_get_by_autocomplete_as_object(self): + res = Client().users.get_by_autocomplete("thisisatest", as_object=True) + validate(List[OnlineLightUser], res) + + @skip_if_older_3_dot_10 + @pytest.mark.vcr + def test_get_by_autocomplete(self): + res = Client().users.get_by_autocomplete("thisisatest") + validate(List[str], res) + + @skip_if_older_3_dot_10 + @pytest.mark.vcr + def test_get_by_autocomplete_not_found(self): + res = Client().users.get_by_autocomplete("username_not_found__") + validate(List[str], res) + + @skip_if_older_3_dot_10 + @pytest.mark.vcr + def test_get_by_autocomplete_as_object_not_found(self): + res = Client().users.get_by_autocomplete("username_not_found__", as_object=True) + validate(List[OnlineLightUser], res) diff --git a/tests/clients/utils.py b/tests/clients/utils.py index 84144f1..ce69b7d 100644 --- a/tests/clients/utils.py +++ b/tests/clients/utils.py @@ -11,12 +11,13 @@ def skip_if_older_3_dot_10(fn): )(fn) -def validate(t, value): +def validate(t: type, value: any): config = ConfigDict(strict=True, extra="forbid") class TWithConfig(t): __pydantic_config__ = config + print(value) try: # In case `t` is a `TypedDict` return TypeAdapter(TWithConfig).validate_python(value)