Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

fix: Update competitive information following Blizzard's latest update #93

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ options:
```

### Code Quality
The code quality is checked using the `ruff` command. I'm also using the `isort` utility for imports ordering, and `black` to enforce PEP-8 convention on my code. To check the quality of the code, you just have to run the following command :
The code quality is checked using the `ruff` command. I'm also using `ruff format` for imports ordering and code formatting, enforcing PEP-8 convention on my code. To check the quality of the code, you just have to run the following command :

```
ruff .
Expand All @@ -139,9 +139,7 @@ python -m pytest --cov=app --cov-report html
The project is using [pre-commit](https://pre-commit.com/) framework to ensure code quality before making any commit on the repository. After installing the project dependencies, you can install the pre-commit by using the `pre-commit install` command.

The configuration can be found in the `.pre-commit-config.yaml` file. It consists in launching 3 processes on modified files before making any commit :
- `isort` for imports sorting in a clean way
- `black` for formatting the code in a uniform and PEP8-compliant format
- `ruff` for code quality checks and some fixes if possible
- `ruff` for linting and code formatting (with `ruff format`)
- `sourcery` for more code quality checks and a lot of simplifications

## 🛠️ Cache System
Expand Down
4 changes: 3 additions & 1 deletion app/commands/update_test_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,9 @@ async def main():
for route, filepath in route_file_mapping.items():
logger.info("Updating {}{}...", test_data_path, filepath)
logger.info("GET {}/{}{}...", settings.blizzard_host, locale, route)
response = await client.get(f"{settings.blizzard_host}/{locale}{route}")
response = await client.get(
f"{settings.blizzard_host}/{locale}{route}", follow_redirects=True
)
logger.debug(
"HTTP {} / Time : {}",
response.status_code,
Expand Down
20 changes: 13 additions & 7 deletions app/common/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@ class Role(StrEnum):
TANK = "tank"


# Dynamically create the CompetitiveRole enum by using the existing
# Role enum and just adding the "open" option for Open Queue
CompetitiveRole = StrEnum(
"CompetitiveRole",
{
**{role.name: role.value for role in Role},
"OPEN": "open",
},
)
CompetitiveRole.__doc__ = "Competitive roles for ranks in stats summary"


class PlayerGamemode(StrEnum):
"""Gamemodes associated with players statistics"""

Expand All @@ -95,13 +107,6 @@ class PlayerPlatform(StrEnum):
PC = "pc"


class PlayerPrivacy(StrEnum):
"""Players career privacy"""

PUBLIC = "public"
PRIVATE = "private"


class CompetitiveDivision(StrEnum):
"""Competitive division of a rank"""

Expand All @@ -112,6 +117,7 @@ class CompetitiveDivision(StrEnum):
DIAMOND = "diamond"
MASTER = "master"
GRANDMASTER = "grandmaster"
CHAMPION = "champion"


class Locale(StrEnum):
Expand Down
2 changes: 1 addition & 1 deletion app/common/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"Dekk-2677", # Classic profile without rank
"KIRIKO-21253", # Profile with rank on only two roles
"Player-1112937", # Console player
"Player-137712", # Private profile
"quibble-11594", # Profile without endorsement
"TeKrop-2217", # Classic profile
"Unknown-1234", # No player
"JohnV1-1190", # Player without any title ingame
Expand Down
20 changes: 0 additions & 20 deletions app/handlers/search_players_request_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@

class SearchPlayersRequestHandler(ApiRequestMixin):
"""Search Players Request Handler used in order to find an Overwatch player
using some filters : career privacy, etc.

The APIRequestHandler class is not used here, as this is a very specific request,
depending on a Blizzard endpoint returning JSON Data. Some parsers are used,
Expand Down Expand Up @@ -56,10 +55,6 @@ async def process_request(self, **kwargs) -> dict:

players = req.json()

# Filter results using kwargs
logger.info("Applying filters..")
players = self.apply_filters(players, **kwargs)

# Transform into PlayerSearchResult format
logger.info("Applying transformation..")
players = self.apply_transformations(players)
Expand All @@ -84,20 +79,6 @@ async def process_request(self, **kwargs) -> dict:
logger.info("Done ! Returning players list...")
return players_list

@staticmethod
def apply_filters(players: list[dict], **kwargs) -> Iterable[dict]:
"""Apply query params filters on a list of players (only career
privacy for now), and return the results accordingly as an iterable.
"""

def filter_privacy(player: dict) -> bool:
return not kwargs.get("privacy") or player["isPublic"] == (
kwargs.get("privacy") == "public"
)

filters = [filter_privacy]
return filter(lambda x: all(f(x) for f in filters), players)

def apply_transformations(self, players: Iterable[dict]) -> list[dict]:
"""Apply transformations to found players in order to return the data
in the OverFast API format. We'll also retrieve some data from parsers.
Expand All @@ -112,7 +93,6 @@ def apply_transformations(self, players: Iterable[dict]) -> list[dict]:
"avatar": self.get_avatar_url(player, player_id),
"namecard": self.get_namecard_url(player, player_id),
"title": self.get_title(player, player_id),
"privacy": "public" if player["isPublic"] else "private",
"career_url": f"{settings.app_base_url}/players/{player_id}",
},
)
Expand Down
44 changes: 17 additions & 27 deletions app/models/players.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
CareerStatCategory,
CompetitiveDivision,
HeroKey,
PlayerPrivacy,
)
from app.common.helpers import get_hero_name, key_to_label

Expand Down Expand Up @@ -57,15 +56,6 @@ class PlayerShort(BaseModel):
description="Title of the player if any",
examples=["Bytefixer"],
)
privacy: PlayerPrivacy = Field(
...,
title="Privacy",
description=(
"Privacy of the player career. If private, only some basic informations "
"are available on player details endpoint (avatar, endorsement)"
),
examples=["public"],
)
career_url: AnyHttpUrl = Field(
...,
title="Career URL",
Expand Down Expand Up @@ -102,9 +92,16 @@ class PlayerCompetitiveRank(BaseModel):
)
rank_icon: HttpUrl = Field(
...,
description="URL of the rank icon associated with the player rank (division + tier)",
description="URL of the division icon associated with the player rank",
examples=[
"https://static.playoverwatch.com/img/pages/career/icons/rank/Rank_MasterTier-7d3b85ba0d.png",
],
)
tier_icon: HttpUrl = Field(
...,
description="URL of the tier icon associated with the player rank",
examples=[
"https://static.playoverwatch.com/img/pages/career/icons/rank/GrandmasterTier-3-e55e61f68f.png",
"https://static.playoverwatch.com/img/pages/career/icons/rank/TierDivision_3-1de89374e2.png",
],
)

Expand All @@ -122,23 +119,25 @@ class PlatformCompetitiveRanksContainer(BaseModel):
tank: PlayerCompetitiveRank | None = Field(..., description="Tank role details")
damage: PlayerCompetitiveRank | None = Field(..., description="Damage role details")
support: PlayerCompetitiveRank | None = Field(
...,
description="Support role details",
..., description="Support role details"
)
open: PlayerCompetitiveRank | None = Field(
..., description="Open Queue role details"
)


class PlayerCompetitiveRanksContainer(BaseModel):
pc: PlatformCompetitiveRanksContainer | None = Field(
...,
description=(
"Role Queue competitive ranks for PC and last season played on it. "
"Competitive ranks for PC and last season played on it. "
"If the player doesn't play on this platform, it's null."
),
)
console: PlatformCompetitiveRanksContainer | None = Field(
...,
description=(
"Role Queue competitive ranks for console and last season played on it. "
"Competitive ranks for console and last season played on it. "
"If the player doesn't play on this platform, it's null."
),
)
Expand Down Expand Up @@ -209,27 +208,18 @@ class PlayerSummary(BaseModel):
description="Title of the player if any",
examples=["Bytefixer"],
)
endorsement: PlayerEndorsement = Field(
endorsement: PlayerEndorsement | None = Field(
...,
description="Player endorsement details",
)
competitive: PlayerCompetitiveRanksContainer | None = Field(
...,
description=(
"Role Queue competitive ranking in the last season played by the player "
"Competitive ranking in the last season played by the player "
"in different roles depending on the platform. If the career is private "
"or if the player doesn't play competitive at all, it's null."
),
)
privacy: PlayerPrivacy = Field(
...,
title="Privacy",
description=(
"Privacy of the player career. If private, only some basic informations "
"are available (avatar, endorsement)"
),
examples=["public"],
)


class HeroesComparisons(BaseModel):
Expand Down
18 changes: 11 additions & 7 deletions app/parsers/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import unicodedata
from functools import cache

from app.common.enums import CompetitiveDivision, HeroKey, Role
from app.common.enums import CompetitiveDivision, CompetitiveRole, HeroKey, Role
from app.common.helpers import read_csv_data_file
from app.config import settings

Expand Down Expand Up @@ -38,8 +38,8 @@ def get_computed_stat_value(input_str: str) -> str | float | int:
return 0 if input_str == "--" else input_str


def get_division_from_rank_icon(rank_url: str) -> CompetitiveDivision:
division_name = rank_url.split("/")[-1].split("-")[0]
def get_division_from_icon(rank_url: str) -> CompetitiveDivision:
division_name = rank_url.split("/")[-1].split("-")[0].split("_")[-1]
return CompetitiveDivision(division_name[:-4].lower())


Expand All @@ -65,10 +65,14 @@ def get_hero_keyname(input_str: str) -> str:
return string_to_snakecase(input_str).replace("_", "-")


def get_role_key_from_icon(icon_url: str) -> Role:
def get_role_key_from_icon(icon_url: str) -> CompetitiveRole:
"""Extract role key from the role icon."""
icon_role_key = icon_url.split("/")[-1].split("-")[0]
return Role.DAMAGE if icon_role_key == "offense" else Role(icon_role_key)
return (
CompetitiveRole.DAMAGE
if icon_role_key == "offense"
else CompetitiveRole(icon_role_key)
)


def get_stats_hero_class(hero_classes: list[str]) -> str:
Expand All @@ -78,10 +82,10 @@ def get_stats_hero_class(hero_classes: list[str]) -> str:
)


def get_tier_from_rank_icon(rank_url: str) -> int:
def get_tier_from_icon(tier_url: str) -> int:
"""Extracts the rank tier from the rank URL. 0 if not found."""
try:
return int(rank_url.split("/")[-1].split("-")[1])
return int(tier_url.split("/")[-1].split("-")[0].split("_")[-1])
except (IndexError, ValueError):
return 0

Expand Down
36 changes: 16 additions & 20 deletions app/parsers/player_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@

from app.common.enums import (
CareerHeroesComparisonsCategory,
CompetitiveRole,
PlayerGamemode,
PlayerPlatform,
PlayerPrivacy,
Role,
)
from app.common.exceptions import ParserBlizzardError
from app.common.helpers import get_player_title
Expand All @@ -18,14 +17,14 @@
from .generics.api_parser import APIParser
from .helpers import (
get_computed_stat_value,
get_division_from_rank_icon,
get_division_from_icon,
get_endorsement_value_from_frame,
get_hero_keyname,
get_plural_stat_key,
get_real_category_name,
get_role_key_from_icon,
get_stats_hero_class,
get_tier_from_rank_icon,
get_tier_from_icon,
string_to_snakecase,
)

Expand Down Expand Up @@ -138,7 +137,6 @@ def __get_summary(self) -> dict:
"title": self.__get_title(profile_div),
"endorsement": self.__get_endorsement(progression_div),
"competitive": self.__get_competitive_ranks(progression_div),
"privacy": self.__get_privacy(profile_div),
}

@staticmethod
Expand All @@ -160,12 +158,15 @@ def __get_title(profile_div: Tag) -> str | None:
return get_player_title(title)

@staticmethod
def __get_endorsement(progression_div: Tag) -> dict:
def __get_endorsement(progression_div: Tag) -> dict | None:
endorsement_span = progression_div.find(
"span",
class_="Profile-player--endorsementWrapper",
recursive=False,
)
if not endorsement_span:
return None

endorsement_frame_url = endorsement_span.find(
"img",
class_="Profile-playerSummary--endorsement",
Expand Down Expand Up @@ -210,19 +211,22 @@ def __get_platform_competitive_ranks(

for role_wrapper in role_wrappers:
role_icon = self.__get_role_icon(role_wrapper)
rank_icon = role_wrapper.find("img", class_="Profile-playerSummary--rank")[
"src"
]
role_key = get_role_key_from_icon(role_icon).value

rank_tier_icons = role_wrapper.find_all(
"img", class_="Profile-playerSummary--rank"
)
rank_icon, tier_icon = rank_tier_icons[0]["src"], rank_tier_icons[1]["src"]

competitive_ranks[role_key] = {
"division": get_division_from_rank_icon(rank_icon).value,
"tier": get_tier_from_rank_icon(rank_icon),
"division": get_division_from_icon(rank_icon).value,
"tier": get_tier_from_icon(tier_icon),
"role_icon": role_icon,
"rank_icon": rank_icon,
"tier_icon": tier_icon,
}

for role in Role:
for role in CompetitiveRole:
if role.value not in competitive_ranks:
competitive_ranks[role.value] = None

Expand Down Expand Up @@ -258,14 +262,6 @@ def __get_role_icon(role_wrapper: Tag) -> str:
role_svg = role_wrapper.find("svg", class_="Profile-playerSummary--role")
return role_svg.find("use")["xlink:href"]

@staticmethod
def __get_privacy(profile_div: Tag) -> str:
return (
PlayerPrivacy.PRIVATE
if profile_div.find("div", class_="Profile-player--private")
else PlayerPrivacy.PUBLIC
).value

def get_stats(self) -> dict | None:
stats = {
platform.value: self.__get_platform_stats(platform_class)
Expand Down
Loading
Loading