diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yml similarity index 87% rename from .github/workflows/ci.yaml rename to .github/workflows/ci.yml index d50f68e..411a36e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yml @@ -2,8 +2,6 @@ name: CI on: push: - branches: ["main"] - pull_request: jobs: test: @@ -11,7 +9,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.10", "3.12", "3.14"] + python-version: ["3.10.0", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yml similarity index 92% rename from .github/workflows/publish.yaml rename to .github/workflows/publish.yml index 4ed4387..e21f8ad 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yml @@ -22,4 +22,4 @@ jobs: run: uv build - name: Publish to PyPI - run: uv publish dist/* + run: uv publish diff --git a/.mypy.ini b/.mypy.ini index 50e92d0..e40b0fb 100644 --- a/.mypy.ini +++ b/.mypy.ini @@ -2,12 +2,9 @@ exclude = (?x)( ^assets/ | ^docs/ - | ^examples/ | ^tests/ ) local_partial_types = True -; We target Python 3.10+ for type checking because modern mypy releases -; have dropped support for Python 3.8, even though our library supports 3.8 python_version = 3.10 strict = True warn_unreachable = True diff --git a/.pytest.ini b/.pytest.ini index fb5000c..7e27d72 100644 --- a/.pytest.ini +++ b/.pytest.ini @@ -1,5 +1,4 @@ [pytest] asyncio_mode = auto -asyncio_default_fixture_loop_scope = function markers = e2e: End-to-end tests that interact with the real FACEIT API diff --git a/.ruff.toml b/.ruff.toml index 3afe8f2..550359a 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -1,6 +1,6 @@ line-length = 88 preview = true -target-version = "py38" +target-version = "py310" [lint] select = ["ALL"] @@ -16,8 +16,8 @@ ignore = [ "PLR0904", # Too many public methods "PLR0913", # Too many function arguments "RUF003", # Ambiguous Unicode character in comment - "SLF001", # Private member accessed outside class "S101", # Use of assert detected + "SLF001", # Private member accessed outside class "TD", # flake8-todos violations ] @@ -40,17 +40,20 @@ banned-from = [ "re", "reprlib", "tenacity", - "typing", "warnings", ] [lint.pep8-naming] -ignore-names = ["env", "pages"] +ignore-names = [ + "env", + "pages", +] [lint.per-file-ignores] "__init__.py" = [ "RUF067", # Code is present in `__init__.py` ] +"docs/*" = ["ALL"] "examples/*" = [ "ICN003", # Import a member from the module instead of importing the member directly "INP001", # Missing `__init__.py` file in a package directory @@ -62,15 +65,14 @@ ignore-names = ["env", "pages"] "E302", # Expected 2 blank lines before top-level definitions "E305", # Expected 2 blank lines after a class or function definition ] -"docs/*" = ["ALL"] "scripts/*" = [ - "T201", # `print()` statement detected "INP001", # Missing `__init__.py` file in a package directory + "T201", # `print()` statement detected ] "tests/*" = [ - "PLR0917", # Too many positional arguments "PLC1901", # Compare to empty string (e.g., `x == ""` instead of `not x`) "PLC2701", # Import of a private name from an external module + "PLR0917", # Too many positional arguments "PLR2004", # Magic value used in a comparison "PLR6301", # Method could be a function, class method, or static method (doesn't use `self`) "PT011", # `pytest.raises()` is too broad, set the `match` parameter @@ -87,6 +89,3 @@ ignore-names = ["env", "pages"] allow-dunder-method-names = [ "__get_pydantic_core_schema__", ] - -[lint.pyupgrade] -keep-runtime-typing = true diff --git a/README.md b/README.md index fa89d1a..f601805 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # FACEIT Python API Library -[![Python](https://img.shields.io/badge/Python-3.8%2B-FAD6C5?style=flat-square)](https://www.python.org/) +[![Python](https://img.shields.io/badge/Python-3.10%2B-FAD6C5?style=flat-square)](https://www.python.org/) [![PyPI](https://img.shields.io/pypi/v/faceit?label=PyPI&style=flat-square&color=FAD6C5)](https://pypi.org/project/faceit/) [![License](https://img.shields.io/badge/License-Apache_2.0-FAD6C5?style=flat-square)](https://opensource.org/licenses/Apache-2.0) [![Downloads](https://img.shields.io/pypi/dm/faceit?label=Downloads&style=flat-square&color=FAD6C5)](https://pypi.org/project/faceit/) @@ -29,7 +29,7 @@ ## Installation -> Requires **Python 3.8+**. +> Requires **Python 3.10+**. ```bash pip install faceit diff --git a/examples/simple_discord_bot.py b/examples/simple_discord_bot.py index 952228d..8e9cbee 100644 --- a/examples/simple_discord_bot.py +++ b/examples/simple_discord_bot.py @@ -1,4 +1,3 @@ -from contextlib import suppress from dataclasses import dataclass from typing import Any, cast @@ -8,6 +7,7 @@ from disnake.ext import commands import faceit +import faceit.exceptions from faceit.models.players import MatchResult @@ -18,31 +18,31 @@ class StatsCommand(commands.Cog): async def cog_slash_command_error( # noqa: PLR6301 self, - inter: disnake.ApplicationCommandInteraction[Any], + inter: disnake.CommandInteraction[Any], error: Exception, ) -> None: if isinstance(error, commands.CommandInvokeError): error = error.original - player_name = inter.filled_options.get("player_name", "") - - if isinstance(error, pydantic.ValidationError): - await inter.edit_original_response( - f"⚠️ We couldn't process the profile for **{player_name}**. " - "Please check if the nickname is entered correctly." - ) - elif isinstance(error, faceit.exceptions.NotFoundError): - await inter.edit_original_response( - f"❌ Player **{player_name}** not found." - ) - elif isinstance(error, faceit.APIError): - await inter.edit_original_response( - f"⚠️ API Error [{error.status_code}]: {error.message}" - ) - else: - await inter.edit_original_response( - "💥 An unexpected error occurred. Please try again later." - ) + player_name = inter.filled_options.get("player_name", "") + match error: + case pydantic.ValidationError(): + await inter.edit_original_response( + f"⚠️ We couldn't process the profile for `{player_name}`. " + "Please check if the nickname is entered correctly." + ) + case faceit.exceptions.NotFoundError(): + await inter.edit_original_response( + f"❌ Player `{player_name}` not found." + ) + case faceit.exceptions.APIError(): + await inter.edit_original_response( + f"⚠️ API Error (`{error.status_code}`): {error.message}" + ) + case _: + await inter.edit_original_response( + "💥 An unexpected error occurred. Please try again later." + ) @commands.slash_command( name="stats", @@ -50,7 +50,7 @@ async def cog_slash_command_error( # noqa: PLR6301 ) async def stats( self, - inter: disnake.ApplicationCommandInteraction[Any], + inter: disnake.CommandInteraction[Any], player_name: str = commands.Param( description="FACEIT player nickname", ), @@ -59,11 +59,10 @@ async def stats( player = await self.faceit_data.players.get(player_name) - cs2_game = player.games.get(faceit.GameID.CS2) - if cs2_game is None: + cs2_stats = player.games.get(faceit.GameID.CS2) + if cs2_stats is None: return await inter.edit_original_response( - f"🔎 Player **{player.nickname}** found, " - "but they don't have CS2 linked." + f"🔎 Player `{player.nickname}` found, but they don't have CS2 linked." ) player_stats = await self.faceit_data.players.stats( @@ -79,8 +78,8 @@ async def stats( if player.avatar: embed.set_thumbnail(url=player.avatar) - embed.add_field("🎮 Level", f"**{int(cs2_game.level)} LVL**", inline=True) - embed.add_field("📈 ELO", f"**{cs2_game.elo}**", inline=True) + embed.add_field("🎮 Level", f"**{int(cs2_stats.level)} LVL**", inline=True) + embed.add_field("📈 ELO", f"**{cs2_stats.elo}**", inline=True) embed.add_field( "📊 K/D", f"**{player_stats.lifetime.average_kd_ratio}**", @@ -98,14 +97,11 @@ async def stats( ) if player_stats.lifetime.recent_results: - embed.add_field( - "Recent Results", - " ".join( - "✅" if result is MatchResult.WIN else "❌" - for result in player_stats.lifetime.recent_results - ), - inline=False, + results = " ".join( + "✅" if result is MatchResult.WIN else "❌" + for result in player_stats.lifetime.recent_results ) + embed.add_field("Recent Results", results, inline=False) embed.set_footer(text="Powered by faceit-python") return await inter.edit_original_response(embed=embed) @@ -119,17 +115,14 @@ async def main() -> None: "DISCORD_BOT_TOKEN" ), ) - async with ( - # NOTE: Ensure the `FACEIT_API_KEY` is set in your environment variables - # (Requires `faceit[env]` to be installed) - faceit.AsyncDataResource() # or use faceit.AsyncDataResource("YOUR_FACEIT_API_KEY") - ) as data: + async with faceit.AsyncDataResource() as data: bot.add_cog(StatsCommand(bot, data)) await bot.start(bot_token) if __name__ == "__main__": import asyncio + from contextlib import suppress with suppress(KeyboardInterrupt, asyncio.CancelledError): # CTRL+C asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index 7981e59..dee3d79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,9 @@ [project] name = "faceit" -version = "0.2.3" +version = "0.3.0" description = "The Python wrapper for the FACEIT API" readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.10" license = "Apache-2.0" authors = [ { name = "zombyacoff", email = "zombyacoff@gmail.com" }, @@ -14,19 +14,18 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Typing :: Typed", ] dependencies = [ - "httpx>=0.27.0,<1.0.0", - "pydantic>=2.7.1,<3.0.0", - "tenacity>=8.2.3,<9.0.0", + "httpx>=0.28.0", + "pydantic>=2.13.0", + "tenacity>=8.5.0", ] [project.optional-dependencies] @@ -34,25 +33,21 @@ env = ["python-decouple>=3.8"] [dependency-groups] dev = [ - "mypy>=1.14", - "pre-commit==3.5", - "pytest>=7.4", - "pytest-asyncio>=0.21.1", - "ruff>=0.4.8", + "mypy>=2.0.0", + "pre-commit>=3.5", + "pytest>=9.0.0", + "pytest-asyncio>=1.3.0", + "ruff>=0.15.0", ] examples = [ - "disnake>=2.0.0,<3.0.0", - # Maybe needed later - # "pydantic-settings>=2.0.0", - # "wireup>=2.0.0,<3.0.0", + "disnake>=2.0.0", ] [project.urls] -"Documentation" = "https://docs.faceit.com/docs" - -[tool.hatch.build.targets.wheel] -packages = ["src/faceit"] +"Bug Tracker" = "https://github.com/zombyacoff/faceit-python/issues" +"Releases" = "https://github.com/zombyacoff/faceit-python/releases" +"Repository" = "https://github.com/zombyacoff/faceit-python" [build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" +requires = ["uv_build>=0.11.14"] +build-backend = "uv_build" diff --git a/scripts/clean.py b/scripts/clean.py index 8885868..e2e0731 100644 --- a/scripts/clean.py +++ b/scripts/clean.py @@ -1,6 +1,6 @@ import shutil from pathlib import Path -from typing import Final # noqa: ICN003 +from typing import Final PYCACHE: Final = "__pycache__" DIRS: Final = { diff --git a/src/faceit/__init__.py b/src/faceit/__init__.py index 9c230e8..e22bc96 100644 --- a/src/faceit/__init__.py +++ b/src/faceit/__init__.py @@ -18,17 +18,14 @@ pages, ) from .constants import EventCategory, ExpandedField, GameID, Region, SkillLevel -from .faceit import AsyncFaceit, Faceit from .http import FromEnv, MaxConcurrentRequests __all__ = [ "AsyncDataResource", - "AsyncFaceit", # deprecated (remove in v0.3.0 ?) "AsyncPageIterator", "CollectReturnFormat", "EventCategory", "ExpandedField", - "Faceit", # deprecated (remove in v0.3.0 ?) "FromEnv", "GameID", "MaxConcurrentRequests", diff --git a/src/faceit/api/aggregator.py b/src/faceit/api/aggregator.py index 92d5940..e155cdd 100644 --- a/src/faceit/api/aggregator.py +++ b/src/faceit/api/aggregator.py @@ -1,38 +1,38 @@ from __future__ import annotations -import typing import warnings from abc import ABC from functools import cached_property +from typing import TYPE_CHECKING, Any, Generic, TypeVar, get_args from typing_extensions import Never, Self from faceit.http import AsyncClient, FromEnv, SyncClient from faceit.types import ClientT, Raw, ValidUUID -if typing.TYPE_CHECKING: +if TYPE_CHECKING: from faceit.api.base import BaseResource from faceit.http.client import BaseAPIClient - _ResourceT = typing.TypeVar("_ResourceT", bound="BaseResource[typing.Any]") - _AggregatorT = typing.TypeVar("_AggregatorT", bound="BaseResources[typing.Any]") + _ResourceT = TypeVar("_ResourceT", bound="BaseResource[Any]") + _AggregatorT = TypeVar("_AggregatorT", bound="BaseResources[Any]") -class BaseResources(ABC, typing.Generic[ClientT]): +class BaseResources(ABC, Generic[ClientT]): __slots__ = ("_client",) - if typing.TYPE_CHECKING: + if TYPE_CHECKING: _client: ClientT - _client_cls: typing.Type[ClientT] + _client_cls: type[ClientT] def _initialize_client( self, - auth: typing.Union[ValidUUID, BaseAPIClient.env, None] = None, - client: typing.Optional[ClientT] = None, + auth: ValidUUID | BaseAPIClient.env | None = None, + client: ClientT | None = None, /, *, secret_type: str, - **client_options: typing.Any, + **client_options: Any, ) -> None: if auth is not None and client is not None: msg = f"Provide either {secret_type!r} or 'client', not both" @@ -90,21 +90,18 @@ async def __aexit__(self, *args: object, **kwargs: object) -> None: await self._client.__aexit__(*args, **kwargs) -def resource_aggregator(cls: typing.Type[_AggregatorT], /) -> typing.Type[_AggregatorT]: +def resource_aggregator(cls: type[_AggregatorT], /) -> type[_AggregatorT]: for name, resource_type in cls.__annotations__.items(): def make_property( - resource_type: typing.Type[_ResourceT], *, is_raw: bool + resource_type: type[_ResourceT], *, is_raw: bool ) -> cached_property[_ResourceT]: def factory(self: _AggregatorT) -> _ResourceT: return resource_type(self._client, raw=is_raw) return cached_property(factory) - property_ = make_property( - resource_type, - is_raw=Raw in typing.get_args(resource_type), - ) + property_ = make_property(resource_type, is_raw=Raw in get_args(resource_type)) setattr(cls, name, property_) property_.__set_name__(cls, name) diff --git a/src/faceit/api/base.py b/src/faceit/api/base.py index 3544dfb..88f55d6 100644 --- a/src/faceit/api/base.py +++ b/src/faceit/api/base.py @@ -1,11 +1,20 @@ from __future__ import annotations import logging -import typing import warnings from abc import ABC from dataclasses import dataclass from types import MappingProxyType +from typing import ( + TYPE_CHECKING, + Any, + ClassVar, + Generic, + TypedDict, + TypeVar, + cast, + final, +) from pydantic import ValidationError @@ -19,10 +28,12 @@ RawAPIPageResponse, RawAPIResponse, ) -from faceit.utils import warn_stacklevel +from faceit.utils import find_user_stacklevel + +if TYPE_CHECKING: + from collections.abc import Mapping -if typing.TYPE_CHECKING: - _ResponseT = typing.TypeVar("_ResponseT", bound=RawAPIResponse) + _ResponseT = TypeVar("_ResponseT", bound=RawAPIResponse) _logger = logging.getLogger(__name__) @@ -32,34 +43,34 @@ ModelPlaceholder: None = None -@typing.final -class RequestPayload(typing.TypedDict): +@final +class RequestPayload(TypedDict): endpoint: Endpoint - params: typing.Mapping[str, typing.Any] + params: Mapping[str, Any] -@typing.final +@final @dataclass(eq=False, frozen=True) -class MappedValidatorConfig(typing.Generic[_T, ModelT]): - validator_map: typing.Mapping[_T, typing.Type[ModelT]] +class MappedValidatorConfig(Generic[_T, ModelT]): + validator_map: Mapping[_T, type[ModelT]] key_name: str = "key" - default_validator: typing.Optional[typing.Type[ModelT]] = None + default_validator: type[ModelT] | None = None # TODO: Refactor the base resource class if/when support for resources # other than Data is required, since the current implementation is # too Data-centric. -class BaseResource(ABC, typing.Generic[ClientT]): +class BaseResource(ABC, Generic[ClientT]): __slots__ = ( "_client", "_raw", "_strict_validation", ) - if typing.TYPE_CHECKING: - PATH: typing.ClassVar[Endpoint] + if TYPE_CHECKING: + PATH: ClassVar[Endpoint] - _PARAM_NAME_MAP: typing.ClassVar[typing.Mapping[str, str]] = MappingProxyType({ + _PARAM_NAME_MAP: ClassVar[Mapping[str, str]] = MappingProxyType({ "start": "from", "category": "type", }) @@ -77,8 +88,8 @@ def __init__( def __init_subclass__( cls, - resource_path: typing.Optional[str] = None, - **kwargs: typing.Any, + resource_path: str | None = None, + **kwargs: Any, ) -> None: if hasattr(cls, "PATH"): return @@ -104,7 +115,7 @@ def _process_item( response: RawAPIItem, key: _T, config: MappedValidatorConfig[_T, ModelT], - ) -> typing.Union[ModelT, RawAPIItem]: + ) -> RawAPIItem | ModelT: if self._raw: return response return self._validate_response( @@ -117,7 +128,7 @@ def _process_page( response: RawAPIPageResponse, key: _T, config: MappedValidatorConfig[_T, ModelT], - ) -> typing.Union[ItemPage[ModelT], RawAPIPageResponse]: + ) -> RawAPIPageResponse | ItemPage[ModelT]: if self._raw: return response @@ -125,8 +136,8 @@ def _process_page( if validator is None: page_validator = None else: - page_validator = typing.cast( - "typing.Type[ItemPage[ModelT]]", + page_validator = cast( + "type[ItemPage[ModelT]]", # Suppressing type checking warning because we're using a # dynamic runtime subscript `ItemPage` is being subscripted # with a variable (`validator`) which mypy cannot statically verify @@ -146,11 +157,11 @@ def _process_page( def _validate_response( self, response: _ResponseT, - validator: typing.Optional[typing.Type[ModelT]], + validator: type[ModelT] | None, /, *, - warn_msg: typing.Optional[str] = None, - ) -> typing.Union[_ResponseT, ModelT]: + warn_msg: str | None = None, + ) -> _ResponseT | ModelT: if self._raw: return response if validator is None: @@ -161,7 +172,7 @@ def _validate_response( "unprocessed data." ) msg = default_warn_msg if warn_msg is None else warn_msg - warnings.warn(msg, stacklevel=warn_stacklevel()) + warnings.warn(msg, stacklevel=find_user_stacklevel()) return response try: return validator.model_validate(response) @@ -177,7 +188,7 @@ def _validate_response( return response @classmethod - def _build_params(cls, **params: typing.Any) -> typing.Dict[str, typing.Any]: + def _build_params(cls, **params: Any) -> dict[str, Any]: return { cls._PARAM_NAME_MAP.get(key, key): value for key, value in params.items() diff --git a/src/faceit/api/data/__init__.py b/src/faceit/api/data/__init__.py index 033b9a2..7fbdb27 100644 --- a/src/faceit/api/data/__init__.py +++ b/src/faceit/api/data/__init__.py @@ -1,4 +1,4 @@ -import typing +from typing import Any, final, overload from faceit.api.aggregator import ( AsyncResources, @@ -49,29 +49,29 @@ class _DataResourceMixin: - @typing.overload + @overload def __init__(self) -> None: ... - @typing.overload + @overload def __init__( # type: ignore[misc] self: BaseResources[ClientT], *, client: ClientT, ) -> None: ... - @typing.overload + @overload def __init__( self, - api_key: typing.Union[ValidUUID, BaseAPIClient.env], - **client_options: typing.Any, + api_key: ValidUUID | BaseAPIClient.env, + **client_options: Any, ) -> None: ... def __init__( # type: ignore[misc] self: BaseResources[ClientT], # pyright: ignore[reportGeneralTypeIssues] - api_key: typing.Union[ValidUUID, BaseAPIClient.env, None] = None, + api_key: ValidUUID | BaseAPIClient.env | None = None, *, - client: typing.Optional[ClientT] = None, - **client_options: typing.Any, + client: ClientT | None = None, + **client_options: Any, ) -> None: self._initialize_client( api_key, @@ -81,7 +81,7 @@ def __init__( # type: ignore[misc] ) -@typing.final +@final @resource_aggregator class SyncDataResource(SyncResources, _DataResourceMixin): championships: SyncChampionships[Model] @@ -109,7 +109,7 @@ class SyncDataResource(SyncResources, _DataResourceMixin): raw_teams: SyncTeams[Raw] -@typing.final +@final @resource_aggregator class AsyncDataResource(AsyncResources, _DataResourceMixin): championships: AsyncChampionships[Model] diff --git a/src/faceit/api/data/championships.py b/src/faceit/api/data/championships.py index 7822950..43568f9 100644 --- a/src/faceit/api/data/championships.py +++ b/src/faceit/api/data/championships.py @@ -1,10 +1,10 @@ from __future__ import annotations -import typing from abc import ABC +from collections.abc import Sequence # noqa: TC003 +from typing import Annotated, Generic, TypeAlias, final, overload from pydantic import AfterValidator, Field, validate_call -from typing_extensions import Annotated, TypeAlias from faceit.api.base import BaseResource, ModelPlaceholder from faceit.api.pagination import ( @@ -43,13 +43,11 @@ class BaseChampionships( __slots__ = () -@typing.final -class SyncChampionships( - BaseChampionships[SyncClient], typing.Generic[APIResponseFormatT] -): +@final +class SyncChampionships(BaseChampionships[SyncClient], Generic[APIResponseFormatT]): __slots__ = () - @typing.overload + @overload def items( self: SyncChampionships[Raw], game: GameID, @@ -59,7 +57,7 @@ def items( limit: int = Field(10, ge=1, le=10), ) -> RawAPIPageResponse: ... - @typing.overload + @overload def items( self: SyncChampionships[Model], game: GameID, @@ -77,7 +75,7 @@ def items( *, offset: int = Field(0, ge=0), limit: int = Field(10, ge=1, le=10), - ) -> typing.Union[RawAPIPageResponse, ItemPage[Championship]]: + ) -> RawAPIPageResponse | ItemPage[Championship]: return self._validate_response( self._client.get( self.__class__.PATH, @@ -89,15 +87,15 @@ def items( ItemPage[Championship], ) - @typing.overload + @overload def all_items( self: SyncChampionships[Raw], game: GameID, category: EventCategory = EventCategory.ALL, max_items: MaxItemsType = pages(30), - ) -> typing.List[RawAPIItem]: ... + ) -> list[RawAPIItem]: ... - @typing.overload + @overload def all_items( self: SyncChampionships[Model], game: GameID, @@ -110,36 +108,30 @@ def all_items( game: GameID, category: EventCategory = EventCategory.ALL, max_items: MaxItemsType = pages(30), - ) -> typing.Union[typing.List[RawAPIItem], ItemPage[Championship]]: + ) -> list[RawAPIItem] | ItemPage[Championship]: iterator = SyncPageIterator(self.items, game, category, max_items=max_items) return iterator.collect() - @typing.overload + @overload def get( self: SyncChampionships[Raw], championship_id: _ChampionshipID, - expanded: typing.Optional[ - typing.Union[ExpandedField, typing.Sequence[ExpandedField]] - ] = None, + expanded: ExpandedField | Sequence[ExpandedField] | None = None, ) -> RawAPIItem: ... - @typing.overload + @overload def get( self: SyncChampionships[Model], championship_id: _ChampionshipID, - expanded: typing.Optional[ - typing.Union[ExpandedField, typing.Sequence[ExpandedField]] - ] = None, + expanded: ExpandedField | Sequence[ExpandedField] | None = None, ) -> ModelNotImplemented: ... @validate_call def get( self, championship_id: _ChampionshipIDValidated, - expanded: typing.Optional[ - typing.Union[ExpandedField, typing.Sequence[ExpandedField]] - ] = None, - ) -> typing.Union[RawAPIItem, ModelNotImplemented]: + expanded: ExpandedField | Sequence[ExpandedField] | None = None, + ) -> RawAPIItem | ModelNotImplemented: return self._validate_response( self._client.get( self.__class__.PATH / str(championship_id), @@ -151,7 +143,7 @@ def get( __call__ = get - @typing.overload + @overload def matches( self: SyncChampionships[Raw], championship_id: _ChampionshipID, @@ -161,7 +153,7 @@ def matches( limit: int = Field(20, ge=1, le=100), ) -> RawAPIPageResponse: ... - @typing.overload + @overload def matches( self: SyncChampionships[Model], championship_id: _ChampionshipID, @@ -179,7 +171,7 @@ def matches( *, offset: int = Field(0, ge=0), limit: int = Field(20, ge=1, le=100), - ) -> typing.Union[RawAPIPageResponse, ModelNotImplemented]: + ) -> RawAPIPageResponse | ModelNotImplemented: return self._validate_response( self._client.get( self.__class__.PATH / str(championship_id) / "matches", @@ -191,7 +183,7 @@ def matches( ModelPlaceholder, ) - @typing.overload + @overload def results( self: SyncChampionships[Raw], championship_id: _ChampionshipID, @@ -200,7 +192,7 @@ def results( limit: int = Field(20, ge=1, le=100), ) -> RawAPIPageResponse: ... - @typing.overload + @overload def results( self: SyncChampionships[Model], championship_id: _ChampionshipID, @@ -216,7 +208,7 @@ def results( *, offset: int = Field(0, ge=0), limit: int = Field(20, ge=1, le=100), - ) -> typing.Union[RawAPIPageResponse, ModelNotImplemented]: + ) -> RawAPIPageResponse | ModelNotImplemented: return self._validate_response( self._client.get( self.__class__.PATH / str(championship_id) / "results", @@ -226,7 +218,7 @@ def results( ModelPlaceholder, ) - @typing.overload + @overload def subscriptions( self: SyncChampionships[Raw], championship_id: _ChampionshipID, @@ -235,7 +227,7 @@ def subscriptions( limit: int = Field(10, ge=1, le=10), ) -> RawAPIPageResponse: ... - @typing.overload + @overload def subscriptions( self: SyncChampionships[Model], championship_id: _ChampionshipID, @@ -251,7 +243,7 @@ def subscriptions( *, offset: int = Field(0, ge=0), limit: int = Field(10, ge=1, le=10), - ) -> typing.Union[RawAPIPageResponse, ModelNotImplemented]: + ) -> RawAPIPageResponse | ModelNotImplemented: return self._validate_response( self._client.get( self.__class__.PATH / str(championship_id) / "subscriptions", @@ -262,13 +254,11 @@ def subscriptions( ) -@typing.final -class AsyncChampionships( - BaseChampionships[AsyncClient], typing.Generic[APIResponseFormatT] -): +@final +class AsyncChampionships(BaseChampionships[AsyncClient], Generic[APIResponseFormatT]): __slots__ = () - @typing.overload + @overload async def items( self: AsyncChampionships[Raw], game: GameID, @@ -278,7 +268,7 @@ async def items( limit: int = Field(10, ge=1, le=10), ) -> RawAPIPageResponse: ... - @typing.overload + @overload async def items( self: AsyncChampionships[Model], game: GameID, @@ -296,7 +286,7 @@ async def items( *, offset: int = Field(0, ge=0), limit: int = Field(10, ge=1, le=10), - ) -> typing.Union[RawAPIPageResponse, ItemPage[Championship]]: + ) -> RawAPIPageResponse | ItemPage[Championship]: return self._validate_response( await self._client.get( self.__class__.PATH, @@ -308,15 +298,15 @@ async def items( ItemPage[Championship], ) - @typing.overload + @overload async def all_items( self: AsyncChampionships[Raw], game: GameID, category: EventCategory = EventCategory.ALL, max_items: MaxItemsType = pages(30), - ) -> typing.List[RawAPIItem]: ... + ) -> list[RawAPIItem]: ... - @typing.overload + @overload async def all_items( self: AsyncChampionships[Model], game: GameID, @@ -329,36 +319,30 @@ async def all_items( game: GameID, category: EventCategory = EventCategory.ALL, max_items: MaxItemsType = pages(30), - ) -> typing.Union[typing.List[RawAPIItem], ItemPage[Championship]]: + ) -> list[RawAPIItem] | ItemPage[Championship]: iterator = AsyncPageIterator(self.items, game, category, max_items=max_items) return await iterator.collect() - @typing.overload + @overload async def get( self: AsyncChampionships[Raw], championship_id: _ChampionshipID, - expanded: typing.Optional[ - typing.Union[ExpandedField, typing.Sequence[ExpandedField]] - ] = None, + expanded: ExpandedField | Sequence[ExpandedField] | None = None, ) -> RawAPIItem: ... - @typing.overload + @overload async def get( self: AsyncChampionships[Model], championship_id: _ChampionshipID, - expanded: typing.Optional[ - typing.Union[ExpandedField, typing.Sequence[ExpandedField]] - ] = None, + expanded: ExpandedField | Sequence[ExpandedField] | None = None, ) -> ModelNotImplemented: ... @validate_call async def get( self, championship_id: _ChampionshipIDValidated, - expanded: typing.Optional[ - typing.Union[ExpandedField, typing.Sequence[ExpandedField]] - ] = None, - ) -> typing.Union[RawAPIItem, ModelNotImplemented]: + expanded: ExpandedField | Sequence[ExpandedField] | None = None, + ) -> RawAPIItem | ModelNotImplemented: return self._validate_response( await self._client.get( self.__class__.PATH / str(championship_id), @@ -370,7 +354,7 @@ async def get( __call__ = get - @typing.overload + @overload async def matches( self: AsyncChampionships[Raw], championship_id: _ChampionshipID, @@ -380,7 +364,7 @@ async def matches( limit: int = Field(20, ge=1, le=100), ) -> RawAPIPageResponse: ... - @typing.overload + @overload async def matches( self: AsyncChampionships[Model], championship_id: _ChampionshipID, @@ -398,7 +382,7 @@ async def matches( *, offset: int = Field(0, ge=0), limit: int = Field(20, ge=1, le=100), - ) -> typing.Union[RawAPIPageResponse, ModelNotImplemented]: + ) -> RawAPIPageResponse | ModelNotImplemented: return self._validate_response( await self._client.get( self.__class__.PATH / str(championship_id) / "matches", @@ -410,7 +394,7 @@ async def matches( ModelPlaceholder, ) - @typing.overload + @overload async def results( self: AsyncChampionships[Raw], championship_id: _ChampionshipID, @@ -419,7 +403,7 @@ async def results( limit: int = Field(20, ge=1, le=100), ) -> RawAPIPageResponse: ... - @typing.overload + @overload async def results( self: AsyncChampionships[Model], championship_id: _ChampionshipID, @@ -435,7 +419,7 @@ async def results( *, offset: int = Field(0, ge=0), limit: int = Field(20, ge=1, le=100), - ) -> typing.Union[RawAPIPageResponse, ModelNotImplemented]: + ) -> RawAPIPageResponse | ModelNotImplemented: return self._validate_response( await self._client.get( self.__class__.PATH / str(championship_id) / "results", @@ -445,7 +429,7 @@ async def results( ModelPlaceholder, ) - @typing.overload + @overload async def subscriptions( self: AsyncChampionships[Raw], championship_id: _ChampionshipID, @@ -454,7 +438,7 @@ async def subscriptions( limit: int = Field(10, ge=1, le=10), ) -> RawAPIPageResponse: ... - @typing.overload + @overload async def subscriptions( self: AsyncChampionships[Model], championship_id: _ChampionshipID, @@ -470,7 +454,7 @@ async def subscriptions( *, offset: int = Field(0, ge=0), limit: int = Field(10, ge=1, le=10), - ) -> typing.Union[RawAPIPageResponse, ModelNotImplemented]: + ) -> RawAPIPageResponse | ModelNotImplemented: return self._validate_response( await self._client.get( self.__class__.PATH / str(championship_id) / "subscriptions", diff --git a/src/faceit/api/data/games.py b/src/faceit/api/data/games.py index ea3ce4c..c01be0c 100644 --- a/src/faceit/api/data/games.py +++ b/src/faceit/api/data/games.py @@ -1,7 +1,7 @@ from __future__ import annotations -import typing from abc import ABC +from typing import Generic, final, overload from pydantic import Field, validate_call @@ -33,11 +33,11 @@ class BaseGames( __slots__ = () -@typing.final -class SyncGames(BaseGames[SyncClient], typing.Generic[APIResponseFormatT]): +@final +class SyncGames(BaseGames[SyncClient], Generic[APIResponseFormatT]): __slots__ = () - @typing.overload + @overload def items( self: SyncGames[Raw], *, @@ -45,7 +45,7 @@ def items( limit: int = Field(20, ge=1, le=100), ) -> RawAPIPageResponse: ... - @typing.overload + @overload def items( self: SyncGames[Model], *, @@ -59,7 +59,7 @@ def items( *, offset: int = Field(0, ge=0), limit: int = Field(20, ge=1, le=100), - ) -> typing.Union[RawAPIPageResponse, ItemPage[ModelNotImplemented]]: + ) -> RawAPIPageResponse | ItemPage[ModelNotImplemented]: return self._validate_response( self._client.get( self.__class__.PATH, @@ -69,28 +69,28 @@ def items( ModelPlaceholder, ) - @typing.overload + @overload def all_items( self: SyncGames[Raw], max_items: MaxItemsType = MaxItems.SAFE - ) -> typing.List[RawAPIItem]: ... + ) -> list[RawAPIItem]: ... - @typing.overload + @overload def all_items( self: SyncGames[Model], max_items: MaxItemsType = MaxItems.SAFE ) -> ItemPage[ModelNotImplemented]: ... def all_items( self, max_items: MaxItemsType = MaxItems.SAFE - ) -> typing.Union[typing.List[RawAPIItem], ItemPage[ModelNotImplemented]]: + ) -> list[RawAPIItem] | ItemPage[ModelNotImplemented]: iterator = SyncPageIterator(self.items, max_items=max_items) return iterator.collect() -@typing.final -class AsyncGames(BaseGames[AsyncClient], typing.Generic[APIResponseFormatT]): +@final +class AsyncGames(BaseGames[AsyncClient], Generic[APIResponseFormatT]): __slots__ = () - @typing.overload + @overload async def items( self: AsyncGames[Raw], *, @@ -98,7 +98,7 @@ async def items( limit: int = Field(20, ge=1, le=100), ) -> RawAPIPageResponse: ... - @typing.overload + @overload async def items( self: AsyncGames[Model], *, @@ -112,7 +112,7 @@ async def items( *, offset: int = Field(0, ge=0), limit: int = Field(20, ge=1, le=100), - ) -> typing.Union[RawAPIPageResponse, ItemPage[ModelNotImplemented]]: + ) -> RawAPIPageResponse | ItemPage[ModelNotImplemented]: return self._validate_response( await self._client.get( self.__class__.PATH, @@ -122,18 +122,18 @@ async def items( ModelPlaceholder, ) - @typing.overload + @overload async def all_items( self: AsyncGames[Raw], max_items: MaxItemsType = MaxItems.SAFE - ) -> typing.List[RawAPIItem]: ... + ) -> list[RawAPIItem]: ... - @typing.overload + @overload async def all_items( self: AsyncGames[Model], max_items: MaxItemsType = MaxItems.SAFE ) -> ItemPage[ModelNotImplemented]: ... async def all_items( self, max_items: MaxItemsType = MaxItems.SAFE - ) -> typing.Union[typing.List[RawAPIItem], ItemPage[ModelNotImplemented]]: + ) -> list[RawAPIItem] | ItemPage[ModelNotImplemented]: iterator = AsyncPageIterator(self.items, max_items=max_items) return await iterator.collect() diff --git a/src/faceit/api/data/helpers.py b/src/faceit/api/data/helpers.py index 74c9f4c..5ff339b 100644 --- a/src/faceit/api/data/helpers.py +++ b/src/faceit/api/data/helpers.py @@ -1,5 +1,5 @@ -import typing from contextlib import suppress +from typing import Any from faceit.constants import FACEIT_USERNAME_REGEX from faceit.utils import create_uuid_validator @@ -7,7 +7,7 @@ validate_player_id = create_uuid_validator(arg_name="player_id") -def validate_player_id_or_nickname(value: typing.Any, /) -> str: +def validate_player_id_or_nickname(value: Any, /) -> str: with suppress(ValueError): return validate_player_id(value) if FACEIT_USERNAME_REGEX.fullmatch(value) is not None: diff --git a/src/faceit/api/data/leagues.py b/src/faceit/api/data/leagues.py index f8e371c..34ac253 100644 --- a/src/faceit/api/data/leagues.py +++ b/src/faceit/api/data/leagues.py @@ -1,10 +1,9 @@ from __future__ import annotations -import typing from abc import ABC +from typing import Annotated, Generic, TypeAlias, final, overload from pydantic import AfterValidator, Field, validate_call -from typing_extensions import Annotated, TypeAlias from faceit.api.base import BaseResource, ModelPlaceholder from faceit.http import AsyncClient, SyncClient @@ -37,14 +36,14 @@ class BaseLeagues( __slots__ = () -@typing.final -class SyncLeagues(BaseLeagues[SyncClient], typing.Generic[APIResponseFormatT]): +@final +class SyncLeagues(BaseLeagues[SyncClient], Generic[APIResponseFormatT]): __slots__ = () - @typing.overload + @overload def get(self: SyncLeagues[Raw], matchmaking_id: _LeagueID) -> RawAPIItem: ... - @typing.overload + @overload def get( self: SyncLeagues[Model], matchmaking_id: _LeagueID ) -> ModelNotImplemented: ... @@ -52,7 +51,7 @@ def get( @validate_call def get( self, matchmaking_id: _LeagueIDValidated - ) -> typing.Union[RawAPIItem, ModelNotImplemented]: + ) -> RawAPIItem | ModelNotImplemented: return self._validate_response( self._client.get( self.__class__.PATH / str(matchmaking_id), expect_item=True @@ -62,12 +61,12 @@ def get( __call__ = get - @typing.overload + @overload def season( self: SyncLeagues[Raw], matchmaking_id: _LeagueID, season_id: _SeasonID ) -> RawAPIItem: ... - @typing.overload + @overload def season( self: SyncLeagues[Model], matchmaking_id: _LeagueID, season_id: _SeasonID ) -> ModelNotImplemented: ... @@ -75,7 +74,7 @@ def season( @validate_call def season( self, matchmaking_id: _LeagueIDValidated, season_id: _SeasonID - ) -> typing.Union[RawAPIItem, ModelNotImplemented]: + ) -> RawAPIItem | ModelNotImplemented: return self._validate_response( self._client.get( self.__class__.PATH / str(matchmaking_id) / "seasons" / str(season_id), @@ -84,7 +83,7 @@ def season( ModelPlaceholder, ) - @typing.overload + @overload def player( self: SyncLeagues[Raw], matchmaking_id: _LeagueID, @@ -92,7 +91,7 @@ def player( player_id: PlayerID, ) -> RawAPIItem: ... - @typing.overload + @overload def player( self: SyncLeagues[Model], matchmaking_id: _LeagueID, @@ -106,7 +105,7 @@ def player( matchmaking_id: _LeagueIDValidated, season_id: _SeasonID, player_id: PlayerIDValidated, - ) -> typing.Union[RawAPIItem, ModelNotImplemented]: + ) -> RawAPIItem | ModelNotImplemented: # fmt: off return self._validate_response( self._client.get( @@ -118,14 +117,14 @@ def player( # fmt: on -@typing.final -class AsyncLeagues(BaseLeagues[AsyncClient], typing.Generic[APIResponseFormatT]): +@final +class AsyncLeagues(BaseLeagues[AsyncClient], Generic[APIResponseFormatT]): __slots__ = () - @typing.overload + @overload async def get(self: AsyncLeagues[Raw], matchmaking_id: _LeagueID) -> RawAPIItem: ... - @typing.overload + @overload async def get( self: AsyncLeagues[Model], matchmaking_id: _LeagueID ) -> ModelNotImplemented: ... @@ -133,7 +132,7 @@ async def get( @validate_call async def get( self, matchmaking_id: _LeagueIDValidated - ) -> typing.Union[RawAPIItem, ModelNotImplemented]: + ) -> RawAPIItem | ModelNotImplemented: return self._validate_response( await self._client.get( self.__class__.PATH / str(matchmaking_id), expect_item=True @@ -143,12 +142,12 @@ async def get( __call__ = get - @typing.overload + @overload async def season( self: AsyncLeagues[Raw], matchmaking_id: _LeagueID, season_id: _SeasonID ) -> RawAPIItem: ... - @typing.overload + @overload async def season( self: AsyncLeagues[Model], matchmaking_id: _LeagueID, season_id: _SeasonID ) -> ModelNotImplemented: ... @@ -156,7 +155,7 @@ async def season( @validate_call async def season( self, matchmaking_id: _LeagueIDValidated, season_id: _SeasonID - ) -> typing.Union[RawAPIItem, ModelNotImplemented]: + ) -> RawAPIItem | ModelNotImplemented: return self._validate_response( await self._client.get( self.__class__.PATH / str(matchmaking_id) / "seasons" / str(season_id), @@ -165,7 +164,7 @@ async def season( ModelPlaceholder, ) - @typing.overload + @overload async def player( self: AsyncLeagues[Raw], matchmaking_id: _LeagueID, @@ -173,7 +172,7 @@ async def player( player_id: PlayerID, ) -> RawAPIItem: ... - @typing.overload + @overload async def player( self: AsyncLeagues[Model], matchmaking_id: _LeagueID, @@ -187,7 +186,7 @@ async def player( matchmaking_id: _LeagueIDValidated, season_id: _SeasonID, player_id: PlayerIDValidated, - ) -> typing.Union[RawAPIItem, ModelNotImplemented]: + ) -> RawAPIItem | ModelNotImplemented: # fmt: off return self._validate_response( await self._client.get( diff --git a/src/faceit/api/data/matches.py b/src/faceit/api/data/matches.py index 772d8b7..a7369b7 100644 --- a/src/faceit/api/data/matches.py +++ b/src/faceit/api/data/matches.py @@ -1,10 +1,9 @@ from __future__ import annotations -import typing from abc import ABC +from typing import Annotated, Generic, TypeAlias, final, overload from pydantic import AfterValidator, validate_call -from typing_extensions import Annotated, TypeAlias from faceit.api.base import BaseResource, ModelPlaceholder from faceit.http import AsyncClient, SyncClient @@ -19,11 +18,9 @@ ) _MatchID: TypeAlias = str -# We use `AfterValidator` with the `_MatchID` type alias instead of `FaceitMatchID` directly -# to avoid mypy complaints. Mypy cannot fully recognize our custom type as compatible -# with str, so this approach ensures proper type checking and validation. _MatchIDValidated: TypeAlias = Annotated[ - _MatchID, AfterValidator(FaceitMatchID._validate) + _MatchID, + AfterValidator(FaceitMatchID), ] @@ -35,20 +32,18 @@ class BaseMatches( __slots__ = () -@typing.final -class SyncMatches(BaseMatches[SyncClient], typing.Generic[APIResponseFormatT]): +@final +class SyncMatches(BaseMatches[SyncClient], Generic[APIResponseFormatT]): __slots__ = () - @typing.overload + @overload def get(self: SyncMatches[Raw], match_id: _MatchID) -> RawAPIItem: ... - @typing.overload + @overload def get(self: SyncMatches[Model], match_id: _MatchID) -> ModelNotImplemented: ... @validate_call - def get( - self, match_id: _MatchIDValidated - ) -> typing.Union[RawAPIItem, ModelNotImplemented]: + def get(self, match_id: _MatchIDValidated) -> RawAPIItem | ModelNotImplemented: return self._validate_response( self._client.get(self.__class__.PATH / match_id, expect_item=True), ModelPlaceholder, @@ -56,16 +51,14 @@ def get( __call__ = get - @typing.overload + @overload def stats(self: SyncMatches[Raw], match_id: _MatchID) -> RawAPIItem: ... - @typing.overload + @overload def stats(self: SyncMatches[Model], match_id: _MatchID) -> ModelNotImplemented: ... @validate_call - def stats( - self, match_id: _MatchIDValidated - ) -> typing.Union[RawAPIItem, ModelNotImplemented]: + def stats(self, match_id: _MatchIDValidated) -> RawAPIItem | ModelNotImplemented: return self._validate_response( self._client.get( self.__class__.PATH / match_id / "stats", expect_item=True @@ -74,14 +67,14 @@ def stats( ) -@typing.final -class AsyncMatches(BaseMatches[AsyncClient], typing.Generic[APIResponseFormatT]): +@final +class AsyncMatches(BaseMatches[AsyncClient], Generic[APIResponseFormatT]): __slots__ = () - @typing.overload + @overload async def get(self: AsyncMatches[Raw], match_id: _MatchID) -> RawAPIItem: ... - @typing.overload + @overload async def get( self: AsyncMatches[Model], match_id: _MatchID ) -> ModelNotImplemented: ... @@ -89,7 +82,7 @@ async def get( @validate_call async def get( self, match_id: _MatchIDValidated - ) -> typing.Union[RawAPIItem, ModelNotImplemented]: + ) -> RawAPIItem | ModelNotImplemented: return self._validate_response( await self._client.get(self.__class__.PATH / match_id, expect_item=True), ModelPlaceholder, @@ -97,10 +90,10 @@ async def get( __call__ = get - @typing.overload + @overload async def stats(self: AsyncMatches[Raw], match_id: _MatchID) -> RawAPIItem: ... - @typing.overload + @overload async def stats( self: AsyncMatches[Model], match_id: _MatchID ) -> ModelNotImplemented: ... @@ -108,7 +101,7 @@ async def stats( @validate_call async def stats( self, match_id: _MatchIDValidated - ) -> typing.Union[RawAPIItem, ModelNotImplemented]: + ) -> RawAPIItem | ModelNotImplemented: return self._validate_response( await self._client.get( self.__class__.PATH / match_id / "stats", expect_item=True diff --git a/src/faceit/api/data/matchmakings.py b/src/faceit/api/data/matchmakings.py index 8e885ed..b86c169 100644 --- a/src/faceit/api/data/matchmakings.py +++ b/src/faceit/api/data/matchmakings.py @@ -1,10 +1,9 @@ from __future__ import annotations -import typing from abc import ABC +from typing import Annotated, Generic, TypeAlias, final, overload from pydantic import AfterValidator, validate_call -from typing_extensions import Annotated, TypeAlias from faceit.api.base import BaseResource, ModelPlaceholder from faceit.http import AsyncClient, SyncClient @@ -32,18 +31,16 @@ class BaseMatchmakings( __slots__ = () -@typing.final -class SyncMatchmakings( - BaseMatchmakings[SyncClient], typing.Generic[APIResponseFormatT] -): +@final +class SyncMatchmakings(BaseMatchmakings[SyncClient], Generic[APIResponseFormatT]): __slots__ = () - @typing.overload + @overload def get( self: SyncMatchmakings[Raw], matchmaking_id: _MatchmakingID ) -> RawAPIItem: ... - @typing.overload + @overload def get( self: SyncMatchmakings[Model], matchmaking_id: _MatchmakingID ) -> ModelNotImplemented: ... @@ -51,7 +48,7 @@ def get( @validate_call def get( self, matchmaking_id: _MatchmakingIDValidated - ) -> typing.Union[RawAPIItem, ModelNotImplemented]: + ) -> RawAPIItem | ModelNotImplemented: return self._validate_response( self._client.get( self.__class__.PATH / str(matchmaking_id), expect_item=True @@ -62,18 +59,16 @@ def get( __call__ = get -@typing.final -class AsyncMatchmakings( - BaseMatchmakings[AsyncClient], typing.Generic[APIResponseFormatT] -): +@final +class AsyncMatchmakings(BaseMatchmakings[AsyncClient], Generic[APIResponseFormatT]): __slots__ = () - @typing.overload + @overload async def get( self: AsyncMatchmakings[Raw], matchmaking_id: _MatchmakingID ) -> RawAPIItem: ... - @typing.overload + @overload async def get( self: AsyncMatchmakings[Model], matchmaking_id: _MatchmakingID ) -> ModelNotImplemented: ... @@ -81,7 +76,7 @@ async def get( @validate_call async def get( self, matchmaking_id: _MatchmakingIDValidated - ) -> typing.Union[RawAPIItem, ModelNotImplemented]: + ) -> RawAPIItem | ModelNotImplemented: return self._validate_response( await self._client.get( self.__class__.PATH / str(matchmaking_id), expect_item=True diff --git a/src/faceit/api/data/players.py b/src/faceit/api/data/players.py index b6f0f21..55bfad9 100644 --- a/src/faceit/api/data/players.py +++ b/src/faceit/api/data/players.py @@ -1,12 +1,20 @@ from __future__ import annotations import logging -import typing import warnings from abc import ABC +from typing import ( + Annotated, + Any, + ClassVar, + Generic, + Literal, + TypeAlias, + final, + overload, +) from pydantic import AfterValidator, Field, validate_call -from typing_extensions import Annotated, TypeAlias from faceit.api.base import ( BaseResource, @@ -56,7 +64,7 @@ PlayerID: TypeAlias = ValidUUID PlayerIDValidated: TypeAlias = Annotated[PlayerID, AfterValidator(validate_player_id)] -_PlayerIdentifier: TypeAlias = typing.Union[str, ValidUUID] +_PlayerIdentifier: TypeAlias = str | ValidUUID _PlayerIdentifierValidated: TypeAlias = Annotated[ _PlayerIdentifier, AfterValidator(validate_player_id_or_nickname) ] @@ -69,7 +77,7 @@ class BasePlayers( ): __slots__ = () - _matches_stats_validator_cfg: typing.ClassVar = MappedValidatorConfig[ + _matches_stats_validator_cfg: ClassVar = MappedValidatorConfig[ GameID, AbstractMatchPlayerStats ]( validator_map={ @@ -78,9 +86,7 @@ class BasePlayers( }, key_name="game", ) - _stats_validator_cfg: typing.ClassVar = MappedValidatorConfig[ - GameID, AnyPlayerStats - ]( + _stats_validator_cfg: ClassVar = MappedValidatorConfig[GameID, AnyPlayerStats]( validator_map={ GameID.CS2: CSPlayerStats, GameID.CSGO: CSPlayerStats, @@ -89,18 +95,18 @@ class BasePlayers( default_validator=FallbackPlayerStats, ) - _matches_stats_timestamp_cfg: typing.ClassVar = TimestampPaginationConfig( + _matches_stats_timestamp_cfg: ClassVar = TimestampPaginationConfig( key="stats.Match Finished At", attr="finished_at" ) - _history_timestamp_cfg: typing.ClassVar = TimestampPaginationConfig( + _history_timestamp_cfg: ClassVar = TimestampPaginationConfig( key="finished_at", attr="finished_at" ) def _process_get_request( self, - player_lookup_key: typing.Any, - game: typing.Optional[GameID], - game_player_id: typing.Optional[str], + player_lookup_key: Any, + game: GameID | None, + game_player_id: str | None, ) -> RequestPayload: params = self.__class__._build_params(game=game, game_player_id=game_player_id) @@ -137,26 +143,26 @@ def _process_get_request( return RequestPayload(endpoint=self.__class__.PATH, params=params) -@typing.final -class SyncPlayers(BasePlayers[SyncClient], typing.Generic[APIResponseFormatT]): +@final +class SyncPlayers(BasePlayers[SyncClient], Generic[APIResponseFormatT]): __slots__ = () - @typing.overload + @overload def get( self: SyncPlayers[Raw], player_lookup_key: _PlayerIdentifier ) -> RawAPIItem: ... - @typing.overload + @overload def get( self: SyncPlayers[Raw], *, game: GameID, game_player_id: str ) -> RawAPIItem: ... - @typing.overload + @overload def get( self: SyncPlayers[Model], player_lookup_key: _PlayerIdentifier ) -> Player: ... - @typing.overload + @overload def get( self: SyncPlayers[Model], *, game: GameID, game_player_id: str ) -> Player: ... @@ -164,11 +170,11 @@ def get( @validate_call def get( self, - player_lookup_key: typing.Optional[_PlayerIdentifierValidated] = None, + player_lookup_key: _PlayerIdentifierValidated | None = None, *, - game: typing.Optional[GameID] = None, - game_player_id: typing.Optional[str] = None, - ) -> typing.Union[RawAPIItem, Player]: + game: GameID | None = None, + game_player_id: str | None = None, + ) -> RawAPIItem | Player: return self._validate_response( self._client.get( **self._process_get_request(player_lookup_key, game, game_player_id), @@ -184,7 +190,7 @@ def get( # Using `Field(...)` as default value rather than `Annotated[..., Field(...)]` # to expose constraints in IDE tooltips and improve developer experience - @typing.overload + @overload def bans( self: SyncPlayers[Raw], player_id: PlayerID, @@ -193,7 +199,7 @@ def bans( limit: int = Field(20, ge=1, le=100), ) -> RawAPIPageResponse: ... - @typing.overload + @overload def bans( self: SyncPlayers[Model], player_id: PlayerID, @@ -209,7 +215,7 @@ def bans( *, offset: int = Field(0, ge=0), limit: int = Field(20, ge=1, le=100), - ) -> typing.Union[RawAPIPageResponse, ItemPage[BanEntry]]: + ) -> RawAPIPageResponse | ItemPage[BanEntry]: return self._validate_response( self._client.get( # `player_id` is validated and normalized; @@ -221,14 +227,14 @@ def bans( ItemPage[BanEntry], ) - @typing.overload + @overload def all_bans( self: SyncPlayers[Raw], player_id: PlayerID, max_items: MaxItemsType = MaxItems.SAFE, - ) -> typing.List[RawAPIItem]: ... + ) -> list[RawAPIItem]: ... - @typing.overload + @overload def all_bans( self: SyncPlayers[Model], player_id: PlayerID, @@ -237,11 +243,11 @@ def all_bans( def all_bans( self, player_id: PlayerID, max_items: MaxItemsType = MaxItems.SAFE - ) -> typing.Union[typing.List[RawAPIItem], ItemPage[BanEntry]]: + ) -> list[RawAPIItem] | ItemPage[BanEntry]: iterator = SyncPageIterator(self.bans, player_id, max_items=max_items) return iterator.collect() - @typing.overload + @overload def matches_stats( self: SyncPlayers[Raw], player_id: PlayerID, @@ -249,26 +255,26 @@ def matches_stats( *, offset: int = Field(0, ge=0, le=200), limit: int = Field(20, ge=1, le=100), - start: typing.Optional[NotStrictTimestampMs] = None, - to: typing.Optional[NotStrictTimestampMs] = None, + start: NotStrictTimestampMs | None = None, + to: NotStrictTimestampMs | None = None, ) -> RawAPIPageResponse: ... # TODO: This overload-based approach for specific games feels clunky and doesn't scale well. # Need to investigate a more sophisticated way to map `GameID` to specific models, # but this is the current best effort. - @typing.overload + @overload def matches_stats( self: SyncPlayers[Model], player_id: PlayerID, - game: typing.Literal[GameID.CS2], + game: Literal[GameID.CS2], *, offset: int = Field(0, ge=0, le=200), limit: int = Field(20, ge=1, le=100), - start: typing.Optional[NotStrictTimestampMs] = None, - to: typing.Optional[NotStrictTimestampMs] = None, + start: NotStrictTimestampMs | None = None, + to: NotStrictTimestampMs | None = None, ) -> ItemPage[CS2MatchPlayerStats]: ... - @typing.overload + @overload def matches_stats( # Fallback self: SyncPlayers[Model], player_id: PlayerID, @@ -276,8 +282,8 @@ def matches_stats( # Fallback *, offset: int = Field(0, ge=0, le=200), limit: int = Field(20, ge=1, le=100), - start: typing.Optional[NotStrictTimestampMs] = None, - to: typing.Optional[NotStrictTimestampMs] = None, + start: NotStrictTimestampMs | None = None, + to: NotStrictTimestampMs | None = None, ) -> ItemPage[AbstractMatchPlayerStats]: ... @validate_call @@ -288,13 +294,13 @@ def matches_stats( *, offset: int = Field(0, ge=0, le=200), limit: int = Field(20, ge=1, le=100), - start: typing.Optional[NotStrictTimestampMs] = None, - to: typing.Optional[NotStrictTimestampMs] = None, - ) -> typing.Union[ - RawAPIPageResponse, - ItemPage[AbstractMatchPlayerStats], - ItemPage[CS2MatchPlayerStats], - ]: + start: NotStrictTimestampMs | None = None, + to: NotStrictTimestampMs | None = None, + ) -> ( + RawAPIPageResponse + | ItemPage[AbstractMatchPlayerStats] + | ItemPage[CS2MatchPlayerStats] + ): return self._process_page( self._client.get( self.__class__.PATH / str(player_id) / "games" / game / "stats", @@ -307,23 +313,23 @@ def matches_stats( self.__class__._matches_stats_validator_cfg, ) - @typing.overload + @overload def all_matches_stats( self: SyncPlayers[Raw], player_id: PlayerID, game: GameID, max_items: MaxItemsType = pages(50), - ) -> typing.List[RawAPIItem]: ... + ) -> list[RawAPIItem]: ... - @typing.overload + @overload def all_matches_stats( self: SyncPlayers[Model], player_id: PlayerID, - game: typing.Literal[GameID.CS2], + game: Literal[GameID.CS2], max_items: MaxItemsType = pages(50), ) -> ItemPage[CS2MatchPlayerStats]: ... - @typing.overload + @overload def all_matches_stats( self: SyncPlayers[Model], player_id: PlayerID, @@ -336,11 +342,11 @@ def all_matches_stats( player_id: PlayerID, game: GameID, max_items: MaxItemsType = pages(50), - ) -> typing.Union[ - typing.List[RawAPIItem], - ItemPage[CS2MatchPlayerStats], - ItemPage[AbstractMatchPlayerStats], - ]: + ) -> ( + list[RawAPIItem] + | ItemPage[CS2MatchPlayerStats] + | ItemPage[AbstractMatchPlayerStats] + ): return SyncPageIterator.gather_from_iterator( SyncPageIterator.unix( self.matches_stats, @@ -351,7 +357,7 @@ def all_matches_stats( ) ) - @typing.overload + @overload def history( self: SyncPlayers[Raw], player_id: PlayerID, @@ -359,11 +365,11 @@ def history( *, offset: int = Field(0, ge=0, le=1000), limit: int = Field(20, ge=1, le=100), - start: typing.Optional[NotStrictTimestampMs] = None, - to: typing.Optional[NotStrictTimestampMs] = None, + start: NotStrictTimestampMs | None = None, + to: NotStrictTimestampMs | None = None, ) -> RawAPIPageResponse: ... - @typing.overload + @overload def history( self: SyncPlayers[Model], player_id: PlayerID, @@ -371,8 +377,8 @@ def history( *, offset: int = Field(0, ge=0, le=1000), limit: int = Field(20, ge=1, le=100), - start: typing.Optional[NotStrictTimestampMs] = None, - to: typing.Optional[NotStrictTimestampMs] = None, + start: NotStrictTimestampMs | None = None, + to: NotStrictTimestampMs | None = None, ) -> ItemPage[Match]: ... @validate_call @@ -383,9 +389,9 @@ def history( *, offset: int = Field(0, ge=0, le=1000), limit: int = Field(20, ge=1, le=100), - start: typing.Optional[NotStrictTimestampMs] = None, - to: typing.Optional[NotStrictTimestampMs] = None, - ) -> typing.Union[RawAPIPageResponse, ItemPage[Match]]: + start: NotStrictTimestampMs | None = None, + to: NotStrictTimestampMs | None = None, + ) -> RawAPIPageResponse | ItemPage[Match]: return self._validate_response( self._client.get( self.__class__.PATH / str(player_id) / "history", @@ -397,15 +403,15 @@ def history( ItemPage[Match], ) - @typing.overload + @overload def all_history( self: SyncPlayers[Raw], player_id: PlayerID, game: GameID, max_items: MaxItemsType = pages(50), - ) -> typing.List[RawAPIItem]: ... + ) -> list[RawAPIItem]: ... - @typing.overload + @overload def all_history( self: SyncPlayers[Model], player_id: PlayerID, @@ -418,7 +424,7 @@ def all_history( player_id: PlayerID, game: GameID, max_items: MaxItemsType = pages(50), - ) -> typing.Union[typing.List[RawAPIItem], ItemPage[Match]]: + ) -> list[RawAPIItem] | ItemPage[Match]: return SyncPageIterator.gather_from_iterator( SyncPageIterator.unix( self.history, @@ -429,7 +435,7 @@ def all_history( ) ) - @typing.overload + @overload def hubs( self: SyncPlayers[Raw], player_id: PlayerID, @@ -438,7 +444,7 @@ def hubs( limit: int = Field(50, ge=1, le=50), ) -> RawAPIPageResponse: ... - @typing.overload + @overload def hubs( self: SyncPlayers[Model], player_id: PlayerID, @@ -454,7 +460,7 @@ def hubs( *, offset: int = Field(0, ge=0, le=1000), limit: int = Field(50, ge=1, le=50), - ) -> typing.Union[RawAPIPageResponse, ItemPage[Hub]]: + ) -> RawAPIPageResponse | ItemPage[Hub]: return self._validate_response( self._client.get( self.__class__.PATH / str(player_id) / "hubs", @@ -464,14 +470,14 @@ def hubs( ItemPage[Hub], ) - @typing.overload + @overload def all_hubs( self: SyncPlayers[Raw], player_id: PlayerID, max_items: MaxItemsType = MaxItems.SAFE, - ) -> typing.List[RawAPIItem]: ... + ) -> list[RawAPIItem]: ... - @typing.overload + @overload def all_hubs( self: SyncPlayers[Model], player_id: PlayerID, @@ -480,21 +486,21 @@ def all_hubs( def all_hubs( self, player_id: PlayerID, max_items: MaxItemsType = MaxItems.SAFE - ) -> typing.Union[typing.List[RawAPIItem], ItemPage[Hub]]: + ) -> list[RawAPIItem] | ItemPage[Hub]: iterator = SyncPageIterator(self.hubs, player_id, max_items=max_items) return iterator.collect() - @typing.overload + @overload def stats( self: SyncPlayers[Raw], player_id: PlayerID, game: GameID ) -> RawAPIItem: ... - @typing.overload + @overload def stats( # type: ignore[overload-overlap] self: SyncPlayers[Model], player_id: PlayerID, game: AnyCSID ) -> CSPlayerStats: ... - @typing.overload + @overload def stats( self: SyncPlayers[Model], player_id: PlayerID, game: GameID ) -> FallbackPlayerStats: ... @@ -502,7 +508,7 @@ def stats( @validate_call def stats( self, player_id: PlayerIDValidated, game: GameID - ) -> typing.Union[RawAPIItem, AnyPlayerStats]: + ) -> RawAPIItem | AnyPlayerStats: return self._process_item( self._client.get( self.__class__.PATH / str(player_id) / "stats" / game, @@ -512,7 +518,7 @@ def stats( self.__class__._stats_validator_cfg, ) - @typing.overload + @overload def teams( self: SyncPlayers[Raw], player_id: PlayerID, @@ -521,7 +527,7 @@ def teams( limit: int = Field(20, ge=1, le=100), ) -> RawAPIPageResponse: ... - @typing.overload + @overload def teams( self: SyncPlayers[Model], player_id: PlayerID, @@ -537,7 +543,7 @@ def teams( *, offset: int = Field(0, ge=0), limit: int = Field(20, ge=1, le=100), - ) -> typing.Union[RawAPIPageResponse, ItemPage[GeneralTeam]]: + ) -> RawAPIPageResponse | ItemPage[GeneralTeam]: return self._validate_response( self._client.get( self.__class__.PATH / str(player_id) / "teams", @@ -547,14 +553,14 @@ def teams( ItemPage[GeneralTeam], ) - @typing.overload + @overload def all_teams( self: SyncPlayers[Raw], player_id: PlayerID, max_items: MaxItemsType = MaxItems.SAFE, - ) -> typing.List[RawAPIItem]: ... + ) -> list[RawAPIItem]: ... - @typing.overload + @overload def all_teams( self: SyncPlayers[Model], player_id: PlayerID, @@ -563,11 +569,11 @@ def all_teams( def all_teams( self, player_id: PlayerID, max_items: MaxItemsType = MaxItems.SAFE - ) -> typing.Union[typing.List[RawAPIItem], ItemPage[GeneralTeam]]: + ) -> list[RawAPIItem] | ItemPage[GeneralTeam]: iterator = SyncPageIterator(self.teams, player_id, max_items=max_items) return iterator.collect() - @typing.overload + @overload def tournaments( self: SyncPlayers[Raw], player_id: PlayerID, @@ -576,7 +582,7 @@ def tournaments( limit: int = Field(20, ge=1, le=100), ) -> RawAPIPageResponse: ... - @typing.overload + @overload def tournaments( self: SyncPlayers[Model], player_id: PlayerID, @@ -592,7 +598,7 @@ def tournaments( *, offset: int = Field(0, ge=0), limit: int = Field(20, ge=1, le=100), - ) -> typing.Union[RawAPIPageResponse, ItemPage[Tournament]]: + ) -> RawAPIPageResponse | ItemPage[Tournament]: return self._validate_response( self._client.get( self.__class__.PATH / str(player_id) / "tournaments", @@ -602,14 +608,14 @@ def tournaments( ItemPage[Tournament], ) - @typing.overload + @overload def all_tournaments( self: SyncPlayers[Raw], player_id: PlayerID, max_items: MaxItemsType = MaxItems.SAFE, - ) -> typing.List[RawAPIItem]: ... + ) -> list[RawAPIItem]: ... - @typing.overload + @overload def all_tournaments( self: SyncPlayers[Model], player_id: PlayerID, @@ -618,31 +624,31 @@ def all_tournaments( def all_tournaments( self, player_id: PlayerID, max_items: MaxItemsType = MaxItems.SAFE - ) -> typing.Union[typing.List[RawAPIItem], ItemPage[Tournament]]: + ) -> list[RawAPIItem] | ItemPage[Tournament]: iterator = SyncPageIterator(self.tournaments, player_id, max_items=max_items) return iterator.collect() -@typing.final -class AsyncPlayers(BasePlayers[AsyncClient], typing.Generic[APIResponseFormatT]): +@final +class AsyncPlayers(BasePlayers[AsyncClient], Generic[APIResponseFormatT]): __slots__ = () - @typing.overload + @overload async def get( self: AsyncPlayers[Raw], player_lookup_key: _PlayerIdentifier ) -> RawAPIItem: ... - @typing.overload + @overload async def get( self: AsyncPlayers[Raw], *, game: GameID, game_player_id: str ) -> RawAPIItem: ... - @typing.overload + @overload async def get( self: AsyncPlayers[Model], player_lookup_key: _PlayerIdentifier ) -> Player: ... - @typing.overload + @overload async def get( self: AsyncPlayers[Model], *, game: GameID, game_player_id: str ) -> Player: ... @@ -650,11 +656,11 @@ async def get( @validate_call async def get( self, - player_lookup_key: typing.Optional[_PlayerIdentifierValidated] = None, + player_lookup_key: _PlayerIdentifierValidated | None = None, *, - game: typing.Optional[GameID] = None, - game_player_id: typing.Optional[str] = None, - ) -> typing.Union[RawAPIItem, Player]: + game: GameID | None = None, + game_player_id: str | None = None, + ) -> RawAPIItem | Player: return self._validate_response( await self._client.get( **self._process_get_request(player_lookup_key, game, game_player_id), @@ -665,7 +671,7 @@ async def get( __call__ = get - @typing.overload + @overload async def bans( self: AsyncPlayers[Raw], player_id: PlayerID, @@ -674,7 +680,7 @@ async def bans( limit: int = Field(20, ge=1, le=100), ) -> RawAPIPageResponse: ... - @typing.overload + @overload async def bans( self: AsyncPlayers[Model], player_id: PlayerID, @@ -690,7 +696,7 @@ async def bans( *, offset: int = Field(0, ge=0), limit: int = Field(20, ge=1, le=100), - ) -> typing.Union[RawAPIPageResponse, ItemPage[BanEntry]]: + ) -> RawAPIPageResponse | ItemPage[BanEntry]: return self._validate_response( await self._client.get( self.__class__.PATH / str(player_id) / "bans", @@ -700,14 +706,14 @@ async def bans( ItemPage[BanEntry], ) - @typing.overload + @overload async def all_bans( self: AsyncPlayers[Raw], player_id: PlayerID, max_items: MaxItemsType = MaxItems.SAFE, - ) -> typing.List[RawAPIItem]: ... + ) -> list[RawAPIItem]: ... - @typing.overload + @overload async def all_bans( self: AsyncPlayers[Model], player_id: PlayerID, @@ -716,11 +722,11 @@ async def all_bans( async def all_bans( self, player_id: PlayerID, max_items: MaxItemsType = MaxItems.SAFE - ) -> typing.Union[typing.List[RawAPIItem], ItemPage[BanEntry]]: + ) -> list[RawAPIItem] | ItemPage[BanEntry]: iterator = AsyncPageIterator(self.bans, player_id, max_items=max_items) return await iterator.collect() - @typing.overload + @overload async def matches_stats( self: AsyncPlayers[Raw], player_id: PlayerID, @@ -728,23 +734,23 @@ async def matches_stats( *, offset: int = Field(0, ge=0, le=200), limit: int = Field(20, ge=1, le=100), - start: typing.Optional[NotStrictTimestampMs] = None, - to: typing.Optional[NotStrictTimestampMs] = None, + start: NotStrictTimestampMs | None = None, + to: NotStrictTimestampMs | None = None, ) -> RawAPIPageResponse: ... - @typing.overload + @overload async def matches_stats( self: AsyncPlayers[Model], player_id: PlayerID, - game: typing.Literal[GameID.CS2], + game: Literal[GameID.CS2], *, offset: int = Field(0, ge=0, le=200), limit: int = Field(20, ge=1, le=100), - start: typing.Optional[NotStrictTimestampMs] = None, - to: typing.Optional[NotStrictTimestampMs] = None, + start: NotStrictTimestampMs | None = None, + to: NotStrictTimestampMs | None = None, ) -> ItemPage[CS2MatchPlayerStats]: ... - @typing.overload + @overload async def matches_stats( self: AsyncPlayers[Model], player_id: PlayerID, @@ -752,8 +758,8 @@ async def matches_stats( *, offset: int = Field(0, ge=0, le=200), limit: int = Field(20, ge=1, le=100), - start: typing.Optional[NotStrictTimestampMs] = None, - to: typing.Optional[NotStrictTimestampMs] = None, + start: NotStrictTimestampMs | None = None, + to: NotStrictTimestampMs | None = None, ) -> ItemPage[AbstractMatchPlayerStats]: ... @validate_call @@ -764,13 +770,13 @@ async def matches_stats( *, offset: int = Field(0, ge=0, le=200), limit: int = Field(20, ge=1, le=100), - start: typing.Optional[NotStrictTimestampMs] = None, - to: typing.Optional[NotStrictTimestampMs] = None, - ) -> typing.Union[ - RawAPIPageResponse, - ItemPage[AbstractMatchPlayerStats], - ItemPage[CS2MatchPlayerStats], - ]: + start: NotStrictTimestampMs | None = None, + to: NotStrictTimestampMs | None = None, + ) -> ( + RawAPIPageResponse + | ItemPage[AbstractMatchPlayerStats] + | ItemPage[CS2MatchPlayerStats] + ): return self._process_page( await self._client.get( self.__class__.PATH / str(player_id) / "games" / game / "stats", @@ -783,23 +789,23 @@ async def matches_stats( self.__class__._matches_stats_validator_cfg, ) - @typing.overload + @overload async def all_matches_stats( self: AsyncPlayers[Raw], player_id: PlayerID, game: GameID, max_items: MaxItemsType = pages(50), - ) -> typing.List[RawAPIItem]: ... + ) -> list[RawAPIItem]: ... - @typing.overload + @overload async def all_matches_stats( self: AsyncPlayers[Model], player_id: PlayerID, - game: typing.Literal[GameID.CS2], + game: Literal[GameID.CS2], max_items: MaxItemsType = pages(50), ) -> ItemPage[CS2MatchPlayerStats]: ... - @typing.overload + @overload async def all_matches_stats( self: AsyncPlayers[Model], player_id: PlayerID, @@ -812,11 +818,11 @@ async def all_matches_stats( player_id: PlayerID, game: GameID, max_items: MaxItemsType = pages(50), - ) -> typing.Union[ - typing.List[RawAPIItem], - ItemPage[AbstractMatchPlayerStats], - ItemPage[CS2MatchPlayerStats], - ]: + ) -> ( + list[RawAPIItem] + | ItemPage[AbstractMatchPlayerStats] + | ItemPage[CS2MatchPlayerStats] + ): return await AsyncPageIterator.gather_from_iterator( AsyncPageIterator.unix( self.matches_stats, @@ -827,7 +833,7 @@ async def all_matches_stats( ) ) - @typing.overload + @overload async def history( self: AsyncPlayers[Raw], player_id: PlayerID, @@ -835,11 +841,11 @@ async def history( *, offset: int = Field(0, ge=0, le=1000), limit: int = Field(20, ge=1, le=100), - start: typing.Optional[NotStrictTimestampMs] = None, - to: typing.Optional[NotStrictTimestampMs] = None, + start: NotStrictTimestampMs | None = None, + to: NotStrictTimestampMs | None = None, ) -> RawAPIPageResponse: ... - @typing.overload + @overload async def history( self: AsyncPlayers[Model], player_id: PlayerID, @@ -847,8 +853,8 @@ async def history( *, offset: int = Field(0, ge=0, le=1000), limit: int = Field(20, ge=1, le=100), - start: typing.Optional[NotStrictTimestampMs] = None, - to: typing.Optional[NotStrictTimestampMs] = None, + start: NotStrictTimestampMs | None = None, + to: NotStrictTimestampMs | None = None, ) -> ItemPage[Match]: ... @validate_call @@ -859,9 +865,9 @@ async def history( *, offset: int = Field(0, ge=0, le=1000), limit: int = Field(20, ge=1, le=100), - start: typing.Optional[NotStrictTimestampMs] = None, - to: typing.Optional[NotStrictTimestampMs] = None, - ) -> typing.Union[RawAPIPageResponse, ItemPage[Match]]: + start: NotStrictTimestampMs | None = None, + to: NotStrictTimestampMs | None = None, + ) -> RawAPIPageResponse | ItemPage[Match]: return self._validate_response( await self._client.get( self.__class__.PATH / str(player_id) / "history", @@ -873,15 +879,15 @@ async def history( ItemPage[Match], ) - @typing.overload + @overload async def all_history( self: AsyncPlayers[Raw], player_id: PlayerID, game: GameID, max_items: MaxItemsType = pages(50), - ) -> typing.List[RawAPIItem]: ... + ) -> list[RawAPIItem]: ... - @typing.overload + @overload async def all_history( self: AsyncPlayers[Model], player_id: PlayerID, @@ -894,7 +900,7 @@ async def all_history( player_id: PlayerID, game: GameID, max_items: MaxItemsType = pages(50), - ) -> typing.Union[typing.List[RawAPIItem], ItemPage[Match]]: + ) -> list[RawAPIItem] | ItemPage[Match]: return await AsyncPageIterator.gather_from_iterator( AsyncPageIterator.unix( self.history, @@ -905,7 +911,7 @@ async def all_history( ) ) - @typing.overload + @overload async def hubs( self: AsyncPlayers[Raw], player_id: PlayerID, @@ -914,7 +920,7 @@ async def hubs( limit: int = Field(50, ge=1, le=50), ) -> RawAPIPageResponse: ... - @typing.overload + @overload async def hubs( self: AsyncPlayers[Model], player_id: PlayerID, @@ -930,7 +936,7 @@ async def hubs( *, offset: int = Field(0, ge=0, le=1000), limit: int = Field(50, ge=1, le=50), - ) -> typing.Union[RawAPIPageResponse, ItemPage[Hub]]: + ) -> RawAPIPageResponse | ItemPage[Hub]: return self._validate_response( await self._client.get( self.__class__.PATH / str(player_id) / "hubs", @@ -940,14 +946,14 @@ async def hubs( ItemPage[Hub], ) - @typing.overload + @overload async def all_hubs( self: AsyncPlayers[Raw], player_id: PlayerID, max_items: MaxItemsType = MaxItems.SAFE, - ) -> typing.List[RawAPIItem]: ... + ) -> list[RawAPIItem]: ... - @typing.overload + @overload async def all_hubs( self: AsyncPlayers[Model], player_id: PlayerID, @@ -956,21 +962,21 @@ async def all_hubs( async def all_hubs( self, player_id: PlayerID, max_items: MaxItemsType = MaxItems.SAFE - ) -> typing.Union[typing.List[RawAPIItem], ItemPage[Hub]]: + ) -> list[RawAPIItem] | ItemPage[Hub]: iterator = AsyncPageIterator(self.hubs, player_id, max_items=max_items) return await iterator.collect() - @typing.overload + @overload async def stats( self: AsyncPlayers[Raw], player_id: PlayerID, game: GameID ) -> RawAPIItem: ... - @typing.overload + @overload async def stats( # type: ignore[overload-overlap] self: AsyncPlayers[Model], player_id: PlayerID, game: AnyCSID ) -> CSPlayerStats: ... - @typing.overload + @overload async def stats( self: AsyncPlayers[Model], player_id: PlayerID, game: GameID ) -> FallbackPlayerStats: ... @@ -978,7 +984,7 @@ async def stats( @validate_call async def stats( self, player_id: PlayerIDValidated, game: GameID - ) -> typing.Union[RawAPIItem, AnyPlayerStats]: + ) -> RawAPIItem | AnyPlayerStats: return self._process_item( await self._client.get( self.__class__.PATH / str(player_id) / "stats" / game, @@ -988,7 +994,7 @@ async def stats( self.__class__._stats_validator_cfg, ) - @typing.overload + @overload async def teams( self: AsyncPlayers[Raw], player_id: PlayerID, @@ -997,7 +1003,7 @@ async def teams( limit: int = Field(20, ge=1, le=100), ) -> RawAPIPageResponse: ... - @typing.overload + @overload async def teams( self: AsyncPlayers[Model], player_id: PlayerID, @@ -1013,7 +1019,7 @@ async def teams( *, offset: int = Field(0, ge=0), limit: int = Field(20, ge=1, le=100), - ) -> typing.Union[RawAPIPageResponse, ItemPage[GeneralTeam]]: + ) -> RawAPIPageResponse | ItemPage[GeneralTeam]: return self._validate_response( await self._client.get( self.__class__.PATH / str(player_id) / "teams", @@ -1023,14 +1029,14 @@ async def teams( ItemPage[GeneralTeam], ) - @typing.overload + @overload async def all_teams( self: AsyncPlayers[Raw], player_id: PlayerID, max_items: MaxItemsType = MaxItems.SAFE, - ) -> typing.List[RawAPIItem]: ... + ) -> list[RawAPIItem]: ... - @typing.overload + @overload async def all_teams( self: AsyncPlayers[Model], player_id: PlayerID, @@ -1039,11 +1045,11 @@ async def all_teams( async def all_teams( self, player_id: PlayerID, max_items: MaxItemsType = MaxItems.SAFE - ) -> typing.Union[typing.List[RawAPIItem], ItemPage[GeneralTeam]]: + ) -> list[RawAPIItem] | ItemPage[GeneralTeam]: iterator = AsyncPageIterator(self.teams, player_id, max_items=max_items) return await iterator.collect() - @typing.overload + @overload async def tournaments( self: AsyncPlayers[Raw], player_id: PlayerID, @@ -1052,7 +1058,7 @@ async def tournaments( limit: int = Field(20, ge=1, le=100), ) -> RawAPIPageResponse: ... - @typing.overload + @overload async def tournaments( self: AsyncPlayers[Model], player_id: PlayerID, @@ -1068,7 +1074,7 @@ async def tournaments( *, offset: int = Field(0, ge=0), limit: int = Field(20, ge=1, le=100), - ) -> typing.Union[RawAPIPageResponse, ItemPage[Tournament]]: + ) -> RawAPIPageResponse | ItemPage[Tournament]: return self._validate_response( await self._client.get( self.__class__.PATH / str(player_id) / "tournaments", @@ -1078,14 +1084,14 @@ async def tournaments( ItemPage[Tournament], ) - @typing.overload + @overload async def all_tournaments( self: AsyncPlayers[Raw], player_id: PlayerID, max_items: MaxItemsType = MaxItems.SAFE, - ) -> typing.List[RawAPIItem]: ... + ) -> list[RawAPIItem]: ... - @typing.overload + @overload async def all_tournaments( self: AsyncPlayers[Model], player_id: PlayerID, @@ -1094,6 +1100,6 @@ async def all_tournaments( async def all_tournaments( self, player_id: PlayerID, max_items: MaxItemsType = MaxItems.SAFE - ) -> typing.Union[typing.List[RawAPIItem], ItemPage[Tournament]]: + ) -> list[RawAPIItem] | ItemPage[Tournament]: iterator = AsyncPageIterator(self.tournaments, player_id, max_items=max_items) return await iterator.collect() diff --git a/src/faceit/api/data/rankings.py b/src/faceit/api/data/rankings.py index 0b9756d..e90e8f3 100644 --- a/src/faceit/api/data/rankings.py +++ b/src/faceit/api/data/rankings.py @@ -1,7 +1,7 @@ from __future__ import annotations -import typing from abc import ABC +from typing import Generic, final, overload from pydantic import Field, validate_call @@ -38,27 +38,27 @@ class BaseRankings( __slots__ = () -@typing.final -class SyncRankings(BaseRankings[SyncClient], typing.Generic[APIResponseFormatT]): +@final +class SyncRankings(BaseRankings[SyncClient], Generic[APIResponseFormatT]): __slots__ = () - @typing.overload + @overload def unbounded( self: SyncRankings[Raw], game: GameID, region: RegionIdentifier, - country: typing.Optional[CountryCode] = None, + country: CountryCode | None = None, *, offset: int = Field(0, ge=0), limit: int = Field(20, ge=1, le=100), ) -> RawAPIPageResponse: ... - @typing.overload + @overload def unbounded( self: SyncRankings[Model], game: GameID, region: RegionIdentifier, - country: typing.Optional[CountryCode] = None, + country: CountryCode | None = None, *, offset: int = Field(0, ge=0), limit: int = Field(20, ge=1, le=100), @@ -69,11 +69,11 @@ def unbounded( self, game: GameID, region: RegionIdentifier, - country: typing.Optional[CountryCode] = None, + country: CountryCode | None = None, *, offset: int = Field(0, ge=0), limit: int = Field(20, ge=1, le=100), - ) -> typing.Union[RawAPIPageResponse, ItemPage[ModelNotImplemented]]: + ) -> RawAPIPageResponse | ItemPage[ModelNotImplemented]: return self._validate_response( self._client.get( self.__class__.PATH / "games" / game / "regions" / region, @@ -85,21 +85,21 @@ def unbounded( ModelPlaceholder, ) - @typing.overload + @overload def all_unbounded( self: SyncRankings[Raw], game: GameID, region: RegionIdentifier, - country: typing.Optional[CountryCode] = None, + country: CountryCode | None = None, max_items: MaxItemsType = pages(10), - ) -> typing.List[RawAPIItem]: ... + ) -> list[RawAPIItem]: ... - @typing.overload + @overload def all_unbounded( self: SyncRankings[Model], game: GameID, region: RegionIdentifier, - country: typing.Optional[CountryCode] = None, + country: CountryCode | None = None, max_items: MaxItemsType = pages(10), ) -> ItemPage[ModelNotImplemented]: ... @@ -108,32 +108,32 @@ def all_unbounded( self, game: GameID, region: RegionIdentifier, - country: typing.Optional[CountryCode] = None, + country: CountryCode | None = None, max_items: MaxItemsType = pages(10), - ) -> typing.Union[typing.List[RawAPIItem], ItemPage[ModelNotImplemented]]: + ) -> list[RawAPIItem] | ItemPage[ModelNotImplemented]: iterator = SyncPageIterator( self.unbounded, game, region, country, max_items=max_items ) return iterator.collect() - @typing.overload + @overload def player( self: SyncRankings[Raw], game: GameID, region: RegionIdentifier, player_id: PlayerID, - country: typing.Optional[CountryCode] = None, + country: CountryCode | None = None, *, limit: int = Field(20, ge=1, le=100), ) -> RawAPIPageResponse: ... - @typing.overload + @overload def player( self: SyncRankings[Model], game: GameID, region: RegionIdentifier, player_id: PlayerID, - country: typing.Optional[CountryCode] = None, + country: CountryCode | None = None, *, limit: int = Field(20, ge=1, le=100), ) -> ModelNotImplemented: ... @@ -144,10 +144,10 @@ def player( game: GameID, region: RegionIdentifier, player_id: PlayerIDValidated, - country: typing.Optional[CountryCode] = None, + country: CountryCode | None = None, *, limit: int = Field(20, ge=1, le=100), - ) -> typing.Union[RawAPIPageResponse, ModelNotImplemented]: + ) -> RawAPIPageResponse | ModelNotImplemented: # fmt: off return self._validate_response( self._client.get( @@ -162,27 +162,27 @@ def player( # fmt: on -@typing.final -class AsyncRankings(BaseRankings[AsyncClient], typing.Generic[APIResponseFormatT]): +@final +class AsyncRankings(BaseRankings[AsyncClient], Generic[APIResponseFormatT]): __slots__ = () - @typing.overload + @overload async def unbounded( self: AsyncRankings[Raw], game: GameID, region: RegionIdentifier, - country: typing.Optional[CountryCode] = None, + country: CountryCode | None = None, *, offset: int = Field(0, ge=0), limit: int = Field(20, ge=1, le=100), ) -> RawAPIPageResponse: ... - @typing.overload + @overload async def unbounded( self: AsyncRankings[Model], game: GameID, region: RegionIdentifier, - country: typing.Optional[CountryCode] = None, + country: CountryCode | None = None, *, offset: int = Field(0, ge=0), limit: int = Field(20, ge=1, le=100), @@ -193,11 +193,11 @@ async def unbounded( self, game: GameID, region: RegionIdentifier, - country: typing.Optional[CountryCode] = None, + country: CountryCode | None = None, *, offset: int = Field(0, ge=0), limit: int = Field(20, ge=1, le=100), - ) -> typing.Union[RawAPIPageResponse, ItemPage[ModelNotImplemented]]: + ) -> RawAPIPageResponse | ItemPage[ModelNotImplemented]: return self._validate_response( await self._client.get( self.__class__.PATH / "games" / game / "regions" / region, @@ -209,21 +209,21 @@ async def unbounded( ModelPlaceholder, ) - @typing.overload + @overload async def all_unbounded( self: AsyncRankings[Raw], game: GameID, region: RegionIdentifier, - country: typing.Optional[CountryCode] = None, + country: CountryCode | None = None, max_items: MaxItemsType = pages(10), - ) -> typing.List[RawAPIItem]: ... + ) -> list[RawAPIItem]: ... - @typing.overload + @overload async def all_unbounded( self: AsyncRankings[Model], game: GameID, region: RegionIdentifier, - country: typing.Optional[CountryCode] = None, + country: CountryCode | None = None, max_items: MaxItemsType = pages(10), ) -> ItemPage[ModelNotImplemented]: ... @@ -232,32 +232,32 @@ async def all_unbounded( self, game: GameID, region: RegionIdentifier, - country: typing.Optional[CountryCode] = None, + country: CountryCode | None = None, max_items: MaxItemsType = pages(10), - ) -> typing.Union[typing.List[RawAPIItem], ItemPage[ModelNotImplemented]]: + ) -> list[RawAPIItem] | ItemPage[ModelNotImplemented]: iterator = AsyncPageIterator( self.unbounded, game, region, country, max_items=max_items ) return await iterator.collect() - @typing.overload + @overload async def player( self: AsyncRankings[Raw], game: GameID, region: RegionIdentifier, player_id: PlayerID, - country: typing.Optional[CountryCode] = None, + country: CountryCode | None = None, *, limit: int = Field(20, ge=1, le=100), ) -> RawAPIPageResponse: ... - @typing.overload + @overload async def player( self: AsyncRankings[Model], game: GameID, region: RegionIdentifier, player_id: PlayerID, - country: typing.Optional[CountryCode] = None, + country: CountryCode | None = None, *, limit: int = Field(20, ge=1, le=100), ) -> ModelNotImplemented: ... @@ -268,10 +268,10 @@ async def player( game: GameID, region: RegionIdentifier, player_id: PlayerIDValidated, - country: typing.Optional[CountryCode] = None, + country: CountryCode | None = None, *, limit: int = Field(20, ge=1, le=100), - ) -> typing.Union[RawAPIPageResponse, ModelNotImplemented]: + ) -> RawAPIPageResponse | ModelNotImplemented: # fmt: off return self._validate_response( await self._client.get( diff --git a/src/faceit/api/data/teams.py b/src/faceit/api/data/teams.py index 845e2ea..c144695 100644 --- a/src/faceit/api/data/teams.py +++ b/src/faceit/api/data/teams.py @@ -1,10 +1,9 @@ from __future__ import annotations -import typing from abc import ABC +from typing import Annotated, Generic, TypeAlias, final, overload from pydantic import AfterValidator, Field, validate_call -from typing_extensions import Annotated, TypeAlias from faceit.api.base import BaseResource, ModelPlaceholder from faceit.api.pagination import ( @@ -40,20 +39,18 @@ class BaseTeams( __slots__ = () -@typing.final -class SyncTeams(BaseTeams[SyncClient], typing.Generic[APIResponseFormatT]): +@final +class SyncTeams(BaseTeams[SyncClient], Generic[APIResponseFormatT]): __slots__ = () - @typing.overload + @overload def get(self: SyncTeams[Raw], team_id: _TeamID) -> RawAPIItem: ... - @typing.overload + @overload def get(self: SyncTeams[Model], team_id: _TeamID) -> ModelNotImplemented: ... @validate_call - def get( - self, team_id: _TeamIDValidated - ) -> typing.Union[RawAPIItem, ModelNotImplemented]: + def get(self, team_id: _TeamIDValidated) -> RawAPIItem | ModelNotImplemented: return self._validate_response( self._client.get(self.__class__.PATH / team_id, expect_item=True), ModelPlaceholder, @@ -61,10 +58,10 @@ def get( __call__ = get - @typing.overload + @overload def stats(self: SyncTeams[Raw], team_id: _TeamID, game: GameID) -> RawAPIItem: ... - @typing.overload + @overload def stats( self: SyncTeams[Model], team_id: _TeamID, game: GameID ) -> ModelNotImplemented: ... @@ -72,7 +69,7 @@ def stats( @validate_call def stats( self, team_id: _TeamIDValidated, game: GameID - ) -> typing.Union[RawAPIItem, ModelNotImplemented]: + ) -> RawAPIItem | ModelNotImplemented: return self._validate_response( self._client.get( self.__class__.PATH / team_id / "stats" / game, @@ -81,7 +78,7 @@ def stats( ModelPlaceholder, ) - @typing.overload + @overload def tournaments( self: SyncTeams[Raw], team_id: _TeamID, @@ -90,7 +87,7 @@ def tournaments( limit: int = Field(20, ge=1, le=100), ) -> RawAPIPageResponse: ... - @typing.overload + @overload def tournaments( self: SyncTeams[Model], team_id: _TeamID, @@ -106,7 +103,7 @@ def tournaments( *, offset: int = Field(0, ge=0), limit: int = Field(20, ge=1, le=100), - ) -> typing.Union[RawAPIPageResponse, ItemPage[ModelNotImplemented]]: + ) -> RawAPIPageResponse | ItemPage[ModelNotImplemented]: return self._validate_response( self._client.get( self.__class__.PATH / team_id / "tournaments", @@ -116,37 +113,35 @@ def tournaments( ModelPlaceholder, ) - @typing.overload + @overload def all_tournaments( self: SyncTeams[Raw], team_id: _TeamID, max_items: MaxItemsType = pages(30) - ) -> typing.List[RawAPIItem]: ... + ) -> list[RawAPIItem]: ... - @typing.overload + @overload def all_tournaments( self: SyncTeams[Model], team_id: _TeamID, max_items: MaxItemsType = pages(30) ) -> ItemPage[ModelNotImplemented]: ... def all_tournaments( self, team_id: _TeamIDValidated, max_items: MaxItemsType = pages(30) - ) -> typing.Union[typing.List[RawAPIItem], ItemPage[ModelNotImplemented]]: + ) -> list[RawAPIItem] | ItemPage[ModelNotImplemented]: iterator = SyncPageIterator(self.tournaments, team_id, max_items=max_items) return iterator.collect() -@typing.final -class AsyncTeams(BaseTeams[AsyncClient], typing.Generic[APIResponseFormatT]): +@final +class AsyncTeams(BaseTeams[AsyncClient], Generic[APIResponseFormatT]): __slots__ = () - @typing.overload + @overload async def get(self: AsyncTeams[Raw], team_id: _TeamID) -> RawAPIItem: ... - @typing.overload + @overload async def get(self: AsyncTeams[Model], team_id: _TeamID) -> ModelNotImplemented: ... @validate_call - async def get( - self, team_id: _TeamIDValidated - ) -> typing.Union[RawAPIItem, ModelNotImplemented]: + async def get(self, team_id: _TeamIDValidated) -> RawAPIItem | ModelNotImplemented: return self._validate_response( await self._client.get(self.__class__.PATH / team_id, expect_item=True), ModelPlaceholder, @@ -154,12 +149,12 @@ async def get( __call__ = get - @typing.overload + @overload async def stats( self: AsyncTeams[Raw], team_id: _TeamID, game: GameID ) -> RawAPIItem: ... - @typing.overload + @overload async def stats( self: AsyncTeams[Model], team_id: _TeamID, game: GameID ) -> ModelNotImplemented: ... @@ -167,7 +162,7 @@ async def stats( @validate_call async def stats( self, team_id: _TeamIDValidated, game: GameID - ) -> typing.Union[RawAPIItem, ModelNotImplemented]: + ) -> RawAPIItem | ModelNotImplemented: return self._validate_response( await self._client.get( self.__class__.PATH / team_id / "stats" / game, @@ -176,7 +171,7 @@ async def stats( ModelPlaceholder, ) - @typing.overload + @overload async def tournaments( self: AsyncTeams[Raw], team_id: _TeamID, @@ -185,7 +180,7 @@ async def tournaments( limit: int = Field(20, ge=1, le=100), ) -> RawAPIPageResponse: ... - @typing.overload + @overload async def tournaments( self: AsyncTeams[Model], team_id: _TeamID, @@ -201,7 +196,7 @@ async def tournaments( *, offset: int = Field(0, ge=0), limit: int = Field(20, ge=1, le=100), - ) -> typing.Union[RawAPIPageResponse, ItemPage[ModelNotImplemented]]: + ) -> RawAPIPageResponse | ItemPage[ModelNotImplemented]: return self._validate_response( await self._client.get( self.__class__.PATH / team_id / "tournaments", @@ -211,18 +206,18 @@ async def tournaments( ModelPlaceholder, ) - @typing.overload + @overload async def all_tournaments( self: AsyncTeams[Raw], team_id: _TeamID, max_items: MaxItemsType = pages(30) - ) -> typing.List[RawAPIItem]: ... + ) -> list[RawAPIItem]: ... - @typing.overload + @overload async def all_tournaments( self: AsyncTeams[Model], team_id: _TeamID, max_items: MaxItemsType = pages(30) ) -> ItemPage[ModelNotImplemented]: ... async def all_tournaments( self, team_id: _TeamIDValidated, max_items: MaxItemsType = pages(30) - ) -> typing.Union[typing.List[RawAPIItem], ItemPage[ModelNotImplemented]]: + ) -> list[RawAPIItem] | ItemPage[ModelNotImplemented]: iterator = AsyncPageIterator(self.tournaments, team_id, max_items=max_items) return await iterator.collect() diff --git a/src/faceit/api/pagination.py b/src/faceit/api/pagination.py index c69d590..8787a04 100644 --- a/src/faceit/api/pagination.py +++ b/src/faceit/api/pagination.py @@ -2,17 +2,31 @@ import inspect import math -import typing import warnings from abc import ABC +from collections.abc import AsyncIterator, Callable, Iterable, Iterator, Mapping from dataclasses import dataclass from itertools import chain from types import MappingProxyType +from typing import ( + TYPE_CHECKING, + Any, + ClassVar, + Final, + Generic, + Literal, + NamedTuple, + TypeAlias, + TypedDict, + TypeVar, + cast, + final, + overload, +) from annotated_types import Le -from pydantic import PositiveInt from pydantic.fields import FieldInfo -from typing_extensions import Self, TypeAlias +from typing_extensions import Self from faceit.constants import RAW_RESPONSE_ITEMS_KEY from faceit.models import ItemPage @@ -31,36 +45,31 @@ deduplicate_unhashable, deep_get, extends, + find_user_stacklevel, representation, validate_positive_int, - warn_stacklevel, ) -_PageType: TypeAlias = typing.Union[ItemPage[typing.Any], RawAPIPageResponse] -_PageList: TypeAlias = typing.List[_PageType] -_PageT = typing.TypeVar("_PageT", bound=_PageType) +_PageType: TypeAlias = RawAPIPageResponse | ItemPage[Any] +_PageList: TypeAlias = list[_PageType] +_PageT = TypeVar("_PageT", bound=_PageType) -if typing.TYPE_CHECKING: - _PageClass: TypeAlias = typing.Union[ - typing.Type[ItemPage[typing.Any]], typing.Type[RawAPIPageResponse] - ] - _PageFactory: TypeAlias = typing.Callable[[_PageList], _PageClass] - _PageFactoryMap: TypeAlias = typing.Mapping["CollectReturnFormat", _PageFactory] - _OptionalTimestampPaginationConfig: TypeAlias = typing.Union[ - "TimestampPaginationConfig", typing.Literal[False] +if TYPE_CHECKING: + _PageFactoryMap: TypeAlias = Mapping[ + "CollectReturnFormat", + Callable[[_PageList], type[RawAPIPageResponse | ItemPage[Any]]], ] + _OptionalTimestampPaginationConfig: TypeAlias = ( + "TimestampPaginationConfig | Literal[False]" + ) class MaxItems(StrEnum): SAFE = "safe" -# We use `PositiveInt` for Pydantic validation where available (e.g., with -# `@validate_call` in resource methods). However, in this module we implement -# our own validation logic for greater flexibility, as Pydantic-based validation -# is not always practical here. -MaxItemsType: TypeAlias = typing.Union[MaxItems, PositiveInt] +MaxItemsType: TypeAlias = MaxItems | int class CollectReturnFormat(StrEnum): @@ -69,19 +78,19 @@ class CollectReturnFormat(StrEnum): MODEL = "model" -@typing.final -class TimestampPaginationConfig(typing.TypedDict): +@final +class TimestampPaginationConfig(TypedDict): key: str attr: str -@typing.final -class PaginationMaxParams(typing.NamedTuple): +@final +class PaginationMaxParams(NamedTuple): limit: int - offset: typing.Optional[int] + offset: int | None -@typing.final +@final class pages(int): __slots__ = () @@ -98,13 +107,13 @@ def __new__(cls, x=2, base=None): # type: ignore[no-untyped-def] # noqa: ANN001 @dataclass(eq=False, frozen=True) -class _MethodCall(typing.Generic[PaginationMethodT]): +class _MethodCall(Generic[PaginationMethodT]): call: PaginationMethodT - args: typing.Tuple[typing.Any, ...] - kwargs: typing.Dict[str, typing.Any] + args: tuple[Any, ...] + kwargs: dict[str, Any] -class _MaxItemsInfo(typing.NamedTuple): +class _MaxItemsInfo(NamedTuple): max_items: MaxItemsType last_page_remainder: int is_partial_last_page: bool @@ -114,23 +123,19 @@ def from_max_pages(cls, max_pages: MaxItemsType, /) -> Self: return cls(max_pages, 0, is_partial_last_page=False) -_UNIX_METHOD_REQUIRED_KEYS: typing.Final = frozenset( - TimestampPaginationConfig.__annotations__ -) -_PAGINATION_ARGS: typing.Final = PaginationMaxParams._fields -_UNIX_PAGINATION_PARAMS: typing.Final = PaginationTimeRange.model_fields.keys() +_UNIX_METHOD_REQUIRED_KEYS: Final = frozenset(TimestampPaginationConfig.__annotations__) +_PAGINATION_ARGS: Final = PaginationMaxParams._fields +_UNIX_PAGINATION_PARAMS: Final = PaginationTimeRange.model_fields.keys() -def _has_unix_pagination_params( - method: BaseResourceMethodProtocol[typing.Any], / -) -> bool: +def _has_unix_pagination_params(method: BaseResourceMethodProtocol[Any], /) -> bool: return all( param in inspect.signature(method).parameters for param in _UNIX_PAGINATION_PARAMS ) -def _get_le(param: inspect.Parameter, /) -> typing.Optional[Le]: +def _get_le(param: inspect.Parameter, /) -> Le | None: generator = (items for items in param.default.metadata if isinstance(items, Le)) return next(generator, None) @@ -151,22 +156,20 @@ def _extract_pagination_limits( ): msg = f"Default for limit/offset in {method_name!r} is not a FieldInfo" raise TypeError(msg) - limit_constraint = _get_le(limit_param) - if limit_constraint is None: + if (limit_constraint := _get_le(limit_param)) is None: msg = f"In limit metadata of {method_name!r}, no Le constraint found" raise ValueError(msg) - offset_constraint = _get_le(offset_param) offset = ( None - if offset_constraint is None + if (offset_constraint := _get_le(offset_param)) is None else validate_positive_int(offset_constraint.le) ) return PaginationMaxParams(validate_positive_int(limit_constraint.le), offset) def check_pagination_support( - func: typing.Callable[..., typing.Any], / -) -> typing.Union[PaginationMaxParams, typing.Literal[False]]: + func: Callable[..., Any], / +) -> PaginationMaxParams | Literal[False]: # Imported here to avoid circular dependency: `base` imports iterators and config # to integrate them into `BaseResource` for convenient use in subclasses. from faceit.api.base import BaseResource # noqa: PLC0415 @@ -185,9 +188,7 @@ def check_pagination_support( return False return _extract_pagination_limits( - limit_param, - offset_param, - typing.cast("str", getattr(func, "__name__", "")), + limit_param, offset_param, cast("str", getattr(func, "__name__", "")) ) @@ -203,34 +204,32 @@ def check_pagination_support( @representation(*_ITERATOR_SLOTS) -class BasePageIterator(ABC, typing.Generic[PaginationMethodT, _PageT]): +class BasePageIterator(ABC, Generic[PaginationMethodT, _PageT]): __slots__ = _ITERATOR_SLOTS - if typing.TYPE_CHECKING: - _STOP_ITERATION_EXC: typing.ClassVar[typing.Type[Exception]] + if TYPE_CHECKING: + _STOP_ITERATION_EXC: ClassVar[type[Exception]] - _COLLECT_RETURN_FORMATS: typing.ClassVar[_PageFactoryMap] = MappingProxyType({ + _COLLECT_RETURN_FORMATS: ClassVar[_PageFactoryMap] = MappingProxyType({ CollectReturnFormat.FIRST: lambda c: type(c[0]) if c else RawAPIPageResponse, CollectReturnFormat.RAW: lambda _: RawAPIPageResponse, CollectReturnFormat.MODEL: lambda _: ItemPage, }) - SAFE_MAX_PAGES: typing.ClassVar = 100 - DEFAULT_MAX_ITEMS: typing.ClassVar = 2000 + SAFE_MAX_PAGES: ClassVar = 100 + DEFAULT_MAX_ITEMS: ClassVar = 2000 """ Selected as an optimal default to balance performance and resource usage when iterating through paginated FACEIT API data. """ - timestamp_cfg: typing.ClassVar = TimestampPaginationConfig - def __init__( self, method: PaginationMethodT, /, - *args: typing.Any, + *args: Any, max_items: MaxItemsType = DEFAULT_MAX_ITEMS, - **kwargs: typing.Any, + **kwargs: Any, ) -> None: pagination_limits = check_pagination_support(method) if pagination_limits is False: @@ -322,7 +321,7 @@ def _effective_limit(self) -> int: def reset(self) -> None: self._init_iteration() - def with_updated_args(self, *args: typing.Any, **kwargs: typing.Any) -> Self: + def with_updated_args(self, *args: Any, **kwargs: Any) -> Self: return self.__class__(self._method.call, *args, **kwargs) def _max_pages_setter(self, max_items: MaxItemsType, /) -> None: @@ -336,7 +335,7 @@ def warn_if_exceeds_safe(max_pages: int, /) -> int: f"The computed number of pages ({max_pages}) exceeds the " f"recommended safe maximum ({self.__class__.SAFE_MAX_PAGES}). " "Proceed at your own risk.", - stacklevel=warn_stacklevel(), + stacklevel=find_user_stacklevel(), ) return max_pages @@ -357,7 +356,7 @@ def warn_if_exceeds_safe(max_pages: int, /) -> int: math.ceil(validated_max_items / self._pagination_limits.limit) ) - def _handle_iteration_state(self, page: typing.Optional[_PageT], /) -> _PageT: + def _handle_iteration_state(self, page: _PageT | None, /) -> _PageT: if page is None: self._exhausted = True raise self.__class__._STOP_ITERATION_EXC @@ -390,13 +389,13 @@ def _handle_iteration_state(self, page: typing.Optional[_PageT], /) -> _PageT: return page @staticmethod - def _remove_pagination_args(**kwargs: _T) -> typing.Dict[str, _T]: + def _remove_pagination_args(**kwargs: _T) -> dict[str, _T]: if any([kwargs.pop(arg, None) for arg in _PAGINATION_ARGS]): # noqa: C419 warnings.warn( f"Pagination parameters {_PAGINATION_ARGS} should not be " "provided by users. These parameters are managed internally " "by the pagination system.", - stacklevel=warn_stacklevel(), + stacklevel=find_user_stacklevel(), ) return kwargs @@ -424,8 +423,8 @@ def _validate_unix_config( @staticmethod def _extract_unix_timestamp( - cfg: TimestampPaginationConfig, page: typing.Optional[_PageType], / - ) -> typing.Optional[int]: + cfg: TimestampPaginationConfig, page: _PageType | None, / + ) -> int | None: if not page: return None if isinstance(page, dict): @@ -433,20 +432,14 @@ def _extract_unix_timestamp( return deep_get(items[-1], cfg["key"]) if items else None return getattr(page.get_last(), cfg["attr"], None) - @staticmethod - def _extract_items_from_raw_pages( - pages: typing.Iterable[RawAPIPageResponse], / - ) -> typing.List[RawAPIItem]: - return list(chain.from_iterable(page[RAW_RESPONSE_ITEMS_KEY] for page in pages)) - @classmethod def _validate_unix_pagination_parameter( cls, + cfg: TimestampPaginationConfig, method: PaginationMethodT, # Process `kwargs` to filter pagination parameters and issue warnings # when user-provided values will be ignored - kwargs: typing.Dict[str, typing.Any], - cfg: TimestampPaginationConfig, + kwargs: dict[str, Any], /, ) -> None: cls._validate_unix_config(cfg) @@ -466,48 +459,46 @@ def _validate_unix_pagination_parameter( warnings.warn( "The parameters 'start' and 'to' will be managed automatically with Unix " "timestamp pagination. Your provided values will be ignored.", - stacklevel=warn_stacklevel(), + stacklevel=find_user_stacklevel(), ) @classmethod def _process_collected_pages( cls, - collection: typing.List[typing.Union[ItemPage[_T], RawAPIPageResponse]], + collection: list[RawAPIPageResponse | ItemPage[_T]], return_format: CollectReturnFormat, deduplicate: bool, # noqa: FBT001 - ) -> typing.Union[ItemPage[_T], typing.List[RawAPIItem]]: + ) -> list[RawAPIItem] | ItemPage[_T]: if cls._COLLECT_RETURN_FORMATS[return_format](collection) is dict: - raw = cls._extract_items_from_raw_pages( - p for p in collection if isinstance(p, dict) + raw = chain.from_iterable( + p[RAW_RESPONSE_ITEMS_KEY] for p in collection if isinstance(p, dict) ) - return cls._deduplicate_collection(raw) if deduplicate else raw + return cls._deduplicate_collection(raw) if deduplicate else list(raw) model = ItemPage.merge(p for p in collection if isinstance(p, ItemPage)) return cls._deduplicate_collection(model) if deduplicate else model @classmethod def _deduplicate_collection( - cls, - collection: typing.Union[ItemPage[_T], typing.List[RawAPIItem]], - /, - ) -> typing.Union[ItemPage[_T], typing.List[RawAPIItem]]: + cls, collection: Iterable[RawAPIItem] | ItemPage[_T], / + ) -> list[RawAPIItem] | ItemPage[_T]: if not isinstance(collection, ItemPage): return deduplicate_unhashable(collection) - return collection.with_items(deduplicate_unhashable(collection)) + return collection.with_items(deduplicate_unhashable(collection)) # pyright: ignore[reportArgumentType, reportReturnType] @classmethod def _create_unix_timestamp_iterator( cls, method: PaginationMethodT, /, - *args: typing.Any, - timestamp: typing.Optional[int], - **kwargs: typing.Any, + *args: Any, + timestamp: int | None, + **kwargs: Any, ) -> Self: - # fmt: off - return cls(method, *args, **{ - **kwargs, **({} if timestamp is None else {"to": timestamp + 1}), - }) - # fmt: on + return cls( + method, + *args, + **(kwargs | ({} if timestamp is None else {"to": timestamp + 1})), + ) del _ITERATOR_SLOTS @@ -515,7 +506,7 @@ def _create_unix_timestamp_iterator( class _BaseSyncPageIterator( BasePageIterator[SyncResourceMethodProtocol[_PageT], _PageT], - typing.Iterator[_PageT], + Iterator[_PageT], ): __slots__ = () @@ -540,7 +531,7 @@ def __next__(self) -> _PageT: class _BaseAsyncPageIterator( BasePageIterator[AsyncResourceMethodProtocol[_PageT], _PageT], - typing.AsyncIterator[_PageT], + AsyncIterator[_PageT], ): __slots__ = () @@ -563,32 +554,29 @@ async def __anext__(self) -> _PageT: ) -@typing.final +@final class SyncPageIterator(_BaseSyncPageIterator[_PageT]): __slots__ = () - @typing.overload + @overload def collect( - self: SyncPageIterator[ItemPage[_T]], + self: SyncPageIterator[RawAPIPageResponse], *, deduplicate: bool = ..., - ) -> ItemPage[_T]: ... + ) -> list[RawAPIItem]: ... - @typing.overload + @overload def collect( - self: SyncPageIterator[RawAPIPageResponse], + self: SyncPageIterator[ItemPage[_T]], *, deduplicate: bool = ..., - ) -> typing.List[RawAPIItem]: ... + ) -> ItemPage[_T]: ... def collect( - self: typing.Union[ - SyncPageIterator[ItemPage[_T]], - SyncPageIterator[RawAPIPageResponse], - ], + self: SyncPageIterator[RawAPIPageResponse] | SyncPageIterator[ItemPage[_T]], *, deduplicate: bool = True, - ) -> typing.Union[ItemPage[_T], typing.List[RawAPIItem]]: + ) -> list[RawAPIItem] | ItemPage[_T]: return self.__class__.gather_from_iterator(self, deduplicate=deduplicate) @classmethod @@ -596,12 +584,12 @@ def unix( cls, method: SyncResourceMethodProtocol[_PageT], /, - *args: typing.Any, + *args: Any, cfg: TimestampPaginationConfig, max_items: MaxItemsType = BasePageIterator.DEFAULT_MAX_ITEMS, - **kwargs: typing.Any, - ) -> typing.Iterator[_PageT]: - cls._validate_unix_pagination_parameter(method, kwargs, cfg) + **kwargs: Any, + ) -> Iterator[_PageT]: + cls._validate_unix_pagination_parameter(cfg, method, kwargs) kwargs["max_items"] = max_items current_timestamp = None @@ -630,60 +618,57 @@ def unix( kwargs["max_items"] = iterator.max_items - total_yielded - @typing.overload + @overload @classmethod def gather_from_iterator( cls, - iterator: typing.Iterator[ItemPage[_T]], + iterator: Iterator[RawAPIPageResponse], /, return_format: CollectReturnFormat = ..., *, deduplicate: bool = ..., - ) -> ItemPage[_T]: ... + ) -> list[RawAPIItem]: ... - @typing.overload + @overload @classmethod def gather_from_iterator( cls, - iterator: typing.Iterator[RawAPIPageResponse], + iterator: Iterator[ItemPage[_T]], /, return_format: CollectReturnFormat = ..., *, deduplicate: bool = ..., - ) -> typing.List[RawAPIItem]: ... + ) -> ItemPage[_T]: ... @classmethod def gather_from_iterator( cls, - iterator: typing.Iterator[typing.Union[ItemPage[_T], RawAPIPageResponse]], + iterator: Iterator[RawAPIPageResponse] | Iterator[ItemPage[_T]], /, return_format: CollectReturnFormat = CollectReturnFormat.FIRST, *, deduplicate: bool = True, - ) -> typing.Union[ItemPage[_T], typing.List[RawAPIItem]]: + ) -> list[RawAPIItem] | ItemPage[_T]: return cls._process_collected_pages(list(iterator), return_format, deduplicate) -@typing.final +@final class AsyncPageIterator(_BaseAsyncPageIterator[_PageT]): __slots__ = () - @typing.overload + @overload async def collect( - self: AsyncPageIterator[ItemPage[_T]], - ) -> ItemPage[_T]: ... + self: AsyncPageIterator[RawAPIPageResponse], + ) -> list[RawAPIItem]: ... - @typing.overload + @overload async def collect( - self: AsyncPageIterator[RawAPIPageResponse], - ) -> typing.List[RawAPIItem]: ... + self: AsyncPageIterator[ItemPage[_T]], + ) -> ItemPage[_T]: ... async def collect( - self: typing.Union[ - AsyncPageIterator[ItemPage[_T]], - AsyncPageIterator[RawAPIPageResponse], - ], - ) -> typing.Union[ItemPage[_T], typing.List[RawAPIItem]]: + self: AsyncPageIterator[RawAPIPageResponse] | AsyncPageIterator[ItemPage[_T]], + ) -> list[RawAPIItem] | ItemPage[_T]: return await self.__class__.gather_from_iterator(self) @classmethod @@ -691,12 +676,12 @@ async def unix( cls, method: AsyncResourceMethodProtocol[_PageT], /, - *args: typing.Any, + *args: Any, cfg: TimestampPaginationConfig, max_items: MaxItemsType = BasePageIterator.DEFAULT_MAX_ITEMS, - **kwargs: typing.Any, - ) -> typing.AsyncIterator[_PageT]: - cls._validate_unix_pagination_parameter(method, kwargs, cfg) + **kwargs: Any, + ) -> AsyncIterator[_PageT]: + cls._validate_unix_pagination_parameter(cfg, method, kwargs) kwargs["max_items"] = max_items current_timestamp = None @@ -725,37 +710,37 @@ async def unix( kwargs["max_items"] = iterator.max_items - total_yielded - @typing.overload + @overload @classmethod async def gather_from_iterator( cls, - iterator: typing.AsyncIterator[ItemPage[_T]], + iterator: AsyncIterator[RawAPIPageResponse], /, return_format: CollectReturnFormat = ..., *, deduplicate: bool = ..., - ) -> ItemPage[_T]: ... + ) -> list[RawAPIItem]: ... - @typing.overload + @overload @classmethod async def gather_from_iterator( cls, - iterator: typing.AsyncIterator[RawAPIPageResponse], + iterator: AsyncIterator[ItemPage[_T]], /, return_format: CollectReturnFormat = ..., *, deduplicate: bool = ..., - ) -> typing.List[RawAPIItem]: ... + ) -> ItemPage[_T]: ... @classmethod async def gather_from_iterator( cls, - iterator: typing.AsyncIterator[typing.Union[ItemPage[_T], RawAPIPageResponse]], + iterator: AsyncIterator[RawAPIPageResponse] | AsyncIterator[ItemPage[_T]], /, return_format: CollectReturnFormat = CollectReturnFormat.FIRST, *, deduplicate: bool = True, - ) -> typing.Union[ItemPage[_T], typing.List[RawAPIItem]]: + ) -> list[RawAPIItem] | ItemPage[_T]: return cls._process_collected_pages( [page async for page in iterator], return_format, deduplicate ) diff --git a/src/faceit/constants.py b/src/faceit/constants.py index 2a9f2ca..7752d27 100644 --- a/src/faceit/constants.py +++ b/src/faceit/constants.py @@ -2,33 +2,44 @@ import logging import re -import typing import warnings from dataclasses import dataclass from functools import total_ordering from types import MappingProxyType +from typing import ( + TYPE_CHECKING, + ClassVar, + Final, + NamedTuple, + TypeAlias, + cast, + final, + overload, +) from pydantic import Field, validate_call -from typing_extensions import Self, TypeAlias +from typing_extensions import Self from .utils import StrEnum, StrEnumWithAll -if typing.TYPE_CHECKING: - _EloThreshold: TypeAlias = typing.Dict[int, "EloRange"] +if TYPE_CHECKING: + from collections.abc import Mapping + + _EloThreshold: TypeAlias = dict[int, "EloRange"] _logger = logging.getLogger(__name__) -BASE_WIKI_URL: typing.Final = "https://docs.faceit.com" +BASE_WIKI_URL: Final = "https://docs.faceit.com" """Base URL for FACEIT's documentation.""" -RAW_RESPONSE_ITEMS_KEY: typing.Final = "items" -FACEIT_COLOR: typing.Final = 0xFF5500 +RAW_RESPONSE_ITEMS_KEY: Final = "items" +FACEIT_COLOR: Final = 0xFF5500 """Hex color code for FACEIT branding.""" -FACEIT_USERNAME_REGEX: typing.Final = re.compile(r"^[a-zA-Z0-9_-]{1,24}$") +FACEIT_USERNAME_REGEX: Final = re.compile(r"^[a-zA-Z0-9_-]{1,24}$") """ Regex pattern for validating FACEIT usernames. Matches 1 to 24 characters: letters, digits, underscores, or hyphens. """ -MIN_ELO: typing.Final = 100 +MIN_ELO: Final = 100 """ Minimum ELO value across all FACEIT games. Players cannot drop below this threshold regardless of consecutive losses. @@ -196,17 +207,17 @@ class Region(StrEnum): SOUTHEAST_ASIA = "SEA" -@typing.final -class EloRange(typing.NamedTuple): +@final +class EloRange(NamedTuple): lower: int - upper: typing.Union[int, HighTierLevel] + upper: int | HighTierLevel @property def is_open_ended(self) -> bool: return self.upper in HighTierLevel @property - def size(self) -> typing.Optional[int]: + def size(self) -> int | None: if self.is_open_ended: return None assert isinstance(self.upper, int) @@ -222,7 +233,7 @@ def __str__(self) -> str: return f"{self.lower}+" if self.is_open_ended else f"{self.lower}-{self.upper}" -_DEFAULT_TEN_LEVEL_LOWER: typing.Final = 2001 +_DEFAULT_TEN_LEVEL_LOWER: Final = 2001 def _create_default_elo_tiers() -> _EloThreshold: @@ -230,15 +241,14 @@ def _create_default_elo_tiers() -> _EloThreshold: for level in range(2, 10): # `cast("int", ...)` tells the type checker that we know `upper` is - # definitely an `int` for levels 1-9, not the full - # `typing.Union[int, HighTierLevel]` type - lower_bound = typing.cast("int", tier_ranges[level - 1].upper) + 1 + # definitely an `int` for levels 1-9, not the full `int | HighTierLevel` type + lower_bound = cast("int", tier_ranges[level - 1].upper) + 1 tier_ranges[level] = EloRange(lower_bound, lower_bound + 149) return tier_ranges -_BASE_ELO_RANGES: typing.Final = _create_default_elo_tiers() +_BASE_ELO_RANGES: Final = _create_default_elo_tiers() del _create_default_elo_tiers @@ -251,16 +261,14 @@ def _append_elite_tier( } -CHALLENGER_CAPPED_ELO_RANGES: typing.Final = _append_elite_tier( - HighTierLevel.CHALLENGER -) +CHALLENGER_CAPPED_ELO_RANGES: Final = _append_elite_tier(HighTierLevel.CHALLENGER) # Pre-generating this range configuration for future implementation needs # Exposed as a constant for both internal use and potential library consumers -OPEN_ENDED_ELO_RANGES: typing.Final = _append_elite_tier(HighTierLevel.ABSENT) +OPEN_ENDED_ELO_RANGES: Final = _append_elite_tier(HighTierLevel.ABSENT) del _append_elite_tier -ELO_THRESHOLDS: typing.Final[ - typing.Mapping[ +ELO_THRESHOLDS: Final[ + Mapping[ GameID, _EloThreshold, ] @@ -287,27 +295,20 @@ def _append_elite_tier( }) -@typing.final +@final @total_ordering -@dataclass(eq=False, frozen=True) +@dataclass(eq=False, frozen=True, slots=True) class SkillLevel: - __slots__ = ( - "elo_range", - "game_id", - "level", - "name", - ) - level: int game_id: GameID elo_range: EloRange name: str - if typing.TYPE_CHECKING: - _registry: typing.ClassVar[ - typing.Mapping[ + if TYPE_CHECKING: + _registry: ClassVar[ + Mapping[ GameID, - typing.Mapping[int, Self], + Mapping[int, Self], ] ] @@ -316,23 +317,23 @@ def is_highest_level(self) -> bool: return self.elo_range.is_open_ended @property - def range_size(self) -> typing.Optional[int]: + def range_size(self) -> int | None: return self.elo_range.size @property - def next_level(self) -> typing.Optional[Self]: + def next_level(self) -> Self | None: if self.is_highest_level: return None return self.get_level(self.game_id, self.level + 1) @property - def previous_level(self) -> typing.Optional[Self]: + def previous_level(self) -> Self | None: if self.level <= 1: return None return self.get_level(self.game_id, self.level - 1) @property - def elo_needed_for_next_level(self) -> typing.Optional[int]: + def elo_needed_for_next_level(self) -> int | None: if self.is_highest_level: return None @@ -346,9 +347,7 @@ def contains_elo(self, elo: int, /) -> bool: return self.elo_range.contains(elo) @validate_call - def progress_percentage( - self, elo: int = Field(ge=MIN_ELO), / - ) -> typing.Optional[float]: + def progress_percentage(self, elo: int = Field(ge=MIN_ELO), /) -> float | None: if self.is_highest_level: warnings.warn( "Cannot calculate progress percentage for highest level", stacklevel=4 @@ -365,29 +364,29 @@ def progress_percentage( ) return progress_ratio * 100 - @typing.overload + @overload @classmethod def get_level( cls, game_id: GameID, level: int = Field(ge=1, le=10), - ) -> typing.Optional[Self]: ... + ) -> Self | None: ... - @typing.overload + @overload @classmethod def get_level( cls, game_id: GameID, *, elo: int = Field(ge=MIN_ELO) - ) -> typing.Optional[Self]: ... + ) -> Self | None: ... @classmethod @validate_call def get_level( cls, game_id: GameID, - level: typing.Optional[int] = Field(None, ge=1, le=10), + level: int | None = Field(None, ge=1, le=10), *, - elo: typing.Optional[int] = Field(None, ge=MIN_ELO), - ) -> typing.Optional[Self]: + elo: int | None = Field(None, ge=MIN_ELO), + ) -> Self | None: if game_id not in cls._registry: warnings.warn(f"Game {game_id!r} is not supported", stacklevel=4) return None @@ -417,7 +416,7 @@ def get_level( @classmethod @validate_call - def get_all_levels(cls, game_id: GameID, /) -> typing.List[Self]: + def get_all_levels(cls, game_id: GameID, /) -> list[Self]: return sorted(cls._registry.get(game_id, {}).values()) def __int__(self) -> int: diff --git a/src/faceit/exceptions.py b/src/faceit/exceptions.py index fad6994..05fad7b 100644 --- a/src/faceit/exceptions.py +++ b/src/faceit/exceptions.py @@ -1,4 +1,4 @@ -import typing +from typing import Any, ClassVar, final import httpx @@ -7,7 +7,7 @@ class FaceitError(Exception): """Base class for all FACEIT exceptions.""" -@typing.final +@final class DecoupleNotFoundError(FaceitError): def __init__(self) -> None: super().__init__( @@ -17,7 +17,7 @@ def __init__(self) -> None: ) -@typing.final +@final class MissingAuthTokenError(FaceitError): def __init__(self, key: str, /) -> None: self.key = key @@ -28,16 +28,16 @@ def __init__(self, key: str, /) -> None: class APIError(FaceitError): - _DEFAULT_MESSAGE: typing.ClassVar = "API request failed" - _EXPECTED_STATUS_CODE: typing.ClassVar = 0 - _MESSAGE_FORMAT: typing.ClassVar = "[{status_code}] {message}" - _STATUS_ERRORS: typing.ClassVar[typing.Dict[int, typing.Type["APIError"]]] = {} + _DEFAULT_MESSAGE: ClassVar = "API request failed" + _EXPECTED_STATUS_CODE: ClassVar = 0 + _MESSAGE_FORMAT: ClassVar = "[{status_code}] {message}" + _STATUS_ERRORS: ClassVar[dict[int, type["APIError"]]] = {} def __init_subclass__( cls, code: httpx.codes, - default_message: typing.Optional[str] = None, - **kwargs: typing.Any, + default_message: str | None = None, + **kwargs: Any, ) -> None: cls._EXPECTED_STATUS_CODE = code.value cls._DEFAULT_MESSAGE = default_message or code.get_reason_phrase(code.value) @@ -46,10 +46,10 @@ def __init_subclass__( def __init__( self, - response: typing.Optional[httpx.Response] = None, + response: httpx.Response | None = None, /, *, - message: typing.Optional[str] = None, + message: str | None = None, ) -> None: self.response = response self.status_code = ( @@ -78,18 +78,11 @@ def from_response(cls, response: httpx.Response, /) -> "APIError": # fmt: off -@typing.final class BadRequestError(APIError, code=httpx.codes.BAD_REQUEST): ... -@typing.final class UnauthorizedError(APIError, code=httpx.codes.UNAUTHORIZED): ... -@typing.final class ForbiddenError(APIError, code=httpx.codes.FORBIDDEN): ... -@typing.final class NotFoundError(APIError, code=httpx.codes.NOT_FOUND): ... -@typing.final class TooManyRequestsError(APIError, code=httpx.codes.TOO_MANY_REQUESTS): ... -@typing.final class InternalServerError(APIError, code=httpx.codes.INTERNAL_SERVER_ERROR): ... -@typing.final class ServiceUnavailableError(APIError, code=httpx.codes.SERVICE_UNAVAILABLE): ... # fmt: on diff --git a/src/faceit/faceit.py b/src/faceit/faceit.py deleted file mode 100644 index 44c6ddd..0000000 --- a/src/faceit/faceit.py +++ /dev/null @@ -1,48 +0,0 @@ -import typing - -from typing_extensions import deprecated - -from .api import AsyncDataResource, SyncDataResource -from .http import AsyncClient, SyncClient -from .types import ClientT, DataResourceT - - -class BaseFaceit(typing.Generic[ClientT, DataResourceT]): - __slots__ = () - - if typing.TYPE_CHECKING: - _data_cls: typing.Type[DataResourceT] - - @classmethod - def data(cls, *args: typing.Any, **kwargs: typing.Any) -> DataResourceT: - import warnings # noqa: PLC0415 - - warnings.warn( - f"`{cls.__name__}.data()` is deprecated and will be removed in a future release. " - f"Please instantiate `{cls._data_cls.__name__}` directly.", - DeprecationWarning, - stacklevel=2, - ) - return typing.cast("DataResourceT", cls._data_cls(*args, **kwargs)) - - -@typing.final -@deprecated( - "`Faceit` is deprecated and will be removed in a future release. " - "Use `SyncDataResource` instead." -) -class Faceit(BaseFaceit[SyncClient, SyncDataResource]): - __slots__ = () - - _data_cls = SyncDataResource - - -@typing.final -@deprecated( - "`AsyncFaceit` is deprecated and will be removed in a future release. " - "Use `AsyncDataResource` instead." -) -class AsyncFaceit(BaseFaceit[AsyncClient, AsyncDataResource]): - __slots__ = () - - _data_cls = AsyncDataResource diff --git a/src/faceit/http/client.py b/src/faceit/http/client.py index af490fa..2c4bbe2 100644 --- a/src/faceit/http/client.py +++ b/src/faceit/http/client.py @@ -2,7 +2,6 @@ import asyncio import logging -import typing import warnings from abc import ABC from collections import UserString @@ -10,6 +9,17 @@ from threading import Lock from time import time from types import MappingProxyType +from typing import ( + TYPE_CHECKING, + Any, + ClassVar, + Generic, + Literal, + TypeVar, + cast, + final, + overload, +) from weakref import WeakSet import httpx @@ -37,7 +47,9 @@ is_ssl_error, ) -if typing.TYPE_CHECKING: +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + from faceit.types import ( EndpointParam, RawAPIItem, @@ -48,8 +60,8 @@ _logger = logging.getLogger(__name__) -_HttpxClientT = typing.TypeVar("_HttpxClientT", httpx.Client, httpx.AsyncClient) -_RetryerT = typing.TypeVar("_RetryerT", tenacity.Retrying, tenacity.AsyncRetrying) +_HttpxClientT = TypeVar("_HttpxClientT", httpx.Client, httpx.AsyncClient) +_RetryerT = TypeVar("_RetryerT", tenacity.Retrying, tenacity.AsyncRetrying) class MaxConcurrentRequests(StrEnum): @@ -60,7 +72,7 @@ class MaxConcurrentRequests(StrEnum): # which is required for the Data resource. This should be revisited when adding # support for other resources, as they may require different authentication methods. @representation("api_key", "base_url", "retry_args") -class BaseAPIClient(ABC, typing.Generic[_HttpxClientT, _RetryerT]): +class BaseAPIClient(ABC, Generic[_HttpxClientT, _RetryerT]): __slots__ = ( "_api_key", "_build_endpoint", @@ -68,27 +80,25 @@ class BaseAPIClient(ABC, typing.Generic[_HttpxClientT, _RetryerT]): "base_url", ) - @typing.final + @final class env(UserString): """String subclass representing a key to fetch from environment variables.""" __slots__ = () - DEFAULT_API_KEY_ENV: typing.ClassVar = env("FACEIT_SECRET") - DEFAULT_BASE_URL: typing.ClassVar = "https://open.faceit.com/data/v4" - DEFAULT_TIMEOUT: typing.ClassVar = 10.0 - DEFAULT_RETRY_ARGS: typing.ClassVar = RetryArgs( + DEFAULT_API_KEY_ENV: ClassVar = env("FACEIT_SECRET") + DEFAULT_BASE_URL: ClassVar = "https://open.faceit.com/data/v4" + DEFAULT_TIMEOUT: ClassVar = 10.0 + DEFAULT_RETRY_ARGS: ClassVar = RetryArgs( stop=tenacity.stop_after_attempt(3), wait=tenacity.wait_random_exponential(1, 10), retry=tenacity.retry_if_exception( lambda e: ( isinstance( e, - ( - httpx.TimeoutException, - httpx.ConnectError, - httpx.RemoteProtocolError, - ), + httpx.TimeoutException + | httpx.ConnectError + | httpx.RemoteProtocolError, ) or (isinstance(e, APIError) and is_retryable_status(e.status_code)) ) @@ -101,24 +111,22 @@ class env(UserString): ), ) - if typing.TYPE_CHECKING: + if TYPE_CHECKING: _retry_args: RetryArgs _client: _HttpxClientT _retryer: _RetryerT - _api_key_validator: typing.ClassVar[typing.Callable[[ValidUUID], str]] = ( - create_uuid_validator( - error_message="Invalid FACEIT API key format: {value!r}. " - "Please visit the official wiki for API key information: " - f"{BASE_WIKI_URL}/getting-started/authentication/api-keys" - ) + _api_key_validator: ClassVar[Callable[[ValidUUID], str]] = create_uuid_validator( + error_message="Invalid FACEIT API key format: {value!r}. " + "Please visit the official wiki for API key information: " + f"{BASE_WIKI_URL}/getting-started/authentication/api-keys" ) def __init__( self, - api_key: typing.Union[ValidUUID, env] = DEFAULT_API_KEY_ENV, + api_key: ValidUUID | env = DEFAULT_API_KEY_ENV, base_url: str = DEFAULT_BASE_URL, - retry_args: typing.Optional[RetryArgs] = None, + retry_args: RetryArgs | None = None, ) -> None: self.base_url = base_url.rstrip("/") self._api_key_setter(api_key) @@ -130,7 +138,7 @@ def api_key(self) -> str: return self._api_key[:4] + "..." + self._api_key[-4:] @api_key.setter - def api_key(self, value: typing.Union[ValidUUID, env], /) -> None: + def api_key(self, value: ValidUUID | env, /) -> None: self._api_key_setter(value) @property @@ -153,7 +161,7 @@ def is_closed(self) -> bool: return self._client.is_closed if hasattr(self, "_client") else True @property - def _base_headers(self) -> typing.Mapping[str, str]: + def _base_headers(self) -> Mapping[str, str]: return MappingProxyType({ "Accept": "application/json", "Authorization": f"Bearer {self._api_key}", @@ -162,7 +170,7 @@ def _base_headers(self) -> typing.Mapping[str, str]: def create_endpoint(self, *path_parts: str) -> Endpoint: return Endpoint(*path_parts, base=self.base_url) - def _api_key_setter(self, value: typing.Union[ValidUUID, env], /) -> None: + def _api_key_setter(self, value: ValidUUID | env, /) -> None: self._api_key = self.__class__._api_key_validator( self.__class__._get_secret_from_env(str(value)) if isinstance(value, self.__class__.env) @@ -173,7 +181,7 @@ def _retry_args_setter(self, retry_args: RetryArgs, /) -> None: if not isinstance(retry_args, dict): msg = f"Expected RetryArgs, got {type(retry_args).__name__}" raise TypeError(msg) - self._retry_args = {**self.__class__.DEFAULT_RETRY_ARGS, **retry_args} + self._retry_args = self.__class__.DEFAULT_RETRY_ARGS | retry_args def _build_endpoint_unwrapped(self, endpoint: EndpointParam, /) -> str: return str( @@ -185,8 +193,8 @@ def _build_endpoint_unwrapped(self, endpoint: EndpointParam, /) -> str: def _prepare_request( self, endpoint: EndpointParam, - headers: typing.Optional[httpx._types.HeaderTypes] = None, - ) -> typing.Tuple[str, httpx.Headers]: + headers: httpx._types.HeaderTypes | None = None, + ) -> tuple[str, httpx.Headers]: combined_headers = httpx.Headers(self._base_headers) combined_headers.update(headers) return self._build_endpoint(endpoint), combined_headers @@ -198,7 +206,7 @@ def _get_secret_from_env(key: str, /) -> str: except ModuleNotFoundError: raise DecoupleNotFoundError from None try: - return typing.cast("str", decouple.config(key)) + return cast("str", decouple.config(key)) except decouple.UndefinedValueError: raise MissingAuthTokenError(key) from None @@ -207,13 +215,13 @@ def _handle_response(response: httpx.Response, /) -> RawAPIResponse: try: response.raise_for_status() _logger.debug("Successful response from %s", response.url) - return typing.cast("RawAPIResponse", response.json()) + return cast("RawAPIResponse", response.json()) except httpx.HTTPStatusError as e: # fmt: off if is_retryable_status(e.response.status_code): _logger.warning( "Retryable HTTP error %s at %s: %s", - e.response.status_code, e.response.url, e.response.text + e.response.status_code, e.response.url, e.response.text, ) else: _logger.exception( @@ -234,14 +242,12 @@ class _BaseSyncClient(BaseAPIClient[httpx.Client, tenacity.Retrying]): def __init__( self, - api_key: typing.Union[ - ValidUUID, BaseAPIClient.env - ] = BaseAPIClient.DEFAULT_API_KEY_ENV, + api_key: ValidUUID | BaseAPIClient.env = BaseAPIClient.DEFAULT_API_KEY_ENV, *, base_url: str = BaseAPIClient.DEFAULT_BASE_URL, timeout: float = BaseAPIClient.DEFAULT_TIMEOUT, - retry_args: typing.Optional[RetryArgs] = None, - **raw_client_kwargs: typing.Any, + retry_args: RetryArgs | None = None, + **raw_client_kwargs: Any, ) -> None: super().__init__(api_key, base_url, retry_args) self._client = httpx.Client( @@ -255,7 +261,7 @@ def close(self) -> None: _logger.debug("%s closed", self.__class__.__name__) def request( - self, method: str, endpoint: EndpointParam, **kwargs: typing.Any + self, method: str, endpoint: EndpointParam, **kwargs: Any ) -> RawAPIResponse: url, headers = self._prepare_request(endpoint, kwargs.pop("headers", None)) return self._retryer( @@ -277,49 +283,46 @@ def __exit__(self, *_: object, **__: object) -> None: class _BaseAsyncClient(BaseAPIClient[httpx.AsyncClient, tenacity.AsyncRetrying]): __slots__ = ("__weakref__", "_client", "_retryer") - _instances: typing.ClassVar[WeakSet[_BaseAsyncClient]] = WeakSet() + _instances: ClassVar[WeakSet[_BaseAsyncClient]] = WeakSet() - _lock: typing.ClassVar = Lock() - _asyncio_lock: typing.ClassVar = asyncio.Lock() - _semaphore: typing.ClassVar[typing.Optional[asyncio.Semaphore]] = None - _ssl_error_count: typing.ClassVar = 0 - _adaptive_limit_enabled: typing.ClassVar = True - _last_ssl_error_time: typing.ClassVar = time() - _recovery_check_time: typing.ClassVar = 0.0 + _lock: ClassVar = Lock() + _asyncio_lock: ClassVar = asyncio.Lock() + _semaphore: ClassVar[asyncio.Semaphore | None] = None + _ssl_error_count: ClassVar = 0 + _adaptive_limit_enabled: ClassVar = True + _last_ssl_error_time: ClassVar = time() + _recovery_check_time: ClassVar = 0.0 # Current limit value is based on empirical testing, # but requires further investigation for optimal setting - MAX_CONCURRENT_REQUESTS_ABSOLUTE: typing.ClassVar = 100 + MAX_CONCURRENT_REQUESTS_ABSOLUTE: ClassVar = 100 - DEFAULT_MAX_CONCURRENT_REQUESTS: typing.ClassVar = 30 - DEFAULT_SSL_ERROR_THRESHOLD: typing.ClassVar = 5 - DEFAULT_MIN_CONNECTIONS: typing.ClassVar = 5 - DEFAULT_RECOVERY_INTERVAL: typing.ClassVar = 300 + DEFAULT_MAX_CONCURRENT_REQUESTS: ClassVar = 30 + DEFAULT_SSL_ERROR_THRESHOLD: ClassVar = 5 + DEFAULT_MIN_CONNECTIONS: ClassVar = 5 + DEFAULT_RECOVERY_INTERVAL: ClassVar = 300 - _initial_max_requests: typing.ClassVar = DEFAULT_MAX_CONCURRENT_REQUESTS - _max_concurrent_requests: typing.ClassVar = DEFAULT_MAX_CONCURRENT_REQUESTS - _ssl_error_threshold: typing.ClassVar = DEFAULT_SSL_ERROR_THRESHOLD - _min_connections: typing.ClassVar = DEFAULT_MIN_CONNECTIONS - _recovery_interval: typing.ClassVar = DEFAULT_RECOVERY_INTERVAL + _initial_max_requests: ClassVar = DEFAULT_MAX_CONCURRENT_REQUESTS + _max_concurrent_requests: ClassVar = DEFAULT_MAX_CONCURRENT_REQUESTS + _ssl_error_threshold: ClassVar = DEFAULT_SSL_ERROR_THRESHOLD + _min_connections: ClassVar = DEFAULT_MIN_CONNECTIONS + _recovery_interval: ClassVar = DEFAULT_RECOVERY_INTERVAL - DEFAULT_KEEPALIVE_EXPIRY: typing.ClassVar = 30.0 + DEFAULT_KEEPALIVE_EXPIRY: ClassVar = 30.0 def __init__( self, - api_key: typing.Union[ - ValidUUID, BaseAPIClient.env - ] = BaseAPIClient.DEFAULT_API_KEY_ENV, + api_key: ValidUUID | BaseAPIClient.env = BaseAPIClient.DEFAULT_API_KEY_ENV, *, base_url: str = BaseAPIClient.DEFAULT_BASE_URL, timeout: float = BaseAPIClient.DEFAULT_TIMEOUT, - retry_args: typing.Optional[RetryArgs] = None, - max_concurrent_requests: typing.Union[ - MaxConcurrentRequests, int - ] = DEFAULT_MAX_CONCURRENT_REQUESTS, + retry_args: RetryArgs | None = None, + max_concurrent_requests: MaxConcurrentRequests + | int = DEFAULT_MAX_CONCURRENT_REQUESTS, ssl_error_threshold: int = DEFAULT_SSL_ERROR_THRESHOLD, min_connections: int = DEFAULT_MIN_CONNECTIONS, recovery_interval: int = DEFAULT_RECOVERY_INTERVAL, - **raw_client_kwargs: typing.Any, + **raw_client_kwargs: Any, ) -> None: super().__init__(api_key, base_url, retry_args) max_concurrent_requests = self.__class__._update_initial_max_requests( @@ -391,10 +394,10 @@ async def ssl_before_sleep(retry_state: tenacity.RetryCallState) -> None: await invoke_callable(original_before_sleep, retry_state) - self._retry_args.update({ + self._retry_args |= { "retry": tenacity.asyncio.retry_if_exception(combined_retry), "before_sleep": ssl_before_sleep, - }) + } async def aclose(self) -> None: if not self.is_closed: @@ -402,7 +405,7 @@ async def aclose(self) -> None: _logger.debug("%s closed", self.__class__.__name__) async def request( - self, method: str, endpoint: EndpointParam, **kwargs: typing.Any + self, method: str, endpoint: EndpointParam, **kwargs: Any ) -> RawAPIResponse: url, headers = self._prepare_request(endpoint, kwargs.pop("headers", None)) await self.__class__._check_connection_recovery() @@ -431,7 +434,7 @@ def _register_ssl_error( cls, # Always returns `True` to ensure retry # happens for SSL errors in the retry mechanism - ) -> typing.Literal[True]: + ) -> Literal[True]: cls._ssl_error_count += 1 cls._last_ssl_error_time = time() current_limit = cls._max_concurrent_requests @@ -491,7 +494,7 @@ async def _check_connection_recovery(cls) -> None: @locked(_lock) @validate_call def _update_initial_max_requests( - cls, value: typing.Union[MaxConcurrentRequests, PositiveInt], / + cls, value: MaxConcurrentRequests | PositiveInt, / ) -> int: max_concurrent_requests = ( cls.MAX_CONCURRENT_REQUESTS_ABSOLUTE @@ -512,7 +515,7 @@ def close(cls) -> Never: Async clients should use :meth:`~.aclose` instead. """ msg = ( - f"Use 'await {cls.__name__}.aclose()' instead of '{cls.__name__}.close().'" + f"Use 'await {cls.__name__}.aclose()' instead of '{cls.__name__}.close()'." ) raise RuntimeError(msg) @@ -547,10 +550,10 @@ def update_rate_limit(cls, new_limit: PositiveInt, /) -> None: @validate_call def configure_adaptive_limits( cls, - ssl_error_threshold: typing.Optional[PositiveInt] = None, - min_connections: typing.Optional[PositiveInt] = None, - recovery_interval: typing.Optional[PositiveInt] = None, - enabled: typing.Optional[bool] = None, # noqa: FBT001 + ssl_error_threshold: PositiveInt | None = None, + min_connections: PositiveInt | None = None, + recovery_interval: PositiveInt | None = None, + enabled: bool | None = None, # noqa: FBT001 ) -> None: params = { "ssl_error_threshold": ("_ssl_error_threshold", ssl_error_threshold), @@ -609,125 +612,115 @@ async def __aexit__(self, *_: object, **__: object) -> None: # the core implementation details in the base classes -def _clean_type_hints( - kwargs: typing.Dict[str, typing.Any], / -) -> typing.Dict[str, typing.Any]: +def _clean_type_hints(kwargs: dict[str, Any], /) -> dict[str, Any]: return {k: v for k, v in kwargs.items() if k not in {"expect_item", "expect_page"}} -@typing.final +@final class SyncClient(_BaseSyncClient): __slots__ = () - @typing.overload + @overload def get( self, endpoint: EndpointParam, *, - expect_item: typing.Literal[True], - **kwargs: typing.Any, + expect_item: Literal[True], + **kwargs: Any, ) -> RawAPIItem: ... - @typing.overload + @overload def get( self, endpoint: EndpointParam, *, - expect_page: typing.Literal[True], - **kwargs: typing.Any, + expect_page: Literal[True], + **kwargs: Any, ) -> RawAPIPageResponse: ... - @typing.overload - def get(self, endpoint: EndpointParam, **kwargs: typing.Any) -> RawAPIResponse: ... + @overload + def get(self, endpoint: EndpointParam, **kwargs: Any) -> RawAPIResponse: ... - def get(self, endpoint: EndpointParam, **kwargs: typing.Any) -> RawAPIResponse: + def get(self, endpoint: EndpointParam, **kwargs: Any) -> RawAPIResponse: return self.request(SupportedMethod.GET, endpoint, **_clean_type_hints(kwargs)) - @typing.overload + @overload def post( self, endpoint: EndpointParam, *, - expect_item: typing.Literal[True], - **kwargs: typing.Any, + expect_item: Literal[True], + **kwargs: Any, ) -> RawAPIItem: ... - @typing.overload + @overload def post( self, endpoint: EndpointParam, *, - expect_page: typing.Literal[True], - **kwargs: typing.Any, + expect_page: Literal[True], + **kwargs: Any, ) -> RawAPIPageResponse: ... - @typing.overload - def post(self, endpoint: EndpointParam, **kwargs: typing.Any) -> RawAPIResponse: ... + @overload + def post(self, endpoint: EndpointParam, **kwargs: Any) -> RawAPIResponse: ... - def post(self, endpoint: EndpointParam, **kwargs: typing.Any) -> RawAPIResponse: + def post(self, endpoint: EndpointParam, **kwargs: Any) -> RawAPIResponse: return self.request(SupportedMethod.POST, endpoint, **_clean_type_hints(kwargs)) -@typing.final +@final class AsyncClient(_BaseAsyncClient): __slots__ = () - @typing.overload + @overload async def get( self, endpoint: EndpointParam, *, - expect_item: typing.Literal[True], - **kwargs: typing.Any, + expect_item: Literal[True], + **kwargs: Any, ) -> RawAPIItem: ... - @typing.overload + @overload async def get( self, endpoint: EndpointParam, *, - expect_page: typing.Literal[True], - **kwargs: typing.Any, + expect_page: Literal[True], + **kwargs: Any, ) -> RawAPIPageResponse: ... - @typing.overload - async def get( - self, endpoint: EndpointParam, **kwargs: typing.Any - ) -> RawAPIResponse: ... + @overload + async def get(self, endpoint: EndpointParam, **kwargs: Any) -> RawAPIResponse: ... - async def get( - self, endpoint: EndpointParam, **kwargs: typing.Any - ) -> RawAPIResponse: + async def get(self, endpoint: EndpointParam, **kwargs: Any) -> RawAPIResponse: return await self.request( SupportedMethod.GET, endpoint, **_clean_type_hints(kwargs) ) - @typing.overload + @overload async def post( self, endpoint: EndpointParam, *, - expect_item: typing.Literal[True], - **kwargs: typing.Any, + expect_item: Literal[True], + **kwargs: Any, ) -> RawAPIItem: ... - @typing.overload + @overload async def post( self, endpoint: EndpointParam, *, - expect_page: typing.Literal[True], - **kwargs: typing.Any, + expect_page: Literal[True], + **kwargs: Any, ) -> RawAPIPageResponse: ... - @typing.overload - async def post( - self, endpoint: EndpointParam, **kwargs: typing.Any - ) -> RawAPIResponse: ... + @overload + async def post(self, endpoint: EndpointParam, **kwargs: Any) -> RawAPIResponse: ... - async def post( - self, endpoint: EndpointParam, **kwargs: typing.Any - ) -> RawAPIResponse: + async def post(self, endpoint: EndpointParam, **kwargs: Any) -> RawAPIResponse: return await self.request( SupportedMethod.POST, endpoint, **_clean_type_hints(kwargs) ) diff --git a/src/faceit/http/helpers.py b/src/faceit/http/helpers.py index 1808f1b..7a345e6 100644 --- a/src/faceit/http/helpers.py +++ b/src/faceit/http/helpers.py @@ -1,56 +1,51 @@ from __future__ import annotations -import typing from ssl import SSLError +from typing import ( + TYPE_CHECKING, + Any, + Protocol, + TypeAlias, + TypedDict, + final, + runtime_checkable, +) import httpx -from typing_extensions import Self, TypeAlias +from typing_extensions import Self from faceit.utils import StrEnum, representation -if typing.TYPE_CHECKING: +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + import tenacity from faceit.types import EndpointParam - _RetryHook: TypeAlias = typing.Callable[ - [tenacity.RetryCallState], typing.Union[typing.Awaitable[None], None] - ] - - -@typing.final -class RetryArgs(typing.TypedDict, total=False): - sleep: typing.Callable[ - [typing.Union[int, float]], typing.Union[typing.Awaitable[None], None] - ] - stop: typing.Union[ - tenacity.stop.stop_base, typing.Callable[[tenacity.RetryCallState], bool] - ] - wait: typing.Union[ - tenacity.wait.wait_base, - typing.Callable[[tenacity.RetryCallState], typing.Union[float, int]], - ] - retry: typing.Union[ - tenacity.retry_base, - typing.Callable[ - [tenacity.RetryCallState], typing.Union[typing.Awaitable[bool], bool] - ], - ] + _RetryHook: TypeAlias = Callable[[tenacity.RetryCallState], Awaitable[None] | None] + + +@final +class RetryArgs(TypedDict, total=False): + sleep: Callable[[int | float], Awaitable[None] | None] + stop: tenacity.stop.stop_base | Callable[[tenacity.RetryCallState], bool] + wait: tenacity.wait.wait_base | Callable[[tenacity.RetryCallState], float | int] + retry: ( + tenacity.retry_base + | Callable[[tenacity.RetryCallState], Awaitable[bool] | bool] + ) before: _RetryHook after: _RetryHook - before_sleep: typing.Optional[_RetryHook] + before_sleep: _RetryHook | None reraise: bool - retry_error_cls: typing.Type[tenacity.RetryError] - retry_error_callback: typing.Optional[ - typing.Callable[[tenacity.RetryCallState], typing.Any] - ] + retry_error_cls: type[tenacity.RetryError] + retry_error_callback: Callable[[tenacity.RetryCallState], Any] | None -@typing.runtime_checkable -class SupportsExceptionPredicate(typing.Protocol): - predicate: typing.Callable[ - [BaseException], typing.Union[typing.Awaitable[bool], bool] - ] +@runtime_checkable +class SupportsExceptionPredicate(Protocol): + predicate: Callable[[BaseException], Awaitable[bool] | bool] class SupportedMethod(StrEnum): @@ -58,12 +53,12 @@ class SupportedMethod(StrEnum): POST = "POST" -@typing.final +@final @representation(use_str=True) class Endpoint: __slots__ = ("base", "path_parts") - def __init__(self, *path_parts: str, base: typing.Optional[str] = None) -> None: + def __init__(self, *path_parts: str, base: str | None = None) -> None: self.path_parts = list(filter(None, path_parts)) self.base = base diff --git a/src/faceit/models/championships.py b/src/faceit/models/championships.py index 47b25c1..f850053 100644 --- a/src/faceit/models/championships.py +++ b/src/faceit/models/championships.py @@ -1,4 +1,4 @@ -import typing +from typing import Any, final from pydantic import BaseModel @@ -13,31 +13,31 @@ ) -@typing.final +@final class JoinChecks(BaseModel): min_skill_level: int max_skill_level: int - whitelist_geo_countries: typing.List[str] + whitelist_geo_countries: list[str] whitelist_geo_countries_min_players: int - blacklist_geo_countries: typing.List[str] + blacklist_geo_countries: list[str] join_policy: str membership_type: str - allowed_team_types: typing.List[str] + allowed_team_types: list[str] -@typing.final +@final class SubstitutionConfiguration(BaseModel): max_substitutes: int max_substitutions: int -@typing.final +@final class Prize(BaseModel): rank: int faceit_points: int -@typing.final +@final class Stream(BaseModel): active: bool platform: str @@ -45,18 +45,18 @@ class Stream(BaseModel): title: str -@typing.final +@final class Screening(BaseModel): id: FaceitID enabled: bool -@typing.final +@final class Championship(BaseModel): id: FaceitID # `championship_id: FaceitID` unnecessary name: str - screening: typing.Optional[Screening] = None + screening: Screening | None = None cover_image: UrlOrEmpty background_image: UrlOrEmpty avatar: UrlOrEmpty @@ -81,7 +81,7 @@ class Championship(BaseModel): full: bool checkin_enabled: bool total_rounds: int - schedule: ResponseContainer[typing.Any] + schedule: ResponseContainer[Any] total_groups: int subscriptions_locked: bool seeding_strategy: str diff --git a/src/faceit/models/custom_types/common.py b/src/faceit/models/custom_types/common.py index 0307a24..de0f36b 100644 --- a/src/faceit/models/custom_types/common.py +++ b/src/faceit/models/custom_types/common.py @@ -1,25 +1,35 @@ from __future__ import annotations import re -import typing from abc import ABC from datetime import datetime, timezone +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + ClassVar, + Final, + TypeAlias, + final, + overload, +) from pydantic import ( AfterValidator, BeforeValidator, - GetCoreSchemaHandler, RootModel, model_validator, ) from pydantic_core import core_schema -from typing_extensions import Annotated, Self, TypeAlias +from typing_extensions import Self from faceit.types import _R, _T, UrlOrEmpty -_INJECTED_KEY: typing.Final = "injected_key" -_LANG_PLACEHOLDER: typing.Final = "{lang}" -_LANG_PATTERN: typing.Final = re.compile(rf"/?{re.escape(_LANG_PLACEHOLDER)}/?") +if TYPE_CHECKING: + from collections.abc import ItemsView, Iterator, KeysView, ValuesView + +_LANG_PLACEHOLDER: Final = "{lang}" +_LANG_PATTERN: Final = re.compile(rf"/?{re.escape(_LANG_PLACEHOLDER)}/?") LangFormattedAnyHttpUrl: TypeAlias = Annotated[ UrlOrEmpty, @@ -28,7 +38,7 @@ ), ] NullableList: TypeAlias = Annotated[ - typing.List[_T], + list[_T], BeforeValidator(lambda x: x or []), ] # NOTE: Type alias for country codes that are always validated and converted to lowercase @@ -44,10 +54,10 @@ class _BaseTimestamp(int, ABC): __slots__ = () - if typing.TYPE_CHECKING: - _UNITS_PER_SEC: typing.ClassVar[int] + if TYPE_CHECKING: + _UNITS_PER_SEC: ClassVar[int] - def __init_subclass__(cls, units_per_sec: int, **kwargs: typing.Any) -> None: + def __init_subclass__(cls, units_per_sec: int, **kwargs: Any) -> None: cls._UNITS_PER_SEC = units_per_sec return super().__init_subclass__(**kwargs) @@ -59,69 +69,64 @@ def from_datetime(cls, dt: datetime, /) -> Self: return cls(round(dt.timestamp() * cls._UNITS_PER_SEC)) @classmethod - def _validate(cls, value: int, /) -> Self: - if value >= 0: - return cls(value) - msg = f"Value {value} is negative. Timestamp cannot be negative" - raise ValueError(msg) - - @classmethod - def __get_pydantic_core_schema__( - cls, _: typing.Type[typing.Any], handler: GetCoreSchemaHandler - ) -> core_schema.CoreSchema: - return core_schema.no_info_after_validator_function(cls._validate, handler(int)) + def __get_pydantic_core_schema__(cls, *_: Any, **__: Any) -> core_schema.CoreSchema: + return core_schema.chain_schema([ + core_schema.int_schema(ge=0), + core_schema.no_info_after_validator_function(cls, core_schema.any_schema()), + ]) -@typing.final +@final class TimestampMs(_BaseTimestamp, units_per_sec=1000): @property def as_sec(self) -> TimestampSec: return TimestampSec(self // 1000) -@typing.final +@final class TimestampSec(_BaseTimestamp, units_per_sec=1): @property def as_ms(self) -> TimestampMs: return TimestampMs(self * 1000) -TimestampLike: TypeAlias = typing.Union[TimestampSec, TimestampMs, int] -NotStrictTimestampMs: TypeAlias = typing.Union[TimestampMs, int] -NotStrictTimestampSec: TypeAlias = typing.Union[TimestampSec, int] +TimestampLike: TypeAlias = TimestampSec | TimestampMs | int +NotStrictTimestampMs: TypeAlias = TimestampMs | int +NotStrictTimestampSec: TypeAlias = TimestampSec | int + + +_INJECTED_KEY: Final = "injected_key" -@typing.final -class ResponseContainer(RootModel[typing.Dict[str, _T]]): +@final +class ResponseContainer(RootModel[dict[str, _T]]): __slots__ = () - def items(self) -> typing.ItemsView[str, _T]: + def items(self) -> ItemsView[str, _T]: return self.root.items() - def keys(self) -> typing.KeysView[str]: + def keys(self) -> KeysView[str]: return self.root.keys() - def values(self) -> typing.ValuesView[_T]: + def values(self) -> ValuesView[_T]: return self.root.values() - @typing.overload - def get(self, key: str, /) -> typing.Optional[_T]: ... + @overload + def get(self, key: str, /) -> _T | None: ... - @typing.overload - def get(self, key: str, /, default: _R) -> typing.Union[_T, _R]: ... + @overload + def get(self, key: str, /, default: _R) -> _T | _R: ... - def get( - self, key: str, /, default: typing.Optional[_R] = None - ) -> typing.Union[_T, _R, None]: + def get(self, key: str, /, default: _R | None = None) -> _T | _R | None: return self.root.get(key, default) def __getattr__(self, name: str) -> _T: if name in self.root: return self.root[name] - msg = f"'{self.__class__.__name__}' object has no attribute '{name}'" + msg = f"{self.__class__.__name__!r} object has no attribute {name!r}" raise AttributeError(msg) - def __iter__(self) -> typing.Iterator[str]: # type: ignore[override] + def __iter__(self) -> Iterator[str]: # type: ignore[override] yield from self.root def __getitem__(self, key: str) -> _T: @@ -129,7 +134,7 @@ def __getitem__(self, key: str) -> _T: @model_validator(mode="before") @classmethod - def _inject_keys(cls, data: typing.Any) -> typing.Any: + def _inject_keys(cls, data: Any) -> Any: if not isinstance(data, dict): return data return { diff --git a/src/faceit/models/custom_types/faceit_uuid.py b/src/faceit/models/custom_types/faceit_uuid.py index 6a37bbf..3eb65c2 100644 --- a/src/faceit/models/custom_types/faceit_uuid.py +++ b/src/faceit/models/custom_types/faceit_uuid.py @@ -1,51 +1,38 @@ from __future__ import annotations -import typing from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias, final from uuid import UUID from pydantic_core import core_schema -from typing_extensions import Self, TypeAlias +from typing_extensions import Self from faceit.types import EmptyString from faceit.utils import is_valid_uuid, representation -if typing.TYPE_CHECKING: +if TYPE_CHECKING: from pydantic import GetCoreSchemaHandler class _BaseFaceitUUIDValidator(ABC): __slots__ = () - _PREFIX: typing.ClassVar = "" - _SUFFIX: typing.ClassVar = "" + _PREFIX: ClassVar = "" + _SUFFIX: ClassVar = "" @classmethod @abstractmethod def _validate(cls, value: str, /) -> Self: raise NotImplementedError - @classmethod - def _remove_prefix_and_suffix(cls, value: str, /) -> str: - if not cls._PREFIX and not cls._SUFFIX: - return value - - start = ( - len(cls._PREFIX) if cls._PREFIX and value.startswith(cls._PREFIX) else None - ) - end = -len(cls._SUFFIX) if cls._SUFFIX and value.endswith(cls._SUFFIX) else None - - if start is None and end is None: - return value - - return value[start:end] - @classmethod def __get_pydantic_core_schema__( - cls, _: typing.Type[typing.Any], handler: GetCoreSchemaHandler + cls, _: type[Any], handler: GetCoreSchemaHandler ) -> core_schema.CoreSchema: return core_schema.no_info_after_validator_function( - lambda v: cls._validate(cls._remove_prefix_and_suffix(v)), + lambda v: cls._validate( + v.removeprefix(cls._PREFIX).removesuffix(cls._SUFFIX) + ), handler(str), serialization=core_schema.to_string_ser_schema(when_used="json"), ) @@ -59,7 +46,7 @@ class BaseFaceitID(_BaseFaceitUUIDValidator): _SUFFIX = "gui" -@typing.final +@final class FaceitID(UUID, BaseFaceitID): __slots__ = () @@ -71,17 +58,17 @@ def _validate(cls, value: str, /) -> Self: raise ValueError(msg) -MaybeFaceitID: TypeAlias = typing.Union[FaceitID, EmptyString] +MaybeFaceitID: TypeAlias = FaceitID | EmptyString @representation(use_str=True) class _FaceitIDWithUniquePrefix(str, BaseFaceitID, ABC): __slots__ = () - if typing.TYPE_CHECKING: - UNIQUE_PREFIX: typing.ClassVar[str] + if TYPE_CHECKING: + UNIQUE_PREFIX: ClassVar[str] - def __init_subclass__(cls, prefix: str, **kwargs: typing.Any) -> None: + def __init_subclass__(cls, prefix: str, **kwargs: Any) -> None: cls.UNIQUE_PREFIX = prefix super().__init_subclass__(**kwargs) @@ -90,17 +77,17 @@ def _validate(cls, value: str, /) -> Self: if not value.startswith(cls.UNIQUE_PREFIX): msg = f"Invalid {cls.__name__}: {value!r} must start with {cls.UNIQUE_PREFIX!r}" raise ValueError(msg) - if not is_valid_uuid(value[len(cls.UNIQUE_PREFIX) :]): + if not is_valid_uuid(value.removeprefix(cls.UNIQUE_PREFIX)): msg = f"Invalid {cls.__name__}: {value!r} contains invalid UUID part." raise ValueError(msg) return cls(value) -@typing.final +@final class FaceitTeamID(_FaceitIDWithUniquePrefix, prefix="team-"): __slots__ = () -@typing.final +@final class FaceitMatchID(_FaceitIDWithUniquePrefix, prefix="1-"): __slots__ = () diff --git a/src/faceit/models/item_page.py b/src/faceit/models/item_page.py index 721462b..03a37d8 100644 --- a/src/faceit/models/item_page.py +++ b/src/faceit/models/item_page.py @@ -1,8 +1,9 @@ from __future__ import annotations -import typing +from collections.abc import Callable, Iterable, Iterator from itertools import chain, starmap from random import choice as random_choice +from typing import Annotated, Any, Generic, SupportsIndex, final, overload from pydantic import ( BaseModel, @@ -12,7 +13,7 @@ computed_field, field_validator, ) -from typing_extensions import Annotated, Self, deprecated +from typing_extensions import Self from faceit.constants import RAW_RESPONSE_ITEMS_KEY from faceit.models.custom_types import TimestampMs # noqa: TC001 @@ -20,142 +21,126 @@ from faceit.utils import get_nested_property -@typing.final +@final class PaginationTimeRange(BaseModel, frozen=True): start: TimestampMs to: TimestampMs -@typing.final +@final class PaginationMetadata(BaseModel, frozen=True): offset: NonNegativeInt limit: PositiveInt - time_range: typing.Optional[PaginationTimeRange] + time_range: PaginationTimeRange | None -# fmt: off -@typing.final -class ItemPage(BaseModel, typing.Generic[_T], +@final +class ItemPage( + BaseModel, + Generic[_T], frozen=True, populate_by_name=True, ): - # fmt: on - items: typing.Tuple[_T, ...] + items: tuple[_T, ...] offset: Annotated[ - typing.Optional[NonNegativeInt], + NonNegativeInt | None, Field(None, alias="start", exclude=True), ] limit: Annotated[ - typing.Optional[PositiveInt], + PositiveInt | None, Field(None, alias="end", exclude=True), ] time_from: Annotated[ - typing.Optional[TimestampMs], + TimestampMs | None, Field(None, alias="from", exclude=True), ] """Unix time in milliseconds to start the range.""" time_to: Annotated[ - typing.Optional[TimestampMs], + TimestampMs | None, Field(None, alias="to", exclude=True), ] """Unix time in milliseconds to end the range.""" @property - def time_range(self) -> typing.Optional[PaginationTimeRange]: + def time_range(self) -> PaginationTimeRange | None: if self.time_from is None or self.time_to is None: return None return PaginationTimeRange(start=self.time_from, to=self.time_to) @computed_field # type: ignore[prop-decorator] @property - def metadata(self) -> typing.Optional[PaginationMetadata]: + def metadata(self) -> PaginationMetadata | None: if self.offset is None or self.limit is None: return None return PaginationMetadata( - offset=self.offset, - limit=self.limit, - time_range=self.time_range, + offset=self.offset, limit=self.limit, time_range=self.time_range ) - @computed_field(deprecated=True) # type: ignore[prop-decorator] - @property - # This property is redundant because all metadata is reset during page merging - # to avoid complex calculations that would likely be inaccurate anyway - @deprecated("`page` is deprecated and will be removed in a future version.") - def page(self) -> typing.Optional[int]: - return None if self.offset is None or self.limit is None else 1 - - @typing.overload - def find(self, attr: str, value: object) -> typing.Optional[_T]: ... + @overload + def find(self, attr: str, value: object) -> _T | None: ... - @typing.overload - def find(self, attr: str, value: object, default: _R) -> typing.Union[_T, _R]: ... + @overload + def find(self, attr: str, value: object, default: _R) -> _T | _R: ... def find( - self, attr: str, value: object, default: typing.Optional[_R] = None - ) -> typing.Union[_T, _R, None]: + self, attr: str, value: object, default: _R | None = None + ) -> _T | _R | None: return next(self._find_items(attr, value), default) def find_all(self, attr: str, value: object) -> ItemPage[_T]: return self.__class__._construct_without_metadata(self._find_items(attr, value)) - @typing.overload - def get_first(self) -> typing.Optional[_T]: ... + @overload + def get_first(self) -> _T | None: ... - @typing.overload - def get_first(self, default: _R, /) -> typing.Union[_T, _R]: ... + @overload + def get_first(self, default: _R, /) -> _T | _R: ... - def get_first( - self, default: typing.Optional[_R] = None - ) -> typing.Union[_T, _R, None]: + def get_first(self, default: _R | None = None) -> _T | _R | None: return self[0] if self else default - @typing.overload - def get_last(self) -> typing.Optional[_T]: ... + @overload + def get_last(self) -> _T | None: ... - @typing.overload - def get_last(self, default: _R, /) -> typing.Union[_T, _R]: ... + @overload + def get_last(self, default: _R, /) -> _T | _R: ... - def get_last( - self, default: typing.Optional[_R] = None - ) -> typing.Union[_T, _R, None]: + def get_last(self, default: _R | None = None) -> _T | _R | None: return self[-1] if self else default - @typing.overload - def get_random(self) -> typing.Optional[_T]: ... + @overload + def get_random(self) -> _T | None: ... - @typing.overload - def get_random(self, default: _R, /) -> typing.Union[_T, _R]: ... + @overload + def get_random(self, default: _R, /) -> _T | _R: ... - def get_random( - self, default: typing.Optional[_R] = None - ) -> typing.Union[_T, _R, None]: + def get_random(self, default: _R | None = None) -> _T | _R | None: # Intentionally using non-cryptographic RNG as this is for # convenience sampling rather than security-sensitive operations return random_choice(self) if self else default # noqa: S311 - def map(self, func: typing.Callable[[_T], _R], /) -> ItemPage[_R]: + def map(self, func: Callable[[_T], _R], /) -> ItemPage[_R]: return self.__class__._construct_without_metadata(map(func, self)) - def filter(self, predicate: typing.Callable[[_T], bool], /) -> ItemPage[_T]: + def filter(self, predicate: Callable[[_T], bool], /) -> ItemPage[_T]: return self.__class__._construct_without_metadata(filter(predicate, self)) - def _find_items(self, attr: str, value: object, /) -> typing.Iterator[_T]: + def _find_items(self, attr: str, value: object, /) -> Iterator[_T]: return (item for item in self if get_nested_property(item, attr) == value) @classmethod - def merge(cls, pages: typing.Iterable[ItemPage[_R]], /) -> ItemPage[_R]: + def merge(cls, pages: Iterable[ItemPage[_R]], /) -> ItemPage[_R]: return cls._construct_without_metadata(chain.from_iterable(pages)) @classmethod - def with_items(cls, new_items: typing.Iterable[_T], /) -> ItemPage[_T]: + def with_items(cls, new_items: Iterable[_T], /) -> ItemPage[_T]: return cls._construct_without_metadata(new_items) @classmethod def _construct_without_metadata( - cls, items: typing.Optional[typing.Iterable[_R]] = None, / + cls, items: Iterable[_R] | None = None, / ) -> ItemPage[_R]: # fmt: off return cls.model_construct( # type: ignore[return-value] @@ -165,7 +150,7 @@ def _construct_without_metadata( ) # fmt: on - def __iter__(self) -> typing.Iterator[_T]: # type: ignore[override] + def __iter__(self) -> Iterator[_T]: # type: ignore[override] yield from self.items def __len__(self) -> int: @@ -174,15 +159,13 @@ def __len__(self) -> int: def __reversed__(self) -> ItemPage[_T]: return self.__class__._construct_without_metadata(reversed(self.items)) - @typing.overload - def __getitem__(self, index: typing.SupportsIndex) -> _T: ... + @overload + def __getitem__(self, index: SupportsIndex) -> _T: ... - @typing.overload + @overload def __getitem__(self, index: slice) -> Self: ... - def __getitem__( - self, index: typing.Union[typing.SupportsIndex, slice] - ) -> typing.Union[_T, ItemPage[_T]]: + def __getitem__(self, index: SupportsIndex | slice) -> _T | ItemPage[_T]: if isinstance(index, slice): return self.__class__._construct_without_metadata(self.items[index]) try: @@ -205,14 +188,12 @@ def __bool__(self) -> bool: @field_validator(RAW_RESPONSE_ITEMS_KEY, mode="before") @classmethod - def _normalize_items( - cls, items: typing.Any - ) -> typing.Tuple[typing.Dict[str, typing.Any], ...]: - if not isinstance(items, typing.Iterable): + def _normalize_items(cls, items: Any) -> tuple[dict[str, Any], ...]: + if not isinstance(items, Iterable): msg = f"Expected {RAW_RESPONSE_ITEMS_KEY} to be an iterable, got {type(items).__name__}" raise ValueError(msg) # noqa: TRY004 - def normalize_item(i: int, item: typing.Any) -> typing.Dict[str, typing.Any]: + def normalize_item(i: int, item: Any) -> dict[str, Any]: if not isinstance(item, dict): msg = f"Element at index {i} must be a dictionary, got {type(item).__name__}" raise ValueError(msg) # noqa: TRY004 diff --git a/src/faceit/models/players/general.py b/src/faceit/models/players/general.py index d4280bd..1aa5b70 100644 --- a/src/faceit/models/players/general.py +++ b/src/faceit/models/players/general.py @@ -1,9 +1,18 @@ -import typing from datetime import datetime from enum import IntEnum +from typing import ( + Annotated, + Any, + ClassVar, + Final, + Generic, + TypeAlias, + TypeVar, + cast, + final, +) from pydantic import BaseModel, Field, model_validator -from typing_extensions import Annotated, TypeAlias from faceit.constants import ELO_THRESHOLDS, GameID, SkillLevel from faceit.models.custom_types import ( @@ -14,11 +23,11 @@ from faceit.models.custom_types.common import _INJECTED_KEY from faceit.types import AnyCSID, RawAPIItem, RegionIdentifier, UrlOrEmpty -_PlayerStatsT = typing.TypeVar("_PlayerStatsT", bound=GameID) -_SegmentStatsT = typing.TypeVar("_SegmentStatsT") -_LifetimeStatsT = typing.TypeVar("_LifetimeStatsT") +_PlayerStatsT = TypeVar("_PlayerStatsT", bound=GameID) +_SegmentStatsT = TypeVar("_SegmentStatsT") +_LifetimeStatsT = TypeVar("_LifetimeStatsT") -_SEGMENT_NAME: typing.Final = "label" +_SEGMENT_NAME: Final = "label" class MatchResult(IntEnum): @@ -26,22 +35,22 @@ class MatchResult(IntEnum): WIN = 1 -@typing.final +@final class GameInfo(BaseModel): - _SKILL_LVL: typing.ClassVar = "skill_level" + _SKILL_LVL: ClassVar = "skill_level" region: RegionIdentifier game_player_id: str - level: Annotated[typing.Union[int, SkillLevel], Field(alias=_SKILL_LVL)] + level: Annotated[int | SkillLevel, Field(alias=_SKILL_LVL)] elo: Annotated[int, Field(alias="faceit_elo")] game_player_name: str level_label: Annotated[str, Field("", alias="skill_level_label")] # Maybe outdated - regions: ResponseContainer[typing.Any] # Maybe outdated + regions: ResponseContainer[Any] # Maybe outdated game_profile_id: str @model_validator(mode="before") @classmethod - def _prepare_skill_level(cls, data: typing.Any) -> typing.Any: + def _prepare_skill_level(cls, data: Any) -> Any: if not isinstance(data, dict): return data @@ -69,35 +78,35 @@ def _prepare_skill_level(cls, data: typing.Any) -> typing.Any: return data -@typing.final +@final class PlayerSettings(BaseModel): language: str -@typing.final +@final class Player(BaseModel): id: Annotated[FaceitID, Field(alias="player_id")] nickname: str avatar: UrlOrEmpty country: str cover_image: UrlOrEmpty - platforms: typing.Optional[ResponseContainer[str]] + platforms: ResponseContainer[str] | None games: ResponseContainer[GameInfo] settings: PlayerSettings - friends_ids: typing.List[FaceitID] + friends_ids: list[FaceitID] new_steam_id: str steam_id_64: str steam_nickname: str - memberships: typing.List[str] + memberships: list[str] faceit_url: LangFormattedAnyHttpUrl membership_type: str cover_featured_image: UrlOrEmpty - infractions: ResponseContainer[typing.Any] # Maybe outdated + infractions: ResponseContainer[Any] # Maybe outdated verified: bool activated_at: datetime -@typing.final +@final class BanEntry(BaseModel): nickname: str type: str @@ -106,7 +115,7 @@ class BanEntry(BaseModel): user_id: FaceitID -@typing.final +@final class Hub(BaseModel): id: Annotated[FaceitID, Field(alias="hub_id")] name: str @@ -116,22 +125,22 @@ class Hub(BaseModel): faceit_url: LangFormattedAnyHttpUrl -@typing.final +@final class GeneralTeam(BaseModel): id: Annotated[FaceitID, Field(alias="team_id")] nickname: str name: str avatar: UrlOrEmpty = "" - cover_image: typing.Optional[str] = None + cover_image: str | None = None game: GameID type: Annotated[str, Field(alias="team_type")] - members: typing.Optional[typing.List[str]] = None + members: list[str] | None = None leader_id: Annotated[FaceitID, Field(alias="leader")] chat_room_id: str # To be honest, I'm not totally sure what the ID is faceit_url: LangFormattedAnyHttpUrl -@typing.final +@final class Tournament(BaseModel): id: Annotated[FaceitID, Field(alias="tournament_id")] name: str @@ -148,7 +157,7 @@ class Tournament(BaseModel): max_skill: int match_type: str organizer_id: str - whitelist_countries: typing.List[str] + whitelist_countries: list[str] membership_type: str number_of_players: int number_of_players_joined: int @@ -160,7 +169,7 @@ class Tournament(BaseModel): faceit_url: LangFormattedAnyHttpUrl -@typing.final +@final class CSLifetimeStats(BaseModel): # `GameID.CS2` & `GameID.CSGO` adr: Annotated[float, Field(0, alias="ADR")] average_headshots_percentage: Annotated[int, Field(alias="Average Headshots %")] @@ -177,7 +186,7 @@ class CSLifetimeStats(BaseModel): # `GameID.CS2` & `GameID.CSGO` longest_win_streak: Annotated[int, Field(alias="Longest Win Streak")] matches: Annotated[int, Field(alias="Matches")] recent_results: Annotated[ - typing.List[typing.Optional[MatchResult]], + list[MatchResult | None], Field(alias="Recent Results", max_length=5), ] sniper_kill_rate: Annotated[float, Field(0.0, alias="Sniper Kill Rate")] @@ -222,9 +231,8 @@ class CSLifetimeStats(BaseModel): # `GameID.CS2` & `GameID.CSGO` wins: Annotated[int, Field(alias="Wins")] -@typing.final +@final class CSMapStats(BaseModel): # `GameID.CS2` & `GameID.CSGO` - # TODO: Преобразование в проценты (*100) таких полей, как "v2_win_rate", "v1_win_rate", ... ? adr: Annotated[float, Field(0.0, alias="ADR")] assists: Annotated[int, Field(alias="Assists")] average_assists: Annotated[float, Field(alias="Average Assists")] @@ -298,8 +306,8 @@ class CSMapStats(BaseModel): # `GameID.CS2` & `GameID.CSGO` wins: Annotated[int, Field(alias="Wins")] -@typing.final -class Segment(BaseModel, typing.Generic[_SegmentStatsT]): +@final +class Segment(BaseModel, Generic[_SegmentStatsT]): stats: _SegmentStatsT type: str mode: str @@ -308,10 +316,10 @@ class Segment(BaseModel, typing.Generic[_SegmentStatsT]): img_regular: UrlOrEmpty -@typing.final +@final class PlayerStats( BaseModel, - typing.Generic[ + Generic[ _PlayerStatsT, _LifetimeStatsT, _SegmentStatsT, @@ -326,7 +334,7 @@ class PlayerStats( @model_validator(mode="before") @classmethod - def _prepare_segments(cls, data: typing.Any) -> typing.Any: + def _prepare_segments(cls, data: Any) -> Any: if not isinstance(data, dict): return data @@ -336,10 +344,7 @@ def _prepare_segments(cls, data: typing.Any) -> typing.Any: # NOTE: Anubis --> anubis, Ancient --> ancient, ... # (lowercase and replace spaces with underscores) seg[_SEGMENT_NAME].lower().replace(" ", "_"): seg - for seg in typing.cast( - "typing.List[typing.Dict[str, str]]", - raw_segments, - ) + for seg in cast("list[dict[str, str]]", raw_segments) if _SEGMENT_NAME in seg } @@ -358,7 +363,4 @@ def _prepare_segments(cls, data: typing.Any) -> typing.Any: RawAPIItem, ] -AnyPlayerStats: TypeAlias = typing.Union[ - CSPlayerStats, - FallbackPlayerStats, -] +AnyPlayerStats: TypeAlias = CSPlayerStats | FallbackPlayerStats diff --git a/src/faceit/models/players/match.py b/src/faceit/models/players/match.py index 1d4f726..1117a62 100644 --- a/src/faceit/models/players/match.py +++ b/src/faceit/models/players/match.py @@ -1,153 +1,152 @@ -import typing -from abc import ABC -from datetime import datetime - -from pydantic import BaseModel, Field, field_validator -from typing_extensions import Annotated - -from faceit.constants import GameID -from faceit.models.custom_types import ( - FaceitID, - FaceitMatchID, - LangFormattedAnyHttpUrl, - TimestampMs, - TimestampSec, -) -from faceit.types import RegionIdentifier, UrlOrEmpty -from faceit.utils import StrEnum - -_F1: typing.Final = "faction1" -_F2: typing.Final = "faction2" -_RESULT_MAP: typing.Final = { - _F1: "first", - _F2: "second", -} - - -class Opponent(StrEnum): - ABSENT = "bye" - - -@typing.final -class PlayerSummary(BaseModel): - id: Annotated[FaceitID, Field(alias="player_id")] - nickname: str - avatar: UrlOrEmpty - level: Annotated[int, Field(alias="skill_level")] - game_player_id: str - game_player_name: str - faceit_url: LangFormattedAnyHttpUrl - - -@typing.final -class Team(BaseModel): - id: Annotated[ - typing.Union[FaceitID, Opponent], - Field(alias="team_id"), - ] - name: Annotated[str, Field(alias="nickname")] - avatar: UrlOrEmpty - type: str - players: typing.List[PlayerSummary] - - -@typing.final -class Teams(BaseModel): - first: Annotated[Team, Field(alias=_F1)] - second: Annotated[Team, Field(alias=_F2)] - - -@typing.final -class Score(BaseModel): - first: Annotated[int, Field(alias=_F1)] - second: Annotated[int, Field(alias=_F2)] - - -@typing.final -class Results(BaseModel): - winner: typing.Literal["first", "second"] - score: Score - - @field_validator("winner", mode="before") - @classmethod - def convert_winner(cls, value: typing.Any) -> str: - if value in _RESULT_MAP: - return _RESULT_MAP[value] - msg = f"Invalid winner value: {value}" - raise ValueError(msg) - - -@typing.final -class Match(BaseModel): - id: Annotated[str, Field(alias="match_id")] - game_id: GameID - region: RegionIdentifier - type: Annotated[str, Field(alias="match_type")] - game_mode: str - max_players: int - teams_size: int - teams: Teams - playing_players: typing.List[FaceitID] - competition_id: FaceitID - competition_name: str - competition_type: str - organizer_id: str - status: str - started_at: TimestampSec - finished_at: TimestampSec - results: Results - faceit_url: LangFormattedAnyHttpUrl - - -class AbstractMatchPlayerStats(BaseModel, ABC): - """ - Abstract class for player match statistics models in the inheritance hierarchy. - - Serves as a common type for various game-specific player statistics models. - Useful for type annotations when the return type depends on the :attr:`~.game` parameter - provided by the user, allowing different ``MatchPlayerStats`` subclasses to be - returned based on the game context. - """ - - game: Annotated[GameID, Field(alias="Game")] - - -@typing.final -# Doesn't work for players who last played around Aug 2024 -# (when extended stats were added to the API) -# TODO: Need to add default values for all fields that may be missing -class CS2MatchPlayerStats(AbstractMatchPlayerStats): - id: Annotated[FaceitMatchID, Field(alias="Match Id")] - game_mode: Annotated[str, Field(alias="Game Mode")] - region: Annotated[RegionIdentifier, Field(alias="Region")] - kd_ratio: Annotated[float, Field(alias="K/D Ratio")] - winner: Annotated[typing.Optional[FaceitID], Field(None, alias="Winner")] - player_id: Annotated[FaceitID, Field(alias="Player Id")] - first_half_score: Annotated[int, Field(alias="First Half Score")] - triple_kills: Annotated[int, Field(alias="Triple Kills")] - assists: Annotated[int, Field(alias="Assists")] - final_score: Annotated[int, Field(alias="Final Score")] - penta_kills: Annotated[int, Field(alias="Penta Kills")] - finished_at: Annotated[TimestampMs, Field(alias="Match Finished At")] - map: Annotated[str, Field(alias="Map")] - overtime_score: Annotated[int, Field(alias="Overtime score")] - deaths: Annotated[int, Field(alias="Deaths")] - nickname: Annotated[str, Field(alias="Nickname")] - updated_at: Annotated[datetime, Field(alias="Updated At")] - second_half_score: Annotated[int, Field(alias="Second Half Score")] - team: Annotated[str, Field(alias="Team")] - mvps: Annotated[int, Field(alias="MVPs")] - headshots: Annotated[int, Field(alias="Headshots")] - kills: Annotated[int, Field(alias="Kills")] - result: Annotated[int, Field(alias="Result")] - rounds: Annotated[int, Field(alias="Rounds")] - match_round: Annotated[int, Field(alias="Match Round")] - created_at: Annotated[datetime, Field(alias="Created At")] - best_of: Annotated[int, Field(alias="Best Of")] - adr: Annotated[float, Field(0.0, alias="ADR")] - headshots_percentage: Annotated[float, Field(alias="Headshots %")] - competition_id: Annotated[FaceitID, Field(alias="Competition Id")] - score: Annotated[str, Field(alias="Score")] - quadro_kills: Annotated[int, Field(alias="Quadro Kills")] - kr_ratio: Annotated[float, Field(alias="K/R Ratio")] - double_kills: Annotated[int, Field(0, alias="Double Kills")] +from abc import ABC +from datetime import datetime +from typing import Annotated, Any, Final, Literal, final + +from pydantic import BaseModel, Field, field_validator + +from faceit.constants import GameID +from faceit.models.custom_types import ( + FaceitID, + FaceitMatchID, + LangFormattedAnyHttpUrl, + TimestampMs, + TimestampSec, +) +from faceit.types import RegionIdentifier, UrlOrEmpty +from faceit.utils import StrEnum + +_F1: Final = "faction1" +_F2: Final = "faction2" +_RESULT_MAP: Final = { + _F1: "first", + _F2: "second", +} + + +class Opponent(StrEnum): + ABSENT = "bye" + + +@final +class PlayerSummary(BaseModel): + id: Annotated[FaceitID, Field(alias="player_id")] + nickname: str + avatar: UrlOrEmpty + level: Annotated[int, Field(alias="skill_level")] + game_player_id: str + game_player_name: str + faceit_url: LangFormattedAnyHttpUrl + + +@final +class Team(BaseModel): + id: Annotated[ + FaceitID | Opponent, + Field(alias="team_id"), + ] + name: Annotated[str, Field(alias="nickname")] + avatar: UrlOrEmpty + type: str + players: list[PlayerSummary] + + +@final +class Teams(BaseModel): + first: Annotated[Team, Field(alias=_F1)] + second: Annotated[Team, Field(alias=_F2)] + + +@final +class Score(BaseModel): + first: Annotated[int, Field(alias=_F1)] + second: Annotated[int, Field(alias=_F2)] + + +@final +class Results(BaseModel): + winner: Literal["first", "second"] + score: Score + + @field_validator("winner", mode="before") + @classmethod + def convert_winner(cls, value: Any) -> str: + if value in _RESULT_MAP: + return _RESULT_MAP[value] + msg = f"Invalid winner value: {value}" + raise ValueError(msg) + + +@final +class Match(BaseModel): + id: Annotated[str, Field(alias="match_id")] + game_id: GameID + region: RegionIdentifier + type: Annotated[str, Field(alias="match_type")] + game_mode: str + max_players: int + teams_size: int + teams: Teams + playing_players: list[FaceitID] + competition_id: FaceitID + competition_name: str + competition_type: str + organizer_id: str + status: str + started_at: TimestampSec + finished_at: TimestampSec + results: Results + faceit_url: LangFormattedAnyHttpUrl + + +class AbstractMatchPlayerStats(BaseModel, ABC): + """ + Abstract class for player match statistics models in the inheritance hierarchy. + + Serves as a common type for various game-specific player statistics models. + Useful for type annotations when the return type depends on the :attr:`~.game` parameter + provided by the user, allowing different ``MatchPlayerStats`` subclasses to be + returned based on the game context. + """ + + game: Annotated[GameID, Field(alias="Game")] + + +@final +# Doesn't work for players who last played around Aug 2024 +# (when extended stats were added to the API) +# TODO: Need to add default values for all fields that may be missing +class CS2MatchPlayerStats(AbstractMatchPlayerStats): + id: Annotated[FaceitMatchID, Field(alias="Match Id")] + game_mode: Annotated[str, Field(alias="Game Mode")] + region: Annotated[RegionIdentifier, Field(alias="Region")] + kd_ratio: Annotated[float, Field(alias="K/D Ratio")] + winner: Annotated[FaceitID | None, Field(None, alias="Winner")] + player_id: Annotated[FaceitID, Field(alias="Player Id")] + first_half_score: Annotated[int, Field(alias="First Half Score")] + triple_kills: Annotated[int, Field(alias="Triple Kills")] + assists: Annotated[int, Field(alias="Assists")] + final_score: Annotated[int, Field(alias="Final Score")] + penta_kills: Annotated[int, Field(alias="Penta Kills")] + finished_at: Annotated[TimestampMs, Field(alias="Match Finished At")] + map: Annotated[str, Field(alias="Map")] + overtime_score: Annotated[int, Field(alias="Overtime score")] + deaths: Annotated[int, Field(alias="Deaths")] + nickname: Annotated[str, Field(alias="Nickname")] + updated_at: Annotated[datetime, Field(alias="Updated At")] + second_half_score: Annotated[int, Field(alias="Second Half Score")] + team: Annotated[str, Field(alias="Team")] + mvps: Annotated[int, Field(alias="MVPs")] + headshots: Annotated[int, Field(alias="Headshots")] + kills: Annotated[int, Field(alias="Kills")] + result: Annotated[int, Field(alias="Result")] + rounds: Annotated[int, Field(alias="Rounds")] + match_round: Annotated[int, Field(alias="Match Round")] + created_at: Annotated[datetime, Field(alias="Created At")] + best_of: Annotated[int, Field(alias="Best Of")] + adr: Annotated[float, Field(0.0, alias="ADR")] + headshots_percentage: Annotated[float, Field(alias="Headshots %")] + competition_id: Annotated[FaceitID, Field(alias="Competition Id")] + score: Annotated[str, Field(alias="Score")] + quadro_kills: Annotated[int, Field(alias="Quadro Kills")] + kr_ratio: Annotated[float, Field(alias="K/R Ratio")] + double_kills: Annotated[int, Field(0, alias="Double Kills")] diff --git a/src/faceit/types.py b/src/faceit/types.py index 7d39c4b..99075d1 100644 --- a/src/faceit/types.py +++ b/src/faceit/types.py @@ -1,78 +1,86 @@ -import typing +from collections.abc import Awaitable, Callable +from typing import ( + TYPE_CHECKING, + Any, + Literal, + NewType, + ParamSpec, + Protocol, + TypeAlias, + TypedDict, + TypeVar, +) from uuid import UUID from pydantic import AnyHttpUrl, BaseModel -from typing_extensions import NotRequired, ParamSpec, TypeAlias +from typing_extensions import NotRequired from .constants import GameID, Region -if typing.TYPE_CHECKING: +if TYPE_CHECKING: from .api import AsyncDataResource, SyncDataResource from .http import Endpoint from .http.client import BaseAPIClient - from .models.custom_types import TimestampMs -_T = typing.TypeVar("_T") -_T_co = typing.TypeVar("_T_co", covariant=True) -_R = typing.TypeVar("_R") +_T = TypeVar("_T") +_T_co = TypeVar("_T_co", covariant=True) +_R = TypeVar("_R") _P = ParamSpec("_P") -ModelT = typing.TypeVar("ModelT", bound="BaseModel") -ClientT = typing.TypeVar("ClientT", bound="BaseAPIClient[typing.Any, typing.Any]") -DataResourceT = typing.TypeVar( - "DataResourceT", bound=typing.Union["SyncDataResource", "AsyncDataResource"] -) +ModelT = TypeVar("ModelT", bound="BaseModel") +ClientT = TypeVar("ClientT", bound="BaseAPIClient[Any, Any]") +DataResourceT = TypeVar("DataResourceT", bound="SyncDataResource | AsyncDataResource") -APIResponseFormatT = typing.TypeVar("APIResponseFormatT", "Raw", "Model") -PaginationMethodT = typing.TypeVar( - "PaginationMethodT", bound="BaseResourceMethodProtocol[typing.Any]" +APIResponseFormatT = TypeVar("APIResponseFormatT", "Raw", "Model") +PaginationMethodT = TypeVar( + "PaginationMethodT", bound="BaseResourceMethodProtocol[Any]" ) -EmptyString: TypeAlias = typing.Literal[""] -UrlOrEmpty: TypeAlias = typing.Union[AnyHttpUrl, EmptyString] -UUIDOrEmpty: TypeAlias = typing.Union[UUID, EmptyString] -EndpointParam: TypeAlias = typing.Union[str, "Endpoint"] -ValidUUID: TypeAlias = typing.Union[UUID, str, bytes] +EmptyString: TypeAlias = Literal[""] +UrlOrEmpty: TypeAlias = AnyHttpUrl | EmptyString +UUIDOrEmpty: TypeAlias = UUID | EmptyString +EndpointParam: TypeAlias = "str | Endpoint" +ValidUUID: TypeAlias = UUID | str | bytes -AnyCSID: TypeAlias = typing.Literal[GameID.CS2, GameID.CSGO] +AnyCSID: TypeAlias = Literal[GameID.CS2, GameID.CSGO] -Raw = typing.NewType("Raw", bool) -Model = typing.NewType("Model", bool) +Raw = NewType("Raw", bool) +Model = NewType("Model", bool) # Placeholder type that signals developers to implement a proper model # for a resource method. Acts as a temporary stub during development. ModelNotImplemented: TypeAlias = BaseModel -RegionIdentifier: TypeAlias = typing.Union[Region, str] +RegionIdentifier: TypeAlias = Region | str -RawAPIItem: TypeAlias = typing.Dict[str, typing.Any] -RawAPIPageResponse = typing.TypedDict( +RawAPIItem: TypeAlias = dict[str, Any] +RawAPIPageResponse = TypedDict( "RawAPIPageResponse", { - "items": typing.List[RawAPIItem], + "items": list[RawAPIItem], # Required pagination parameters (cursor based) "start": int, "end": int, # Unix timestamps (in milliseconds) - "from": NotRequired["TimestampMs"], - "to": NotRequired["TimestampMs"], + "from": NotRequired[int], + "to": NotRequired[int], }, ) -RawAPIResponse: TypeAlias = typing.Union[RawAPIItem, RawAPIPageResponse] +RawAPIResponse: TypeAlias = RawAPIItem | RawAPIPageResponse -class BaseResourceMethodProtocol(typing.Protocol[_T]): +class BaseResourceMethodProtocol(Protocol[_T]): __name__: str - __call__: typing.Callable[..., _T] + __call__: Callable[..., _T] class SyncResourceMethodProtocol( BaseResourceMethodProtocol[_T], - typing.Protocol, + Protocol, ): ... class AsyncResourceMethodProtocol( - BaseResourceMethodProtocol[typing.Awaitable[_T]], - typing.Protocol, + BaseResourceMethodProtocol[Awaitable[_T]], + Protocol, ): ... diff --git a/src/faceit/utils.py b/src/faceit/utils.py index 24dc822..466ab16 100644 --- a/src/faceit/utils.py +++ b/src/faceit/utils.py @@ -4,39 +4,26 @@ import json import reprlib import sys -import typing from contextlib import suppress from enum import Enum, auto from functools import lru_cache, reduce, wraps from hashlib import sha256 from pathlib import Path +from typing import TYPE_CHECKING, Any, Final, TypeVar, cast, overload from uuid import UUID -from typing_extensions import Self, TypeIs, deprecated +from typing_extensions import Self, TypeIs -if typing.TYPE_CHECKING: +if TYPE_CHECKING: from asyncio import Lock as AsyncLock # noqa: ICN003 + from collections.abc import Awaitable, Callable, Iterable, Mapping from threading import Lock as SyncLock from types import FrameType from .types import _P, _T, ValidUUID - _CallableT = typing.TypeVar("_CallableT", bound=typing.Callable[..., typing.Any]) - _ClassT = typing.TypeVar("_ClassT", bound=type) - -_IGNORED_MODULES: typing.Final = { - "pydantic", -} -_UUID_BYTES: typing.Final = 16 -_UNINITIALIZED_MARKER: typing.Final = "uninitialized" - - -@deprecated( - "`UnsetValue` is deprecated and will be removed in a future release. " - "Please use `None` instead of `UnsetValue.UNSET`.", -) -class UnsetValue: - UNSET: typing.ClassVar[None] = None + _CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) + _ClassT = TypeVar("_ClassT", bound=type) # NOTE: Inspired by irgeek/StrEnum: @@ -46,10 +33,8 @@ class UnsetValue: class StrEnum(str, Enum): _value_: str - def __new__( - cls, value: typing.Union[str, auto], *args: object, **kwargs: object - ) -> Self: - if isinstance(value, (str, auto)): + def __new__(cls, value: str | auto, *args: object, **kwargs: object) -> Self: + if isinstance(value, str | auto): return super().__new__(cls, value, *args, **kwargs) msg = f"StrEnum values must be of type 'str', but got {type(value).__name__}: {value!r}" # type: ignore[unreachable] raise TypeError(msg) @@ -64,26 +49,26 @@ def __str__(self) -> str: class StrEnumWithAll(StrEnum): @classmethod - def get_all_values(cls) -> typing.Tuple[Self, ...]: + def get_all_values(cls) -> tuple[Self, ...]: return tuple(cls) def locked( - lock: typing.Union[SyncLock, AsyncLock], / -) -> typing.Callable[[typing.Callable[_P, _T]], typing.Callable[_P, _T]]: - def decorator(func: typing.Callable[_P, _T], /) -> typing.Callable[_P, _T]: + lock: SyncLock | AsyncLock, / +) -> Callable[[Callable[_P, _T]], Callable[_P, _T]]: + def decorator(func: Callable[_P, _T], /) -> Callable[_P, _T]: if inspect.iscoroutinefunction(func): @wraps(func) async def async_wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: - async with typing.cast("AsyncLock", lock): # Developer's responsibility - return typing.cast("_T", await func(*args, **kwargs)) + async with cast("AsyncLock", lock): # Developer's responsibility + return cast("_T", await func(*args, **kwargs)) - return typing.cast("typing.Callable[_P, _T]", async_wrapper) + return cast("Callable[_P, _T]", async_wrapper) @wraps(func) def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: - with typing.cast("SyncLock", lock): + with cast("SyncLock", lock): return func(*args, **kwargs) return wrapper @@ -91,22 +76,20 @@ def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T: return decorator -def extends( - _: _CallableT, / -) -> typing.Callable[[typing.Callable[..., object]], _CallableT]: +def extends(_: _CallableT, /) -> Callable[[Callable[..., object]], _CallableT]: """ Decorator that assigns the type signature of the given function to the decorated function. Type checking is enforced only at the function boundary (when calling the function), not within the function body. """ - return lambda x: typing.cast("_CallableT", x) + return lambda x: cast("_CallableT", x) async def invoke_callable( - func: typing.Callable[..., typing.Union[_T, typing.Awaitable[_T]]], + func: Callable[..., _T | Awaitable[_T]], /, - *args: typing.Any, - **kwargs: typing.Any, + *args: Any, + **kwargs: Any, ) -> _T: if not callable(func): msg = ( # type: ignore[unreachable] @@ -117,15 +100,15 @@ async def invoke_callable( result = func(*args, **kwargs) if inspect.isawaitable(result): result = await result - return typing.cast("_T", result) + return cast("_T", result) def deep_get( - dictionary: typing.Mapping[str, typing.Any], + dictionary: Mapping[str, Any], keys: str, /, - default: typing.Optional[_T] = None, -) -> typing.Union[_T, typing.Any, None]: + default: _T | None = None, +) -> _T | Any | None: current = dictionary try: for key in keys.split("."): @@ -136,8 +119,8 @@ def deep_get( def get_nested_property( - obj: typing.Any, path: str, /, default: typing.Optional[_T] = None -) -> typing.Union[_T, typing.Any, None]: + obj: Any, path: str, /, default: _T | None = None +) -> _T | Any | None: if obj is None or not path: return default try: @@ -148,7 +131,7 @@ def get_nested_property( return default -def get_hashable_representation(obj: typing.Any, /) -> int: +def get_hashable_representation(obj: Any, /) -> int: with suppress(TypeError): return hash(obj) try: @@ -158,11 +141,14 @@ def get_hashable_representation(obj: typing.Any, /) -> int: return int.from_bytes(sha256(obj_str.encode()).digest()[:8], "big", signed=True) -def deduplicate_unhashable(values: typing.Iterable[_T], /) -> typing.List[_T]: +def deduplicate_unhashable(values: Iterable[_T], /) -> list[_T]: return list({get_hashable_representation(v): v for v in values}.values()) -def to_uuid(value: typing.Union[str, bytes], /) -> UUID: +_UUID_BYTES: Final = 16 + + +def to_uuid(value: str | bytes, /) -> UUID: if isinstance(value, str): return UUID(value) if not isinstance(value, bytes): @@ -177,10 +163,10 @@ def to_uuid(value: typing.Union[str, bytes], /) -> UUID: raise ValueError(msg) from e -def is_valid_uuid(value: typing.Any, /) -> TypeIs[ValidUUID]: +def is_valid_uuid(value: Any, /) -> TypeIs[ValidUUID]: if isinstance(value, UUID): return True - if not isinstance(value, (str, bytes)): + if not isinstance(value, str | bytes): return False try: to_uuid(value) @@ -192,41 +178,46 @@ def is_valid_uuid(value: typing.Any, /) -> TypeIs[ValidUUID]: def create_uuid_validator( *, arg_name: str = "value", - error_message: typing.Optional[str] = None, -) -> typing.Callable[[typing.Any], str]: + error_message: str | None = None, +) -> Callable[[Any], str]: if error_message is None: error_message = "Invalid {arg_name}: {value}. Expected a valid UUID." - def validator(value: typing.Any, /) -> str: + def validator(value: Any, /) -> str: if is_valid_uuid(value): - return str(value if isinstance(value, (UUID, str)) else to_uuid(value)) + return str(value if isinstance(value, UUID | str) else to_uuid(value)) raise ValueError(error_message.format(arg_name=arg_name, value=value)) return validator -def validate_positive_int(value: typing.Any, /, param_name: str = "value") -> int: +def validate_positive_int(value: Any, /, param_name: str = "value") -> int: """ Utility for validating that a value is a positive integer. Use this when :class:`pydantic.PositiveInt` type or validation is impractical or unavailable. """ if not isinstance(value, int): - msg = f"'{param_name}' must be int, got {type(value).__name__}" + msg = f"{param_name!r} must be int, got {type(value).__name__}" raise TypeError(msg) if value <= 0: - msg = f"'{param_name}' must be a positive integer, got {value}" + msg = f"{param_name!r} must be a positive integer, got {value}" raise ValueError(msg) return value +_IGNORED_MODULES: Final = { + "pydantic", +} + + @lru_cache(maxsize=1) -def _get_ignored_paths() -> typing.Tuple[ - typing.Tuple[Path, ...], - typing.FrozenSet[Path], +def _get_ignored_paths() -> tuple[ + tuple[Path, ...], + frozenset[Path], ]: - prefixes: typing.List[Path] = [] - files: typing.Set[Path] = set() + prefixes: list[Path] = [] + files: set[Path] = set() for mod_name in (__name__.split(".")[0], *_IGNORED_MODULES): mod = sys.modules.get(mod_name) @@ -242,14 +233,14 @@ def _get_ignored_paths() -> typing.Tuple[ return tuple(prefixes), frozenset(files) -def warn_stacklevel() -> int: +def find_user_stacklevel() -> int: """ Determines the appropriate stack level for warnings emitted by the library, so that they point to the user's code instead of internal library frames. """ with suppress(ValueError, AttributeError): ignored_prefixes, ignored_files = _get_ignored_paths() - frame: typing.Optional[FrameType] = sys._getframe(1) + frame: FrameType | None = sys._getframe(1) level = 1 while frame: @@ -269,7 +260,10 @@ def warn_stacklevel() -> int: return 1 -def _format_fields(obj: object, fields: typing.Tuple[str, ...], *, joiner: str) -> str: +_UNINITIALIZED_MARKER: Final = "uninitialized" + + +def _format_fields(obj: object, fields: tuple[str, ...], *, joiner: str) -> str: return ( joiner.join(f"{field}={reprlib.repr(getattr(obj, field))}" for field in fields) if all(hasattr(obj, field) for field in fields) @@ -279,7 +273,7 @@ def _format_fields(obj: object, fields: typing.Tuple[str, ...], *, joiner: str) def _apply_representation( cls: _ClassT, - fields: typing.Tuple[str, ...], + fields: tuple[str, ...], use_str: bool, # noqa: FBT001 ) -> _ClassT: has_str = getattr(cls, "__str__", object.__str__) is not object.__str__ @@ -302,22 +296,22 @@ def build_str(self: _ClassT) -> str: return cls -@typing.overload +@overload def representation( cls: _ClassT, /, *fields: str, use_str: bool = ..., ) -> _ClassT: ... -@typing.overload +@overload def representation( *fields: str, use_str: bool = ..., -) -> typing.Callable[[_ClassT], _ClassT]: ... +) -> Callable[[_ClassT], _ClassT]: ... def representation( - *fields: typing.Any, + *fields: Any, use_str: bool = False, -) -> typing.Union[_ClassT, typing.Callable[[_ClassT], _ClassT]]: +) -> _ClassT | Callable[[_ClassT], _ClassT]: return ( _apply_representation(fields[0], fields[1:], use_str) if fields and inspect.isclass(fields[0]) diff --git a/tests/conftest.py b/tests/conftest.py index 9b4e219..67a53f3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,4 @@ -import typing +from typing import Any from unittest.mock import AsyncMock, Mock from uuid import uuid4 @@ -27,10 +27,10 @@ def mock_async_client() -> Mock: @pytest.fixture(scope="module") -def sync_players_raw(mock_sync_client: Mock) -> SyncPlayers[typing.Any]: +def sync_players_raw(mock_sync_client: Mock) -> SyncPlayers[Any]: return SyncPlayers(client=mock_sync_client, raw=True) @pytest.fixture(scope="module") -def async_players_raw(mock_async_client: Mock) -> AsyncPlayers[typing.Any]: +def async_players_raw(mock_async_client: Mock) -> AsyncPlayers[Any]: return AsyncPlayers(client=mock_async_client, raw=True) diff --git a/tests/test_custom_types.py b/tests/test_custom_types.py index 490b1bb..4b78356 100644 --- a/tests/test_custom_types.py +++ b/tests/test_custom_types.py @@ -44,91 +44,72 @@ def test_validate_success( class TestFaceitID: + @pytest.fixture(autouse=True) + def setup(self) -> None: + self.ta = TypeAdapter(FaceitID) + def test_valid_uuid(self, valid_uuid: str) -> None: - faceit_id = FaceitID._validate(valid_uuid) + faceit_id = self.ta.validate_python(valid_uuid) assert isinstance(faceit_id, FaceitID) assert str(faceit_id) == valid_uuid def test_invalid_uuid(self) -> None: - with pytest.raises(ValueError, match="Invalid FaceitID:"): - FaceitID._validate("not-a-uuid") - - with pytest.raises(AttributeError): - FaceitID(123) + with pytest.raises(ValidationError): + self.ta.validate_python("not-a-uuid") def test_suffix_handling(self, valid_uuid: str) -> None: suffixed_uuid = f"{valid_uuid}gui" - - with pytest.raises(ValueError, match="is not a valid UUID format"): - FaceitID._validate(suffixed_uuid) - - if suffixed_uuid.endswith("gui"): - cleaned_uuid = suffixed_uuid[:-3] - faceit_id_from_cleaned = FaceitID._validate(cleaned_uuid) - assert isinstance(faceit_id_from_cleaned, FaceitID) - assert str(faceit_id_from_cleaned) == valid_uuid + faceit_id = self.ta.validate_python(suffixed_uuid) + assert isinstance(faceit_id, FaceitID) + assert str(faceit_id) == valid_uuid class TestFaceitTeamID: + @pytest.fixture(autouse=True) + def setup(self) -> None: + self.ta = TypeAdapter(FaceitTeamID) + def test_valid_team_id(self, valid_uuid: str) -> None: valid_team_id = f"team-{valid_uuid}" - - team_id = FaceitTeamID._validate(valid_team_id) + team_id = self.ta.validate_python(valid_team_id) assert isinstance(team_id, FaceitTeamID) assert str(team_id) == valid_team_id def test_missing_prefix(self, valid_uuid: str) -> None: - with pytest.raises(ValueError, match="must start with 'team-'"): - FaceitTeamID._validate(valid_uuid) + with pytest.raises(ValidationError): + self.ta.validate_python(valid_uuid) def test_invalid_uuid_part(self) -> None: - with pytest.raises(ValueError, match="contains invalid UUID part"): - FaceitTeamID._validate("team-not-a-valid-uuid") + with pytest.raises(ValidationError): + self.ta.validate_python("team-not-a-valid-uuid") def test_suffix_handling(self, valid_uuid: str) -> None: - valid_team_id = f"team-{valid_uuid}" - suffixed_team_id = f"{valid_team_id}gui" - _ = FaceitTeamID._validate(valid_team_id) - - with pytest.raises(ValueError, match="contains invalid UUID part"): - FaceitTeamID._validate(suffixed_team_id) - - if suffixed_team_id.endswith("gui"): - cleaned_team_id = suffixed_team_id[:-3] - team_id_from_cleaned = FaceitTeamID._validate(cleaned_team_id) - assert isinstance(team_id_from_cleaned, FaceitTeamID) - assert str(team_id_from_cleaned) == valid_team_id + self.ta.validate_python(v := f"team-{valid_uuid}") + self.ta.validate_python(f"{v}gui") class TestFaceitMatchID: + @pytest.fixture(autouse=True) + def setup(self) -> None: + self.ta = TypeAdapter(FaceitMatchID) + def test_valid_match_id(self, valid_uuid: str) -> None: valid_match_id = f"1-{valid_uuid}" - - match_id = FaceitMatchID._validate(valid_match_id) + match_id = self.ta.validate_python(valid_match_id) assert isinstance(match_id, FaceitMatchID) assert str(match_id) == valid_match_id def test_missing_prefix(self, valid_uuid: str) -> None: - with pytest.raises(ValueError, match="must start with '1-'"): - FaceitMatchID._validate(valid_uuid) + with pytest.raises(ValidationError): + self.ta.validate_python(valid_uuid) def test_invalid_uuid_part(self) -> None: - with pytest.raises(ValueError, match="contains invalid UUID part"): - FaceitMatchID._validate("1-not-a-valid-uuid") + with pytest.raises(ValidationError): + self.ta.validate_python("1-not-a-valid-uuid") def test_suffix_handling(self, valid_uuid: str) -> None: - valid_match_id = f"1-{valid_uuid}" - suffixed_match_id = f"{valid_match_id}gui" - _ = FaceitMatchID._validate(valid_match_id) - - with pytest.raises(ValueError, match="contains invalid UUID part"): - FaceitMatchID._validate(suffixed_match_id) - - if suffixed_match_id.endswith("gui"): - cleaned_match_id = suffixed_match_id[:-3] - match_id_from_cleaned = FaceitMatchID._validate(cleaned_match_id) - assert isinstance(match_id_from_cleaned, FaceitMatchID) - assert str(match_id_from_cleaned) == valid_match_id + self.ta.validate_python(m := f"1-{valid_uuid}") + self.ta.validate_python(f"{m}gui") class TestPydanticIntegration: @@ -157,11 +138,6 @@ class TeamModel(BaseModel): assert isinstance(team.id, FaceitTeamID) assert str(team.id) == valid_team_id - suffixed_team_id = f"{valid_team_id}gui" - team = TeamModel(id=suffixed_team_id) - assert isinstance(team.id, FaceitTeamID) - assert str(team.id) == valid_team_id - with pytest.raises(ValidationError): TeamModel(id=valid_uuid) @@ -177,11 +153,6 @@ class MatchModel(BaseModel): assert isinstance(match.id, FaceitMatchID) assert str(match.id) == valid_match_id - suffixed_match_id = f"{valid_match_id}gui" - match = MatchModel(id=suffixed_match_id) - assert isinstance(match.id, FaceitMatchID) - assert str(match.id) == valid_match_id - with pytest.raises(ValidationError): MatchModel(id=valid_uuid) diff --git a/tests/test_e2e_data_resource.py b/tests/test_e2e_data_resource.py index ea15e1a..adec138 100644 --- a/tests/test_e2e_data_resource.py +++ b/tests/test_e2e_data_resource.py @@ -1,6 +1,6 @@ from __future__ import annotations -import typing +from typing import TYPE_CHECKING, Final try: import decouple @@ -14,7 +14,9 @@ from faceit.models.custom_types import TimestampSec from faceit.models.custom_types.faceit_uuid import FaceitMatchID -if typing.TYPE_CHECKING: +if TYPE_CHECKING: + from collections.abc import AsyncGenerator + from faceit.api import AsyncDataResource, SyncDataResource pytestmark = [ @@ -25,7 +27,7 @@ ), ] -DEFAULT_PLAYER: typing.Final = "zombyacoff" +DEFAULT_PLAYER: Final = "zombyacoff" @pytest.fixture(scope="module") @@ -39,7 +41,7 @@ def data() -> SyncDataResource: @pytest.fixture -async def async_data() -> typing.AsyncGenerator[AsyncDataResource, None]: +async def async_data() -> AsyncGenerator[AsyncDataResource, None]: async with faceit.AsyncDataResource() as client: yield client diff --git a/tests/test_http_client.py b/tests/test_http_client.py index 0b8d3e6..9121526 100644 --- a/tests/test_http_client.py +++ b/tests/test_http_client.py @@ -1,7 +1,8 @@ import asyncio import ssl -import typing +from collections.abc import Callable, Iterator from time import time +from typing import Any from unittest.mock import AsyncMock, Mock, patch import httpx @@ -21,9 +22,9 @@ def _create_response_mock( status_code: int = 200, - json_data: typing.Optional[typing.Dict[str, typing.Any]] = None, + json_data: dict[str, Any] | None = None, text: str = "", - raise_for_status: typing.Optional[Exception] = None, + raise_for_status: Exception | None = None, ) -> Mock: if json_data is None: json_data = {"data": "test_data"} @@ -86,10 +87,8 @@ def invalid_json_response() -> Mock: @pytest.fixture -def async_client_factory( - valid_uuid: str, -) -> typing.Iterator[typing.Callable[[], AsyncClient]]: - clients: typing.List[AsyncClient] = [] +def async_client_factory(valid_uuid: str) -> Iterator[Callable[[], AsyncClient]]: + clients: list[AsyncClient] = [] def _create_client() -> AsyncClient: with patch("httpx.AsyncClient") as mock_client: @@ -244,7 +243,7 @@ def test_get_post_methods( valid_uuid: str, client_method: str, endpoint: str, - call_kwargs: typing.Dict[str, typing.Any], + call_kwargs: dict[str, Any], expected_supported_method: SupportedMethod, ) -> None: with patch("httpx.Client") as mock_client: @@ -257,9 +256,7 @@ def test_get_post_methods( assert call_kwargs == {} or "json" in call_kwargs mock_request.assert_called_with( - expected_supported_method, - endpoint, - **call_kwargs, + expected_supported_method, endpoint, **call_kwargs ) client.close() @@ -296,15 +293,13 @@ def test_request_with_timeout(self, mock_client: Mock, valid_uuid: str) -> None: class TestAsyncClient: - async def test_init( - self, async_client_factory: typing.Callable[[], AsyncClient] - ) -> None: + async def test_init(self, async_client_factory: Callable[[], AsyncClient]) -> None: client = async_client_factory() assert isinstance(client, AsyncClient) await client.aclose() async def test_aclose( - self, async_client_factory: typing.Callable[[], AsyncClient] + self, async_client_factory: Callable[[], AsyncClient] ) -> None: client = async_client_factory() await client.aclose() @@ -321,10 +316,10 @@ async def test_aclose( async def test_get_post_methods( self, mock_request: Mock, - async_client_factory: typing.Callable[[], AsyncClient], + async_client_factory: Callable[[], AsyncClient], client_method: str, endpoint: str, - call_kwargs: typing.Dict[str, typing.Any], + call_kwargs: dict[str, Any], expected_supported_method: SupportedMethod, ) -> None: client = async_client_factory() @@ -332,9 +327,7 @@ async def test_get_post_methods( await getattr(client, client_method)(endpoint, **call_kwargs) mock_request.assert_called_with( - expected_supported_method, - endpoint, - **call_kwargs, + expected_supported_method, endpoint, **call_kwargs ) await client.aclose() @@ -415,7 +408,7 @@ def test_close_raises_error(self, valid_uuid: str) -> None: asyncio.run(client.aclose()) async def test_update_rate_limit( - self, async_client_factory: typing.Callable[[], AsyncClient] + self, async_client_factory: Callable[[], AsyncClient] ) -> None: client = async_client_factory() try: @@ -438,7 +431,7 @@ async def test_update_rate_limit( await client.aclose() async def test_configure_adaptive_limits( - self, async_client_factory: typing.Callable[[], AsyncClient] + self, async_client_factory: Callable[[], AsyncClient] ) -> None: client = async_client_factory() try: @@ -586,9 +579,11 @@ def test_retry_predicate( assert not retry_predicate(ValueError("Random error")) async def test_ssl_before_sleep(self, valid_uuid: str) -> None: - with patch("httpx.AsyncClient") as mock_client, patch( - "faceit.http.client._logger" - ) as mock_logger, patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + with ( + patch("httpx.AsyncClient") as mock_client, + patch("faceit.http.client._logger") as mock_logger, + patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep, + ): mock_instance = Mock() mock_instance.is_closed = False mock_instance.aclose = AsyncMock() @@ -617,6 +612,5 @@ async def test_ssl_before_sleep(self, valid_uuid: str) -> None: mock_sleep.assert_called_once_with(0.5) mock_logger.warning.assert_called_with( - "SSL connection error to %s", - "https://test.com/api", + "SSL connection error to %s", "https://test.com/api" ) diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 5887b44..210a4ca 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -1,7 +1,7 @@ from __future__ import annotations -import typing from dataclasses import dataclass +from typing import TYPE_CHECKING, Any from unittest.mock import patch import pytest @@ -21,17 +21,19 @@ from faceit.models import ItemPage from faceit.models.custom_types import TimestampMs -if typing.TYPE_CHECKING: +if TYPE_CHECKING: + from collections.abc import AsyncIterator + from faceit.types import RawAPIPageResponse class _DummyResource( - BaseResource[typing.Any], + BaseResource[Any], resource_path="players", ): __slots__ = ("_items",) - def __init__(self, items: typing.List[typing.Dict[str, typing.Any]]) -> None: + def __init__(self, items: list[dict[str, Any]]) -> None: super().__init__(client=None, raw=False) self._items = items @@ -57,8 +59,8 @@ def raw_method_with_unix( *, offset: int = Field(0, ge=0), limit: int = Field(2, ge=1, le=2), - start: typing.Optional[int] = None, # noqa: ARG002 - to: typing.Optional[int] = None, + start: int | None = None, # noqa: ARG002 + to: int | None = None, ) -> RawAPIPageResponse: filtered = ( self._items @@ -73,8 +75,8 @@ async def async_raw_method_with_unix( *, offset: int = Field(0, ge=0), limit: int = Field(2, ge=1, le=2), - start: typing.Optional[int] = None, - to: typing.Optional[int] = None, + start: int | None = None, + to: int | None = None, ) -> RawAPIPageResponse: return self.raw_method_with_unix(offset=offset, limit=limit, start=start, to=to) @@ -86,7 +88,7 @@ class _ModelItem: @pytest.fixture -def raw_items() -> typing.List[typing.Dict[str, typing.Any]]: +def raw_items() -> list[dict[str, Any]]: return [ {"id": "a", "finished_at": 300}, {"id": "b", "finished_at": 200}, @@ -96,13 +98,13 @@ def raw_items() -> typing.List[typing.Dict[str, typing.Any]]: @pytest.fixture def dummy_resource( - raw_items: typing.List[typing.Dict[str, typing.Any]], + raw_items: list[dict[str, Any]], ) -> _DummyResource: return _DummyResource(raw_items) @pytest.fixture -def raw_pages() -> typing.Tuple[RawAPIPageResponse, RawAPIPageResponse]: +def raw_pages() -> tuple[RawAPIPageResponse, RawAPIPageResponse]: return ( {"items": [{"id": "a"}, {"id": "b"}], "start": 0, "end": 2}, {"items": [{"id": "b"}, {"id": "c"}], "start": 2, "end": 4}, @@ -110,16 +112,14 @@ def raw_pages() -> typing.Tuple[RawAPIPageResponse, RawAPIPageResponse]: @pytest.fixture -def model_pages() -> typing.Tuple[ - ItemPage[typing.Dict[str, int]], ItemPage[typing.Dict[str, int]] -]: +def model_pages() -> tuple[ItemPage[dict[str, int]], ItemPage[dict[str, int]]]: return ( - ItemPage[typing.Dict[str, int]].model_validate({ + ItemPage[dict[str, int]].model_validate({ "items": [{"id": 1}, {"id": 2}], "start": 0, "end": 2, }), - ItemPage[typing.Dict[str, int]].model_validate({ + ItemPage[dict[str, int]].model_validate({ "items": [{"id": 2}, {"id": 3}], "start": 2, "end": 4, @@ -191,7 +191,7 @@ def test_extract_unix_timestamp_from_model_page() -> None: def test_sync_gather_from_iterator_raw_deduplicates( - raw_pages: typing.Tuple[RawAPIPageResponse, RawAPIPageResponse], + raw_pages: tuple[RawAPIPageResponse, RawAPIPageResponse], ) -> None: result = SyncPageIterator.gather_from_iterator( iter(raw_pages), CollectReturnFormat.FIRST, deduplicate=True @@ -200,9 +200,7 @@ def test_sync_gather_from_iterator_raw_deduplicates( def test_sync_gather_from_iterator_model_merges_pages( - model_pages: typing.Tuple[ - ItemPage[typing.Dict[str, int]], ItemPage[typing.Dict[str, int]] - ], + model_pages: tuple[ItemPage[dict[str, int]], ItemPage[dict[str, int]]], ) -> None: result = SyncPageIterator.gather_from_iterator( iter(model_pages), CollectReturnFormat.MODEL, deduplicate=True @@ -231,9 +229,13 @@ def test_sync_iterator_strips_user_pagination_params_with_warning( def test_sync_unix_iterator_with_invalid_config_raises( dummy_resource: _DummyResource, ) -> None: - iterator = SyncPageIterator.unix(dummy_resource.raw_method, cfg={"key": "only-key"}) with pytest.raises(ValueError): - next(iterator) + next( + SyncPageIterator.unix( + dummy_resource.raw_method, + cfg={"key": "only-key"}, + ) + ) def test_sync_unix_iterator_yields_pages(dummy_resource: _DummyResource) -> None: @@ -251,15 +253,13 @@ def test_sync_iterator_collect_respects_safe_max_items( dummy_resource: _DummyResource, ) -> None: with patch.object(SyncPageIterator, "SAFE_MAX_PAGES", 1): - result = SyncPageIterator( - dummy_resource.raw_method, - max_items=MaxItems.SAFE, - ).collect(deduplicate=False) + iterator = SyncPageIterator(dummy_resource.raw_method, max_items=MaxItems.SAFE) + result = iterator.collect(deduplicate=False) assert len(result) == 2 async def test_async_gather_from_iterator_raw() -> None: - async def source() -> typing.AsyncIterator[RawAPIPageResponse]: # noqa: RUF029 + async def source() -> AsyncIterator[RawAPIPageResponse]: # noqa: RUF029 yield {"items": [{"id": 1}], "start": 0, "end": 1} yield {"items": [{"id": 2}], "start": 1, "end": 2} @@ -285,8 +285,9 @@ async def test_async_unix_iterator_yields_pages(dummy_resource: _DummyResource) async def test_async_unix_iterator_with_invalid_config_raises( dummy_resource: _DummyResource, ) -> None: - iterator = AsyncPageIterator.unix( - dummy_resource.async_raw_method, cfg={"attr": "finished_at"} - ) with pytest.raises(ValueError): - await iterator.__anext__() + await anext( + AsyncPageIterator.unix( + dummy_resource.async_raw_method, cfg={"attr": "finished_at"} + ) + ) diff --git a/tests/test_players_resource.py b/tests/test_players_resource.py index 5e8cbbd..3a126bd 100644 --- a/tests/test_players_resource.py +++ b/tests/test_players_resource.py @@ -1,6 +1,6 @@ from __future__ import annotations -import typing +from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, Mock, patch import pytest @@ -8,7 +8,9 @@ from faceit.api import AsyncPageIterator, SyncPageIterator from faceit.constants import GameID -if typing.TYPE_CHECKING: +if TYPE_CHECKING: + from collections.abc import AsyncIterator + from faceit.api.data.players import AsyncPlayers, SyncPlayers @@ -64,11 +66,12 @@ def test_sync_get_calls_client_with_expect_item( def test_sync_all_matches_stats_delegates_to_pagination_with_unix_cfg( sync_players_raw: SyncPlayers, valid_uuid: str ) -> None: - with patch.object( - SyncPageIterator, "unix", return_value=iter([]) - ) as mock_unix, patch.object( - SyncPageIterator, "gather_from_iterator", return_value=[{"id": "m1"}] - ) as mock_gather: + with ( + patch.object(SyncPageIterator, "unix", return_value=iter([])) as mock_unix, + patch.object( + SyncPageIterator, "gather_from_iterator", return_value=[{"id": "m1"}] + ) as mock_gather, + ): result = sync_players_raw.all_matches_stats(valid_uuid, GameID.CS2) assert result == [{"id": "m1"}] @@ -97,19 +100,22 @@ async def test_async_get_calls_client_with_expect_item( async def test_async_all_history_delegates_to_pagination_with_unix_cfg( async_players_raw: AsyncPlayers, valid_uuid: str ) -> None: - async def empty_async_iter() -> typing.AsyncIterator[typing.Any]: # noqa: RUF029 + async def empty_async_iter() -> AsyncIterator[Any]: # noqa: RUF029 if False: yield mock_iterator = empty_async_iter() - with patch.object( - AsyncPageIterator, "unix", return_value=mock_iterator - ) as mock_unix, patch.object( - AsyncPageIterator, - "gather_from_iterator", - new=AsyncMock(return_value=[{"id": "h1"}]), - ) as mock_gather: + with ( + patch.object( + AsyncPageIterator, "unix", return_value=mock_iterator + ) as mock_unix, + patch.object( + AsyncPageIterator, + "gather_from_iterator", + new=AsyncMock(return_value=[{"id": "h1"}]), + ) as mock_gather, + ): result = await async_players_raw.all_history(valid_uuid, GameID.CS2) assert result == [{"id": "h1"}] diff --git a/tests/test_repr.py b/tests/test_repr.py index 7860223..5bd5994 100644 --- a/tests/test_repr.py +++ b/tests/test_repr.py @@ -1,23 +1,16 @@ -import typing from dataclasses import dataclass +from typing import Final import pytest from faceit.utils import _UNINITIALIZED_MARKER, representation -DEFINE_STR_ERROR_MSG: typing.Final = "must define '__str__' method" +DEFINE_STR_ERROR_MSG: Final = "must define '__str__' method" -@pytest.fixture(scope="module") -def dataclass_no_repr() -> typing.Callable[..., typing.Any]: - return dataclass(repr=False) - - -def test_basic_representation( - dataclass_no_repr: typing.Callable[..., typing.Any], -) -> None: +def test_basic_representation() -> None: @representation("name", "age") - @dataclass_no_repr + @dataclass(repr=False) class Person: name: str age: int @@ -27,11 +20,9 @@ class Person: assert str(person) == "name='John' age=30" -def test_representation_with_use_str( - dataclass_no_repr: typing.Callable[..., typing.Any], -) -> None: +def test_representation_with_use_str() -> None: @representation("name", "age", use_str=True) - @dataclass_no_repr + @dataclass(repr=False) class Person: name: str age: int @@ -44,11 +35,9 @@ def __str__(self) -> str: assert str(person) == "John (30)" -def test_representation_with_missing_fields( - dataclass_no_repr: typing.Callable[..., typing.Any], -) -> None: +def test_representation_with_missing_fields() -> None: @representation("name", "age", "missing_field") - @dataclass_no_repr + @dataclass(repr=False) class Person: name: str age: int @@ -62,11 +51,9 @@ def test_representation_with_use_str_but_no_str_method() -> None: representation("name", use_str=True)(object) -def test_representation_preserves_existing_str( - dataclass_no_repr: typing.Callable[..., typing.Any], -) -> None: +def test_representation_preserves_existing_str() -> None: @representation("name", "age") - @dataclass_no_repr + @dataclass(repr=False) class Person: name: str age: int diff --git a/tests/test_resources_integration.py b/tests/test_resources_integration.py index ef0edc1..5c8e655 100644 --- a/tests/test_resources_integration.py +++ b/tests/test_resources_integration.py @@ -1,4 +1,4 @@ -import typing +from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, Mock, patch import pytest @@ -7,7 +7,7 @@ @pytest.fixture -def mock_sync_data(valid_uuid: str) -> typing.Generator[SyncDataResource, None, None]: +def mock_sync_data(valid_uuid: str) -> Generator[SyncDataResource, None, None]: with patch("httpx.Client") as mock_httpx: mock_instance = mock_httpx.return_value mock_instance.is_closed = False @@ -21,8 +21,8 @@ def mock_sync_data(valid_uuid: str) -> typing.Generator[SyncDataResource, None, @pytest.fixture -def mock_async_data(valid_uuid: str) -> typing.AsyncGenerator[AsyncDataResource, None]: - async def _mock_async() -> typing.AsyncGenerator[AsyncDataResource, None]: # noqa: RUF029 +def mock_async_data(valid_uuid: str) -> AsyncGenerator[AsyncDataResource, None]: + async def _mock_async() -> AsyncGenerator[AsyncDataResource, None]: # noqa: RUF029 with patch("httpx.AsyncClient") as mock_httpx: mock_instance = mock_httpx.return_value mock_instance.is_closed = False @@ -76,7 +76,7 @@ def test_teams_get(mock_sync_data: SyncDataResource, valid_uuid: str) -> None: async def test_async_games_items( - mock_async_data: typing.AsyncGenerator[AsyncDataResource, None], + mock_async_data: AsyncGenerator[AsyncDataResource, None], ) -> None: async for data in mock_async_data: await data.raw_games.items(offset=5) diff --git a/tests/test_warn_stacklevel.py b/tests/test_warn_stacklevel.py index 5caa375..c5dd649 100644 --- a/tests/test_warn_stacklevel.py +++ b/tests/test_warn_stacklevel.py @@ -4,7 +4,7 @@ import pytest -from faceit.utils import _get_ignored_paths, warn_stacklevel +from faceit.utils import _get_ignored_paths, find_user_stacklevel @pytest.fixture(autouse=True) @@ -18,8 +18,9 @@ def test_get_ignored_paths_init_py(self) -> None: path_str = Path("libs") / "package" / "__init__.py" mock_mod.__file__ = str(path_str.resolve()) - with patch.dict("sys.modules", {"test_pkg": mock_mod}), patch( - "faceit.utils._IGNORED_MODULES", {"test_pkg"} + with ( + patch.dict("sys.modules", {"test_pkg": mock_mod}), + patch("faceit.utils._IGNORED_MODULES", {"test_pkg"}), ): prefixes, _ = _get_ignored_paths() @@ -32,8 +33,9 @@ def test_get_ignored_paths_single_file(self) -> None: fake_path = Path("external").resolve() / "mod.py" mock_mod.__file__ = str(fake_path) - with patch.dict("sys.modules", {"external_mod": mock_mod}), patch( - "faceit.utils._IGNORED_MODULES", {"external_mod"} + with ( + patch.dict("sys.modules", {"external_mod": mock_mod}), + patch("faceit.utils._IGNORED_MODULES", {"external_mod"}), ): _, files = _get_ignored_paths() @@ -53,15 +55,16 @@ def test_warn_stacklevel_finds_external_frame(self) -> None: frame_int.f_code.co_filename = str(ignored_path) frame_int.f_back = frame_ext - with patch("faceit.utils._get_ignored_paths") as mock_paths, patch( - "sys._getframe", return_value=frame_int + with ( + patch("faceit.utils._get_ignored_paths") as mock_paths, + patch("sys._getframe", return_value=frame_int), ): mock_paths.return_value = ((), frozenset([ignored_path])) - assert warn_stacklevel() == 2 + assert find_user_stacklevel() == 2 def test_warn_stacklevel_fallback(self) -> None: with patch("sys._getframe", side_effect=ValueError): - assert warn_stacklevel() == 1 + assert find_user_stacklevel() == 1 def test_warn_stacklevel_skips_internal_python_calls(self) -> None: internal_frame = MagicMock(spec=FrameType) @@ -69,15 +72,15 @@ def test_warn_stacklevel_skips_internal_python_calls(self) -> None: internal_frame.f_back = None with patch("sys._getframe", return_value=internal_frame): - assert warn_stacklevel() == 1 + assert find_user_stacklevel() == 1 def test_integration_stack_navigation() -> None: def wrapper() -> int: - return warn_stacklevel() + return find_user_stacklevel() current_file = Path(__file__).resolve() with patch( - "faceit.utils._get_ignored_paths", return_value=((), frozenset([current_file])) + "faceit.utils._get_ignored_paths", return_value=((), frozenset((current_file,))) ): assert wrapper() > 1