Skip to content

Commit

Permalink
feat: enhanced async client usage (#187)
Browse files Browse the repository at this point in the history
* feat: enhanced async client usage

* fix: ruff code cleaning and test update
  • Loading branch information
TeKrop authored Sep 4, 2024
1 parent 051aaee commit 94a0299
Show file tree
Hide file tree
Showing 33 changed files with 364 additions and 342 deletions.
11 changes: 9 additions & 2 deletions app/commands/check_and_update_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@

import asyncio

import httpx
from fastapi import HTTPException

from app.common.cache_manager import CacheManager
from app.common.exceptions import ParserBlizzardError, ParserParsingError
from app.common.helpers import overfast_internal_error
from app.common.helpers import overfast_client_settings, overfast_internal_error
from app.common.logging import logger
from app.config import settings
from app.parsers.gamemodes_parser import GamemodesParser
Expand Down Expand Up @@ -106,14 +107,20 @@ async def main():
keys_to_update = get_soon_expired_cache_keys()
logger.info("Done ! Retrieved keys : {}", len(keys_to_update))

# Instanciate one HTTPX Client to use for all the updates
client = httpx.AsyncClient(**overfast_client_settings)

tasks = []
for key in keys_to_update:
parser_class, kwargs = get_request_parser_class(key)
parser = parser_class(**kwargs)
parser = parser_class(client=client, **kwargs)
tasks.append(retrieve_data(key, parser))

await asyncio.gather(*tasks)

# Properly close HTTPX Async Client
await client.aclose()

logger.info("Redis cache update finished !")


Expand Down
21 changes: 14 additions & 7 deletions app/commands/check_new_hero.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,22 @@

import asyncio

import httpx
from fastapi import HTTPException

from app.common.enums import HeroKey
from app.common.helpers import send_discord_webhook_message
from app.common.helpers import overfast_client_settings, send_discord_webhook_message
from app.common.logging import logger
from app.config import settings
from app.parsers.heroes_parser import HeroesParser


def get_distant_hero_keys() -> set[str]:
async def get_distant_hero_keys(client: httpx.AsyncClient) -> set[str]:
"""Get a set of Overwatch hero keys from the Blizzard heroes page"""
heroes_parser = HeroesParser()
heroes_parser = HeroesParser(client=client)

try:
asyncio.run(heroes_parser.retrieve_and_parse_data())
await heroes_parser.retrieve_and_parse_data()
except HTTPException as error:
raise SystemExit from error

Expand All @@ -31,17 +32,23 @@ def get_local_hero_keys() -> set[str]:
return {h.value for h in HeroKey}


def main():
async def main():
"""Main method of the script"""
logger.info("Checking if a Discord webhook is configured...")
if not settings.discord_webhook_enabled:
logger.info("No Discord webhook configured ! Exiting...")
raise SystemExit

logger.info("OK ! Starting to check if a new hero is here...")
distant_hero_keys = get_distant_hero_keys()

# Instanciate one HTTPX Client to use for all the updates
client = httpx.AsyncClient(**overfast_client_settings)

distant_hero_keys = await get_distant_hero_keys(client)
local_hero_keys = get_local_hero_keys()

await client.aclose()

# Compare both sets. If we have a difference, notify the developer
new_hero_keys = distant_hero_keys - local_hero_keys
if len(new_hero_keys) > 0:
Expand All @@ -56,4 +63,4 @@ def main():

if __name__ == "__main__": # pragma: no cover
logger = logger.patch(lambda record: record.update(name="check_new_hero"))
main()
asyncio.run(main())
7 changes: 2 additions & 5 deletions app/common/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,12 @@
"follow_redirects": True,
}

# Instanciate global httpx client
overfast_client = httpx.AsyncClient(**overfast_client_settings)


async def overfast_request(url: str) -> httpx.Response:
async def overfast_request(client: httpx.AsyncClient, url: str) -> httpx.Response:
"""Make an HTTP GET request with custom headers and retrieve the result"""
try:
logger.debug("Requesting {}...", url)
response = await overfast_client.get(url)
response = await client.get(url)
except httpx.TimeoutException as error:
raise blizzard_response_error(
status_code=0,
Expand Down
3 changes: 2 additions & 1 deletion app/handlers/api_request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class APIRequestHandler(ABC):

def __init__(self, request: Request):
self.cache_key = CacheManager.get_cache_key_from_request(request)
self.overfast_client = request.app.overfast_client

@property
@abstractmethod
Expand Down Expand Up @@ -51,7 +52,7 @@ async def process_request(self, **kwargs) -> dict:
# Instanciate the parser, it will check if a Parser Cache is here.
# If not, it will retrieve its associated Blizzard
# page and use the kwargs to generate the appropriate URL
parser = parser_class(**kwargs)
parser = parser_class(client=self.overfast_client, **kwargs)

# Do the parsing. Internally, it will check for Parser Cache
# before doing a real parsing using BeautifulSoup
Expand Down
17 changes: 13 additions & 4 deletions app/handlers/search_players_request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class SearchPlayersRequestHandler:

def __init__(self, request: Request):
self.cache_key = CacheManager.get_cache_key_from_request(request)
self.overfast_client = request.app.overfast_client

async def process_request(self, **kwargs) -> dict:
"""Main method used to process the request from user and return final data.
Expand All @@ -42,7 +43,9 @@ async def process_request(self, **kwargs) -> dict:
"""

# Request the data from Blizzard URL
req = await overfast_request(self.get_blizzard_url(**kwargs))
req = await overfast_request(
client=self.overfast_client, url=self.get_blizzard_url(**kwargs)
)
if req.status_code != status.HTTP_200_OK:
raise blizzard_response_error_from_request(req)

Expand Down Expand Up @@ -110,11 +113,17 @@ def get_blizzard_url(**kwargs) -> str:
return f"{settings.blizzard_host}/{locale}{settings.search_account_path}/{kwargs.get('name')}/"

def get_avatar_url(self, player: dict, player_id: str) -> str | None:
return PortraitParser(player_id=player_id).retrieve_data_value(player)
return PortraitParser(
client=self.overfast_client, player_id=player_id
).retrieve_data_value(player)

def get_namecard_url(self, player: dict, player_id: str) -> str | None:
return NamecardParser(player_id=player_id).retrieve_data_value(player)
return NamecardParser(
client=self.overfast_client, player_id=player_id
).retrieve_data_value(player)

def get_title(self, player: dict, player_id: str) -> str | None:
title = TitleParser(player_id=player_id).retrieve_data_value(player)
title = TitleParser(
client=self.overfast_client, player_id=player_id
).retrieve_data_value(player)
return get_player_title(title)
11 changes: 10 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from contextlib import asynccontextmanager, suppress

import httpx
from fastapi import FastAPI, Request
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.openapi.utils import get_openapi
Expand All @@ -11,21 +12,29 @@

from .commands.update_search_data_cache import update_search_data_cache
from .common.enums import RouteTag
from .common.helpers import overfast_client_settings
from .common.logging import logger
from .config import settings
from .routers import gamemodes, heroes, maps, players, roles


@asynccontextmanager
async def lifespan(_: FastAPI): # pragma: no cover
async def lifespan(app: FastAPI): # pragma: no cover
# Update search data list from Blizzard before starting up
if settings.redis_caching_enabled:
logger.info("Updating search data cache (avatars, namecards, titles)")
with suppress(SystemExit):
update_search_data_cache()

# Instanciate HTTPX Async Client
logger.info("Instanciating HTTPX AsyncClient...")
app.overfast_client = httpx.AsyncClient(**overfast_client_settings)

yield

# Properly close HTTPX Async Client
await app.overfast_client.aclose()


app = FastAPI(title="OverFast API", docs_url=None, redoc_url=None, lifespan=lifespan)
description = f"""OverFast API provides comprehensive data on Overwatch 2 heroes,
Expand Down
2 changes: 1 addition & 1 deletion app/parsers/generics/abstract_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class AbstractParser(ABC):
cache_manager = CacheManager()

def __init__(self, **_):
self.data: dict | list = None
self.data: dict | list | None = None

@property
@abstractmethod
Expand Down
6 changes: 4 additions & 2 deletions app/parsers/generics/api_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from functools import cached_property
from typing import ClassVar

import httpx
from bs4 import BeautifulSoup
from fastapi import status

Expand All @@ -23,8 +24,9 @@ class APIParser(AbstractParser):
# List of valid HTTP codes when retrieving Blizzard pages
valid_http_codes: ClassVar[list] = [status.HTTP_200_OK]

def __init__(self, **kwargs):
def __init__(self, client: httpx.AsyncClient, **kwargs):
self.blizzard_url = self.get_blizzard_url(**kwargs)
self.overfast_client = client
super().__init__(**kwargs)

@property
Expand All @@ -51,7 +53,7 @@ async def retrieve_and_parse_data(self) -> None:
"""Method used to retrieve data from Blizzard (HTML data), parsing it
and storing it into self.data attribute.
"""
req = await overfast_request(self.blizzard_url)
req = await overfast_request(client=self.overfast_client, url=self.blizzard_url)
if req.status_code not in self.valid_http_codes:
raise blizzard_response_error_from_request(req)

Expand Down
2 changes: 1 addition & 1 deletion app/parsers/search_data_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ async def retrieve_and_parse_data(self) -> None:
"""Method used to retrieve data from Blizzard (JSON data), parsing it
and storing it into self.data attribute.
"""
req = await overfast_request(self.blizzard_url)
req = await overfast_request(client=self.overfast_client, url=self.blizzard_url)
if req.status_code not in self.valid_http_codes:
raise blizzard_response_error_from_request(req)

Expand Down
19 changes: 13 additions & 6 deletions app/routers/players.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Players endpoints router : players search, players career, statistics, etc."""

from typing import Annotated

from fastapi import APIRouter, Depends, Path, Query, Request, status

from app.common.decorators import validation_error_handler
Expand Down Expand Up @@ -51,8 +53,11 @@ async def get_player_common_parameters(
return {"player_id": player_id}


CommonsPlayerDep = Annotated[dict, Depends(get_player_common_parameters)]


async def get_player_career_common_parameters(
commons: dict = Depends(get_player_common_parameters),
commons: CommonsPlayerDep,
gamemode: PlayerGamemode = Query(
...,
title="Gamemode",
Expand Down Expand Up @@ -86,6 +91,8 @@ async def get_player_career_common_parameters(
}


CommonsPlayerCareerDep = Annotated[dict, Depends(get_player_career_common_parameters)]

router = APIRouter()


Expand Down Expand Up @@ -138,7 +145,7 @@ async def search_players(
@validation_error_handler(response_model=PlayerSummary)
async def get_player_summary(
request: Request,
commons: dict = Depends(get_player_common_parameters),
commons: CommonsPlayerDep,
) -> PlayerSummary:
return await GetPlayerCareerRequestHandler(request).process_request(
summary=True,
Expand Down Expand Up @@ -167,7 +174,7 @@ async def get_player_summary(
@validation_error_handler(response_model=PlayerStatsSummary)
async def get_player_stats_summary(
request: Request,
commons: dict = Depends(get_player_common_parameters),
commons: CommonsPlayerDep,
gamemode: PlayerGamemode = Query(
None,
title="Gamemode",
Expand Down Expand Up @@ -212,7 +219,7 @@ async def get_player_stats_summary(
@validation_error_handler(response_model=PlayerCareerStats)
async def get_player_career_stats(
request: Request,
commons: dict = Depends(get_player_career_common_parameters),
commons: CommonsPlayerCareerDep,
) -> PlayerCareerStats:
return await GetPlayerCareerStatsRequestHandler(request).process_request(
stats=True,
Expand All @@ -236,7 +243,7 @@ async def get_player_career_stats(
@validation_error_handler(response_model=CareerStats)
async def get_player_stats(
request: Request,
commons: dict = Depends(get_player_career_common_parameters),
commons: CommonsPlayerCareerDep,
) -> CareerStats:
return await GetPlayerCareerRequestHandler(request).process_request(
stats=True,
Expand All @@ -257,7 +264,7 @@ async def get_player_stats(
@validation_error_handler(response_model=Player)
async def get_player_career(
request: Request,
commons: dict = Depends(get_player_common_parameters),
commons: CommonsPlayerDep,
) -> Player:
return await GetPlayerCareerRequestHandler(request).process_request(
player_id=commons.get("player_id"),
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "overfast-api"
version = "2.35.2"
version = "2.36.0"
description = "Overwatch API giving data about heroes, maps, and players statistics."
license = {file = "LICENSE"}
authors = [
Expand Down
Loading

0 comments on commit 94a0299

Please sign in to comment.