diff --git a/pyproject.toml b/pyproject.toml index d9282cb..b9c4ea2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,19 +1,21 @@ [project] -name = "vlr-scraper" +name = "vlrscraper" version = '0.0.1' authors = [ { name = "Joe Paton", email = "joantpat@gmail.com" }, ] description = "vlrscraper - Scrape data from vlr seamlessly" readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.6" dependencies = [ "lxml", "requests", ] [project.optional-dependencies] -dev = ["pytest", "pytest-cov"] +test = ["pytest", "pytest-cov"] +lint = ["ruff", "pyright"] +docs = ["sphinx"] [build-system] requires = ["setuptools >= 61.0"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 7937e3b..0000000 --- a/requirements.txt +++ /dev/null @@ -1,19 +0,0 @@ -certifi==2024.8.30 -charset-normalizer==3.3.2 -colorama==0.4.6 -coverage==7.6.1 -idna==3.10 -iniconfig==2.0.0 -lxml==5.3.0 -multidict==6.1.0 -packaging==24.1 -pluggy==1.5.0 -pytest==8.3.3 -pytest-cov==5.0.0 -pytest-recording==0.13.2 -PyYAML==6.0.2 -requests==2.32.3 -urllib3==2.2.3 -vcrpy==6.0.1 -wrapt==1.16.0 -yarl==1.13.1 diff --git a/src/vlrscraper/logger.py b/src/vlrscraper/logger.py index 0005876..2978e42 100644 --- a/src/vlrscraper/logger.py +++ b/src/vlrscraper/logger.py @@ -2,11 +2,13 @@ import sys import logging +from typing import Optional + class LogConfig: - formatter: logging.Formatter | None = None + formatter: Optional[logging.Formatter] = None stdoutHandler: logging.StreamHandler = logging.StreamHandler(sys.stdout) - fileHandler: logging.FileHandler | None = None + fileHandler: Optional[logging.FileHandler] = None setup: bool = False logger: logging.Logger diff --git a/src/vlrscraper/match.py b/src/vlrscraper/match.py index c425504..fabc876 100644 --- a/src/vlrscraper/match.py +++ b/src/vlrscraper/match.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, List, Tuple from lxml import html @@ -18,19 +18,19 @@ @dataclass -class MatchStats: - rating: float | None - ACS: int - kills: int - deaths: int - assists: int - KD: int - KAST: int | None - ADR: int - HS: int - FK: int - FD: int - FKFD: int +class PlayerStats: + rating: Optional[float] + ACS: Optional[int] + kills: Optional[int] + deaths: Optional[int] + assists: Optional[int] + KD: Optional[int] + KAST: Optional[int] + ADR: Optional[int] + HS: Optional[int] + FK: Optional[int] + FD: Optional[int] + FKFD: Optional[int] class Match: @@ -42,22 +42,32 @@ def __init__( match_name: str, event_name: str, epoch: float, - teams: tuple[Team, Team] | tuple[()] = (), + teams: Tuple[Team, Team] | Tuple[()] = (), ) -> None: self.__id = _id self.__name = match_name self.__event = event_name self.__epoch = epoch self.__teams = teams - self.__stats: dict[int, MatchStats] = {} + self.__stats: dict[int, PlayerStats] = {} def __eq__(self, other: object) -> bool: + _logger.warning( + "Avoid using inbuilt equality for Players. See Match.is_same_match()" + ) + return object.__eq__(self, other) + + def is_same_match(self, other: object) -> bool: return ( isinstance(other, Match) and self.get_id() == other.get_id() and self.get_full_name() == other.get_full_name() and self.get_date() == other.get_date() - and self.get_teams() == other.get_teams() + and all( + team.is_same_team(other.get_teams()[i]) + and team.has_same_roster(other.get_teams()[i]) + for i, team in enumerate(self.get_teams()) + ) ) def get_id(self) -> int: @@ -72,56 +82,48 @@ def get_event_name(self) -> str: def get_full_name(self) -> str: return f"{self.__event} - {self.__name}" - def get_teams(self) -> tuple[Team, Team] | tuple[()]: + def get_teams(self) -> Tuple[Team, Team] | Tuple[()]: return self.__teams - def get_stats(self) -> dict[int, MatchStats]: + def get_stats(self) -> dict[int, PlayerStats]: return self.__stats - def get_player_stats(self, player: int) -> Optional[MatchStats]: + def get_player_stats(self, player: int) -> Optional[PlayerStats]: return self.__stats.get(player, None) def get_date(self) -> float: return self.__epoch - def set_stats(self, stats: dict[int, MatchStats]): + def set_stats(self, stats: dict[int, PlayerStats]): self.__stats = stats - def add_match_stat(self, player: int, stats: MatchStats) -> None: + def add_match_stat(self, player: int, stats: PlayerStats) -> None: self.__stats.update({player: stats}) @staticmethod def __parse_match_stats( - players: list[int], stats: list[html.HtmlElement] - ) -> dict[int, MatchStats]: + players: List[int], stats: List[html.HtmlElement] + ) -> dict[int, PlayerStats]: if len(stats) % 12 != 0: _logger.warning(f"Wrong amount of stats passed ({len(stats)})") return {} player_stats = {} - TO_LOAD = [ - float, - int, - int, - int, - int, - int, - int, - int, - int, - int, - int, - int, - ] for i, player in enumerate(players): - indexes = range(i * 12, (i + 1) * 12) - player_stats.update( { - player: MatchStats( - *[ - parse_stat(stats[stat].text, rtype=TO_LOAD[stat % 12]) - for stat in indexes - ] + player: PlayerStats( + parse_stat(stats[i * 12 + 0].text, rtype=float), + parse_stat(stats[i * 12 + 1].text, rtype=int), + parse_stat(stats[i * 12 + 2].text, rtype=int), + parse_stat(stats[i * 12 + 3].text, rtype=int), + parse_stat(stats[i * 12 + 4].text, rtype=int), + parse_stat(stats[i * 12 + 5].text, rtype=int), + parse_stat(stats[i * 12 + 6].text, rtype=int), + parse_stat(stats[i * 12 + 7].text, rtype=int), + parse_stat(stats[i * 12 + 8].text, rtype=int), + parse_stat(stats[i * 12 + 9].text, rtype=int), + parse_stat(stats[i * 12 + 10].text, rtype=int), + parse_stat(stats[i * 12 + 11].text, rtype=int), ) } ) @@ -152,18 +154,27 @@ def get_match(_id: int) -> Optional[Match]: from vlrscraper.team import Team from vlrscraper.player import Player - teams = tuple( + teams = ( Team.from_match_page( - get_url_segment(team, 2, int), - team_names[i], + get_url_segment(team_links[0], 2, int), + team_names[0], "", - f"https:{team_logos[i]}", + f"https:{team_logos[0]}", [ Player.from_match_page(match_player_ids[pl], match_player_names[pl]) - for pl in range(i * 5, (i + 1) * 5) + for pl in range(0, 5) ], - ) - for i, team in enumerate(team_links) + ), + Team.from_match_page( + get_url_segment(team_links[1], 2, int), + team_names[1], + "", + f"https:{team_logos[1]}", + [ + Player.from_match_page(match_player_ids[pl], match_player_names[pl]) + for pl in range(1, 5) + ], + ), ) match = Match( diff --git a/src/vlrscraper/player.py b/src/vlrscraper/player.py index 3ccbf5f..fa3d179 100644 --- a/src/vlrscraper/player.py +++ b/src/vlrscraper/player.py @@ -1,12 +1,13 @@ from __future__ import annotations -from typing import Optional, TYPE_CHECKING + from enum import IntEnum +from typing import Optional, TYPE_CHECKING, List import vlrscraper.constants as const - from vlrscraper.resource import Resource -from vlrscraper.scraping import XpathParser, join -from vlrscraper.utils import get_url_segment, parse_first_last_name +from vlrscraper.logger import get_logger +from vlrscraper.scraping import XpathParser +from vlrscraper.utils import parse_first_last_name, get_url_segment if TYPE_CHECKING: from vlrscraper.team import Team @@ -17,6 +18,9 @@ class PlayerStatus(IntEnum): ACTIVE = 2 +_logger = get_logger() + + class Player: resource = Resource("https://www.vlr.gg/player/") @@ -30,82 +34,85 @@ def __init__( image: Optional[str], status: Optional[PlayerStatus], ) -> None: + if not isinstance(_id, int) or _id <= 0: + raise ValueError("Player ID must be an integer {0 < ID}") + self.__id = _id self.__displayname = name self.__current_team = current_team - self.__name = (forename, surname) if forename or surname else () + self.__name = tuple(x for x in (forename, surname) if x is not None) or None self.__image_src = image self.__status = status - self.__fully_scraped = False def __eq__(self, other: object) -> bool: - return ( - isinstance(other, Player) - and self.get_id() == other.get_id() - and self.get_display_name() == other.get_display_name() - and self.get_name() == other.get_name() - and self.get_image() == other.get_image() - and self.get_status() == other.get_status() + _logger.warning( + "Avoid using inbuilt equality for Players. See Player.is_same_player()" ) + return object.__eq__(self, other) def __repr__(self) -> str: - return f"{self.get_display_name()} ({self.get_name()}) [{self.get_image()}]" + return ( + f"Player({self.get_id()}" + + f", {self.get_display_name()}" * bool(self.get_display_name()) + + f", {self.get_name()}" * bool(self.get_name()) + + f", {self.get_image()}" * bool(self.get_image()) + + f", {0 if (t := self.get_current_team()) is None else t.get_name()}" + * bool(t) + + f", {0 if (s := self.get_status()) is None else s.name}" + * bool(self.get_status()) + + ")" + ) def get_id(self) -> int: return self.__id - def get_display_name(self) -> str: + def get_display_name(self) -> Optional[str]: return self.__displayname - def get_current_team(self) -> Team: + def get_current_team(self) -> Optional[Team]: return self.__current_team - def get_name(self) -> str: - return " ".join(self.__name) + def get_name(self) -> Optional[str]: + return " ".join(self.__name) if self.__name is not None else None - def get_image(self) -> str: + def get_image(self) -> Optional[str]: return self.__image_src - def get_status(self) -> PlayerStatus: + def get_status(self) -> Optional[PlayerStatus]: return self.__status - def is_fully_scraped(self) -> bool: - return self.__fully_scraped - - def set_fully_scraped(self, scraped: bool) -> None: - self.__fully_scraped = scraped + def is_same_player(self, other: object) -> bool: + return ( + isinstance(other, Player) + and self.get_id() == other.get_id() + and self.get_display_name() == other.get_display_name() + and self.get_name() == other.get_name() + and self.get_image() == other.get_image() + ) @staticmethod def from_player_page( _id: int, display_name: str, forename: str, - surname: str, + surname: Optional[str], current_team: Team, image: str, status: PlayerStatus, ) -> Player: - player = Player( - _id, display_name, current_team, forename, surname, image, status - ) - player.set_fully_scraped(True) - return player + return Player(_id, display_name, current_team, forename, surname, image, status) @staticmethod def from_team_page( _id: int, display_name: str, forename: str, - surname: str, + surname: Optional[str], current_team: Team, image: str, status: PlayerStatus, ) -> Player: - player = Player( - _id, display_name, current_team, forename, surname, image, status - ) - player.set_fully_scraped(True) - return player + return Player(_id, display_name, current_team, forename, surname, image, status) @staticmethod def from_match_page(_id: int, display_name: str) -> Player: @@ -123,9 +130,7 @@ def from_match_page(_id: int, display_name: str) -> Player: Player _description_ """ - player = Player(_id, display_name, None, None, None, None, None) - player.set_fully_scraped(False) - return player + return Player(_id, display_name, None, None, None, None, None) @staticmethod def get_player(_id: int) -> Optional[Player]: @@ -135,28 +140,59 @@ def get_player(_id: int) -> Optional[Player]: parser = XpathParser(data["data"]) + player_alias = parser.get_text(const.PLAYER_DISPLAYNAME) + player_image = f"https:{parser.get_img(const.PLAYER_IMAGE_SRC)}" player_name = parse_first_last_name(parser.get_text(const.PLAYER_FULLNAME)) - - from vlrscraper.team import Team - - team_id = get_url_segment( - parser.get_href(const.PLAYER_CURRENT_TEAM), 2, rtype=int + player_status = ( + PlayerStatus.ACTIVE + if len(parser.get_elements(const.PLAYER_INACTIVE_CHECK)) <= 2 + else PlayerStatus.INACTIVE ) - imgpath = join(const.PLAYER_CURRENT_TEAM, "img")[2:] - namepath = join(const.PLAYER_CURRENT_TEAM, "div[2]", "div[1]")[2:] - - team_image = f"https:{parser.get_img(imgpath)}" - team_name = parser.get_text(namepath) + from vlrscraper.team import Team return Player.from_player_page( _id, - parser.get_text(const.PLAYER_DISPLAYNAME), + player_alias, player_name[0], player_name[-1], - Team.from_player_page(team_id, team_name, team_image), - f"https:{parser.get_img(const.PLAYER_IMAGE_SRC)}", - PlayerStatus.ACTIVE - if len(parser.get_elements(const.PLAYER_INACTIVE_CHECK)) <= 2 - else PlayerStatus.INACTIVE, + Team.get_team_from_player_page(parser=parser), + player_image, + player_status, ) + + @staticmethod + def get_players_from_team_page(parser: XpathParser, team: Team) -> List[Player]: + player_ids = [ + get_url_segment(url, 2, rtype=int) + for url in parser.get_elements(const.TEAM_ROSTER_ITEMS, "href") + ] + player_aliases = parser.get_text_many(const.TEAM_ROSTER_ITEM_ALIAS) + player_fullnames = [ + parse_first_last_name(name) + for name in parser.get_text_many(const.TEAM_ROSTER_ITEM_FULLNAME) + ] + player_images = [ + f"https:{img}" + for img in parser.get_elements(const.TEAM_ROSTER_ITEM_IMAGE, "src") + ] + player_tags = [ + parser.get_text( + f"//a[contains(@href, '{p.lower()}')]//div[contains(@class, 'wf-tag')]" + ) + for p in player_aliases + ] + return [ + Player.from_team_page( + pid, + player_aliases[i], + player_fullnames[i][0], + player_fullnames[i][1], + team, + image=player_images[i], + status=PlayerStatus.INACTIVE + if player_tags[i] == "Inactive" + else PlayerStatus.ACTIVE, + ) + for i, pid in enumerate(player_ids) + ] diff --git a/src/vlrscraper/resource.py b/src/vlrscraper/resource.py index 49dfc99..05b9af9 100644 --- a/src/vlrscraper/resource.py +++ b/src/vlrscraper/resource.py @@ -29,8 +29,14 @@ def success(data) -> dict: class Resource: def __init__(self, url: str) -> None: if not isinstance(url, str): + _logger.error( + f"Attempt to create resource with url {url} failed. URL must be of type string." + ) raise TypeError("Resource URLs must be strings.") if "" not in url: + _logger.error( + "Resource URLs must contain some reference to a resource ID using the tag." + ) raise ValueError("Resource URLs must contain some reference to .") self.__url = url diff --git a/src/vlrscraper/scraping.py b/src/vlrscraper/scraping.py index 5025e9b..bcef3fe 100644 --- a/src/vlrscraper/scraping.py +++ b/src/vlrscraper/scraping.py @@ -5,7 +5,7 @@ - `xpath`, a function that generates xpath strings based on the arguments passed """ -from typing import Optional +from typing import Optional, List from lxml import html @@ -40,7 +40,7 @@ def get_element(self, xpath: str) -> Optional[html.HtmlElement]: return elem[0] if elem else None return None - def get_elements(self, xpath: str, attr: str = "") -> list[html.HtmlElement]: + def get_elements(self, xpath: str, attr: str = "") -> List[html.HtmlElement]: """Gets a list of htmlElements that match a given XPATH TODO: Do we want this to return null values for failed GETS or do we want this to return only the successful @@ -51,7 +51,7 @@ def get_elements(self, xpath: str, attr: str = "") -> list[html.HtmlElement]: attr (str): The attribute to get from each element (or '') Returns: - list[str | html.HtmlElement]: The list of elements that match the given XPATH + List[str | html.HtmlElement]: The list of elements that match the given XPATH """ return ( [elem.get(attr, None) for elem in self.content.xpath(xpath)] @@ -103,7 +103,7 @@ def get_text(self, xpath: str) -> str: return txt.replace("\n", "").replace("\t", "").strip() - def get_text_many(self, xpath: str) -> list[str]: + def get_text_many(self, xpath: str) -> List[str]: elems = self.get_elements(xpath) return [ diff --git a/src/vlrscraper/team.py b/src/vlrscraper/team.py index d546c0f..494aa74 100644 --- a/src/vlrscraper/team.py +++ b/src/vlrscraper/team.py @@ -1,14 +1,18 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, List -from .resource import Resource -from .scraping import XpathParser -from .utils import get_url_segment, parse_first_last_name +from vlrscraper.logger import get_logger +from vlrscraper.resource import Resource from vlrscraper import constants as const +from vlrscraper.utils import get_url_segment +from vlrscraper.scraping import XpathParser, join + if TYPE_CHECKING: from vlrscraper.player import Player +_logger = get_logger() + class Team: resource = Resource("https://vlr.gg/team/") @@ -19,27 +23,88 @@ def __init__( name: Optional[str], tag: Optional[str], logo: Optional[str], - roster: Optional[list[Player]], + roster: Optional[List[Player]], ) -> None: + if not isinstance(_id, int) or _id <= 0: + raise ValueError("Player ID must be an integer {0 < ID}") + self.__id = _id self.__name = name self.__tag = tag self.__logo = logo self.__roster = roster - self.__fully_scraped = True - def __eq__(self, other: object) -> bool: + _logger.warning( + "Avoid using inbuilt equality for Team. See Team.is_same_team() and Team.is_same_roster()" + ) + return object.__eq__(self, other) + + def is_same_team(self, other: object) -> bool: + """Check if this team's org is the same organization as the other team. + + Purely checks attributes related to the actual organization itself (ID, name, tag, logo) rather than + attributes that change over time such as roster + + Parameters + ---------- + other : object + The other team to check + + Returns + ------- + bool + `True` if all attributes (ID, name, tag, logo) match, else `False` + """ + return ( + isinstance(other, Team) + and self.__id == other.__id + and self.__name == other.__name + and self.__tag == other.__tag + ) + + def has_same_roster(self, other: object) -> bool: + """Check if all of the players / staff on this team are the same as the other team + + Does not include the player's current team in the equality check, only whether + the roster contains the same actual players + + Parameters + ---------- + other : object + The other team to check + + Returns + ------- + bool + _description_ + """ + + if not isinstance(other, Team): + return False + + mR, oR = self.get_roster(), other.get_roster() + return ( isinstance(other, Team) - and self.get_id() == other.get_id() - and self.get_name() == other.get_name() - and self.get_tag() == other.get_tag() - and self.get_logo() == other.get_logo() + and mR is None is oR + or ( + not (mR is None or oR is None) + and len(mR) == len(oR) + and all([p.is_same_player(oR[i])] for i, p in enumerate(mR)) + ) ) def __repr__(self) -> str: - return f"{self.get_name()} / {self.get_tag()}, [{self.get_logo()}]" + return ( + f"Team({self.get_id()}" + + f", {self.get_name()}" * bool(self.get_name()) + + f", {self.get_tag()}" * bool(self.get_tag()) + + f", {self.get_logo()}" * bool(self.get_logo()) + + f", {0 if (r := self.get_roster()) is None else [p.get_display_name() for p in r]}" + * bool(r) + + ")" + ) def get_id(self) -> int: """Get the vlr ID of this team @@ -51,7 +116,7 @@ def get_id(self) -> int: """ return self.__id - def get_name(self) -> str: + def get_name(self) -> Optional[str]: """Get the name of this team Returns @@ -61,7 +126,7 @@ def get_name(self) -> str: """ return self.__name - def get_tag(self) -> str: + def get_tag(self) -> Optional[str]: """Get the 1-3 letter team tag of this team Returns @@ -71,7 +136,7 @@ def get_tag(self) -> str: """ return self.__tag - def get_logo(self) -> str: + def get_logo(self) -> Optional[str]: """Get the URL of this team's logo Returns @@ -81,7 +146,7 @@ def get_logo(self) -> str: """ return self.__logo - def get_roster(self) -> list[Player]: + def get_roster(self) -> Optional[List[Player]]: """Get the list of players / staff for this team Returns @@ -91,18 +156,18 @@ def get_roster(self) -> list[Player]: """ return self.__roster - def set_roster(self, roster: list[Player]) -> None: + def set_roster(self, roster: List[Player]) -> None: self.__roster = roster - def set_fully_scraped(self, scraped: bool) -> None: - self.__fully_scraped = scraped + def add_to_roster(self, player: Player) -> None: + if self.__roster is None: + self.__roster = [] - def get_fully_scraped(self) -> bool: - return self.__fully_scraped + self.__roster.append(player) @staticmethod def from_team_page( - _id: int, name: str, tag: str, logo: str, roster: list[Player] + _id: int, name: str, tag: str, logo: str, roster: List[Player] ) -> Team: """Construct a Team object from the data available on the team's page @@ -124,9 +189,7 @@ def from_team_page( Team The team object created using the given values """ - team = Team(_id, name, tag, logo, roster) - team.set_fully_scraped(True) - return team + return Team(_id, name, tag, logo, roster) @staticmethod def from_player_page(_id: int, name: str, logo: str) -> Team: @@ -148,17 +211,13 @@ def from_player_page(_id: int, name: str, logo: str) -> Team: Team The team object created using the given values """ - team = Team(_id, name=name, tag=None, logo=logo, roster=None) - team.set_fully_scraped(False) - return team + return Team(_id, name=name, tag=None, logo=logo, roster=None) @staticmethod def from_match_page( - _id: int, name: str, tag: str, logo: str, roster: list[Player] + _id: int, name: str, tag: str, logo: str, roster: List[Player] ) -> Team: - team = Team(_id, name, tag, logo, roster) - team.set_fully_scraped(True) - return team + return Team(_id, name, tag, logo, roster) @staticmethod def get_team(_id: int) -> Optional[Team]: @@ -180,27 +239,7 @@ def get_team(_id: int) -> Optional[Team]: parser = XpathParser(data["data"]) - player_ids = [ - get_url_segment(url, 2, rtype=int) - for url in parser.get_elements(const.TEAM_ROSTER_ITEMS, "href") - ] - player_aliases = parser.get_text_many(const.TEAM_ROSTER_ITEM_ALIAS) - player_fullnames = [ - parse_first_last_name(name) - for name in parser.get_text_many(const.TEAM_ROSTER_ITEM_FULLNAME) - ] - player_images = [ - f"https:{img}" - for img in parser.get_elements(const.TEAM_ROSTER_ITEM_IMAGE, "src") - ] - player_tags = [ - parser.get_text( - f"//a[contains(@href, '{p.lower()}')]//div[contains(@class, 'wf-tag')]" - ) - for p in player_aliases - ] - - from vlrscraper.player import Player, PlayerStatus + from vlrscraper.player import Player team = Team.from_team_page( _id, @@ -209,22 +248,19 @@ def get_team(_id: int) -> Optional[Team]: f"https:{parser.get_img(const.TEAM_IMG)}", [], ) + team.set_roster(Player.get_players_from_team_page(parser, team)) - team.set_roster( - list( - [ - Player.from_team_page( - pid, - player_aliases[i], - *player_fullnames[i], - team, - image=player_images[i], - status=PlayerStatus.INACTIVE - if player_tags[i] == "Inactive" - else PlayerStatus.ACTIVE, - ) - for i, pid in enumerate(player_ids) - ] - ), - ) return team + + @staticmethod + def get_team_from_player_page(parser: XpathParser) -> Team: + imgpath = join(const.PLAYER_CURRENT_TEAM, "img")[2:] + namepath = join(const.PLAYER_CURRENT_TEAM, "div[2]", "div[1]")[2:] + + team_name = parser.get_text(namepath) + team_image = f"https:{parser.get_img(imgpath)}" + team_id = get_url_segment( + parser.get_href(const.PLAYER_CURRENT_TEAM), 2, rtype=int + ) + + return Team.from_player_page(team_id, team_name, team_image) diff --git a/src/vlrscraper/utils.py b/src/vlrscraper/utils.py index 020f849..3041af8 100644 --- a/src/vlrscraper/utils.py +++ b/src/vlrscraper/utils.py @@ -7,10 +7,10 @@ """ from datetime import datetime -from typing import TypeVar, Type +from typing import TypeVar, Type, Optional, Tuple -def parse_first_last_name(name: str) -> tuple[str, str]: +def parse_first_last_name(name: str) -> Tuple[str, Optional[str]]: names = name.split(" ") # Get rid of non-ascii names (ie korean names) if names[-1].startswith("("): @@ -18,15 +18,15 @@ def parse_first_last_name(name: str) -> tuple[str, str]: # Only one name (Weird ?) if len(names) == 1: - return (names[0],) + return (names[0], None) return names[0], names[-1] T = TypeVar("T", int, float, str) -def parse_stat(stat: str, rtype: Type) -> T | None: - if stat == "\xa0": +def parse_stat(stat: Optional[str], rtype: Type) -> Optional[T]: + if stat == "\xa0" or stat is None: return None return rtype(stat.replace("%", "").strip()) diff --git a/tests/test_match.py b/tests/test_match.py index b72c6d1..51ec01e 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -1,4 +1,5 @@ -from vlrscraper.match import Match, MatchStats +# type: ignore +from vlrscraper.match import Match, PlayerStats from vlrscraper.team import Team from .helpers import assert_teams @@ -20,9 +21,8 @@ def test_match_init(): assert m.get_event_name() == "Red Bull Home Ground #5" assert m.get_full_name() == "Red Bull Home Ground #5 - NA Play-ins: Grand Final" assert m.get_date() == 100 - assert m.get_teams() == ( - Team.from_match_page(2, "Sentinels", "", "", []), - Team.from_match_page(188, "Cloud9", "", "", []), + assert m.get_teams()[0].is_same_team( + Team.from_match_page(2, "Sentinels", "", "", []) ) @@ -37,20 +37,29 @@ def test_match_eq(): Team.from_match_page(188, "Cloud9", "", "", []), ), ) + assert m.is_same_match(m) + + assert not m.is_same_match("NA Play-ins") + assert not m.is_same_match(10) + assert m == m + assert m != 10 def test_match_get(): # Current match m = Match.get_match(408415) + assert m is not None assert m.get_id() == 408415 assert m.get_name() == "NA Play-ins: Grand Final" assert m.get_event_name() == "Red Bull Home Ground #5" assert m.get_full_name() == "Red Bull Home Ground #5 - NA Play-ins: Grand Final" assert m.get_date() == 1727660400.0 - assert m.get_player_stats(4004) == MatchStats( - 1.19, 271, 45, 36, 8, 9, 71, 164, 21, 7, 5, 2 + assert ( + m.get_player_stats(4004) + == m.get_stats()[4004] + == PlayerStats(1.19, 271, 45, 36, 8, 9, 71, 164, 21, 7, 5, 2) ) assert_teams( @@ -62,12 +71,17 @@ def test_match_get(): # China match (no stats) m = Match.get_match(370727) - assert m.get_player_stats(3520) == MatchStats( + assert m is not None + assert m.get_player_stats(3520) == PlayerStats( None, 204, 77, 82, 20, -5, None, 131, 24, 18, 25, -7 ) # Old match (no stats) m = Match.get_match(3490) - assert m.get_player_stats(4004) == MatchStats( + assert m is not None + assert m.get_player_stats(4004) == PlayerStats( None, 176, 13, 17, 8, -4, None, 112, 28, 2, 1, 1 ) + + assert Match.get_match(0) is None + assert Match.get_match("3490") is None diff --git a/tests/test_player.py b/tests/test_player.py index e53e8ed..ee49d4e 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -1,12 +1,30 @@ +# type: ignore +import pytest + from vlrscraper.player import Player, PlayerStatus from vlrscraper.team import Team def test_player_init(): + # Correct exceptions + with pytest.raises(ValueError): + Player(0, None, None, None, None, None, None) + + with pytest.raises(ValueError): + Player(-100, None, None, None, None, None, None) + + with pytest.raises(ValueError): + Player("4004", None, None, None, None, None, None) + + with pytest.raises(ValueError): + Player(None, None, None, None, None, None, None) + benjy = Player( 29873, "benjyfishy", - 1001, + Team.from_player_page( + 1001, "Team Heretics", "https://owcdn.net/img/637b755224c12.png" + ), "Benjamin", "Fish", "https://owcdn.net/img/665b77ca4bc4d.png", @@ -14,35 +32,69 @@ def test_player_init(): ) assert benjy.get_id() == 29873 assert benjy.get_display_name() == "benjyfishy" - assert benjy.get_current_team() == 1001 + assert benjy.get_current_team().is_same_team( + Team.from_player_page( + 1001, "Team Heretics", "https://owcdn.net/img/637b755224c12.png" + ) + ) assert benjy.get_name() == "Benjamin Fish" assert benjy.get_image() == "https://owcdn.net/img/665b77ca4bc4d.png" assert benjy.get_status() == PlayerStatus.ACTIVE + # No forename OR surname + crappy = Player(31207, None, None, None, None, None, None) + assert crappy.get_name() is None + + # Forename but no surname + crappy = Player(31207, None, None, "Lee", None, None, None) + assert crappy.get_name() == "Lee" + def test_player_equals(): benjy = Player( 29873, "benjyfishy", - 1001, + Team.from_player_page( + 1001, "Team Heretics", "https://owcdn.net/img/637b755224c12.png" + ), "Benjamin", "Fish", "https://owcdn.net/img/665b77ca4bc4d.png", PlayerStatus.ACTIVE, ) - assert benjy == benjy - assert benjy != 1 + assert benjy.is_same_player(benjy) + assert not benjy.is_same_player(1) benjy2 = Player( 298731, "benjyfishy", - 1001, + Team.from_player_page( + 1001, "Team Heretics", "https://owcdn.net/img/637b755224c12.png" + ), + "Benjamin", + "Fish", + "https://owcdn.net/img/665b77ca4bc4d.png", + PlayerStatus.ACTIVE, + ) + + # Different ID, different object + assert not benjy.is_same_player(benjy2) + + # Different object, same ID + benjy3 = Player( + 29873, + "benjyfishy", + Team.from_player_page( + 1001, "Team Heretics", "https://owcdn.net/img/637b755224c12.png" + ), "Benjamin", "Fish", "https://owcdn.net/img/665b77ca4bc4d.png", PlayerStatus.ACTIVE, ) + assert benjy.is_same_player(benjy3) + assert benjy == benjy assert benjy != benjy2 @@ -50,7 +102,9 @@ def test_string(): benjy = Player( 29873, "benjyfishy", - 1001, + Team.from_player_page( + 1001, "Team Heretics", "https://owcdn.net/img/637b755224c12.png" + ), "Benjamin", "Fish", "https://owcdn.net/img/665b77ca4bc4d.png", @@ -58,35 +112,64 @@ def test_string(): ) assert ( str(benjy) - == "benjyfishy (Benjamin Fish) [https://owcdn.net/img/665b77ca4bc4d.png]" + == "Player(29873, benjyfishy, Benjamin Fish, https://owcdn.net/img/665b77ca4bc4d.png, Team Heretics, ACTIVE)" + ) + + +def test_player_from(): + benjy = Player.from_player_page( + 29873, + "benjyfishy", + Team.from_player_page( + 1001, "Team Heretics", "https://owcdn.net/img/637b755224c12.png" + ), + "Benjamin", + "Fish", + "https://owcdn.net/img/665b77ca4bc4d.png", + PlayerStatus.ACTIVE, ) + benjy = Player.from_team_page( + 29873, + "benjyfishy", + Team.from_player_page( + 1001, "Team Heretics", "https://owcdn.net/img/637b755224c12.png" + ), + "Benjamin", + "Fish", + "https://owcdn.net/img/665b77ca4bc4d.png", + PlayerStatus.ACTIVE, + ) + benjy = Player.from_match_page(29873, "Benjyfishy") + + assert benjy.get_name() is benjy.get_current_team() is benjy.get_status() is None def test_player_get(): # Average player benjy = Player.get_player(29873) + assert benjy is not None assert benjy.get_id() == 29873 assert benjy.get_display_name() == "benjyfishy" - assert benjy.get_current_team() == Team.from_player_page( - 1001, "Team Heretics", "https://owcdn.net/img/637b755224c12.png" + assert benjy.get_current_team().is_same_team( + Team.from_player_page( + 1001, "Team Heretics", "https://owcdn.net/img/637b755224c12.png" + ) ) assert benjy.get_name() == "Benjamin Fish" assert benjy.get_image() == "https://owcdn.net/img/665b77ca4bc4d.png" assert benjy.get_status() == PlayerStatus.ACTIVE - assert benjy.is_fully_scraped() is True # Player with non-latin characters in name crappy = Player.get_player(31207) + assert crappy is not None assert crappy.get_id() == 31207 assert crappy.get_display_name() == "Carpe" - assert crappy.get_current_team() == Team.from_player_page( - 14, "T1", "https://owcdn.net/img/62fe0b8f6b084.png" + assert crappy.get_current_team().is_same_team( + Team.from_player_page(14, "T1", "https://owcdn.net/img/62fe0b8f6b084.png") ) - assert crappy.get_current_team().get_fully_scraped() is False assert crappy.get_name() == "Lee Jae-hyeok" assert crappy.get_image() == "https://owcdn.net/img/65cc6f0f4da99.png" assert crappy.get_status() == PlayerStatus.ACTIVE - assert crappy.is_fully_scraped() is True # Bad player very bad assert Player.get_player(None) is None diff --git a/tests/test_resource.py b/tests/test_resource.py index 4c8786d..8d1c4a3 100644 --- a/tests/test_resource.py +++ b/tests/test_resource.py @@ -1,3 +1,4 @@ +# type: ignore import pytest from vlrscraper.resource import Resource, ResourceResponse diff --git a/tests/test_scraping.py b/tests/test_scraping.py index a7331b2..3e7007e 100644 --- a/tests/test_scraping.py +++ b/tests/test_scraping.py @@ -1,3 +1,4 @@ +# type: ignore import pytest import requests diff --git a/tests/test_team.py b/tests/test_team.py index b3f590c..1722989 100644 --- a/tests/test_team.py +++ b/tests/test_team.py @@ -1,3 +1,6 @@ +# type: ignore +import pytest + from vlrscraper.team import Team from vlrscraper.player import Player, PlayerStatus @@ -5,6 +8,15 @@ def test_team_init(): + with pytest.raises(ValueError): + Team(0, None, None, None, None) + with pytest.raises(ValueError): + Team(-100, None, None, None, None) + with pytest.raises(ValueError): + Team("4004", None, None, None, None) + with pytest.raises(ValueError): + Team(4004.0, None, None, None, None) + sen = Team(2, "Sentinels", "SEN", "https://owcdn.net/img/62875027c8e06.png", []) assert sen.get_id() == 2 @@ -13,33 +25,73 @@ def test_team_init(): assert sen.get_logo() == "https://owcdn.net/img/62875027c8e06.png" assert sen.get_roster() == [] + sen = Team(2, "Sentinels", "SEN", "", None) + assert sen.get_roster() is None + def test_teamEq(): sen = Team(2, "Sentinels", "SEN", "https://owcdn.net/img/62875027c8e06.png", []) + assert sen.is_same_team(sen) is True + assert sen.has_same_roster(sen) is True assert sen == sen sen2 = Team(2, "Sentinels", "SEN", "https://owcdn.net/img/62875027c8e06.png", []) - assert sen == sen2 + assert sen.is_same_team(sen) is True + assert sen.has_same_roster(sen) is True + assert sen != sen2 + + sen2.set_roster([Player.from_match_page(4004, "Zekken")]) + assert sen.is_same_team(sen2) is True + assert sen.has_same_roster(sen2) is False sen3 = Team(2, "Heretics", "SEN", "https://owcdn.net/img/62875027c8e06.png", []) - assert sen != sen3 + assert sen.is_same_team(sen3) is False + assert sen.has_same_roster(sen3) is True + + assert not sen.is_same_team("sen") + assert not sen.has_same_roster("sen") + + +def test_teamRoster(): + sen = Team.from_player_page(2, "Sentinels", "") + sen.add_to_roster(Player.from_match_page(2, "Zekken")) + + assert sen.get_roster()[0].is_same_player(Player.from_match_page(2, "Zekken")) def test_teamRepr(): sen = Team(2, "Sentinels", "SEN", "https://owcdn.net/img/62875027c8e06.png", []) - assert str(sen) == "Sentinels / SEN, [https://owcdn.net/img/62875027c8e06.png]" + assert ( + str(sen) == "Team(2, Sentinels, SEN, https://owcdn.net/img/62875027c8e06.png)" + ) + + sen.add_to_roster(Player.from_match_page(4004, "Zekken")) + assert ( + str(sen) + == "Team(2, Sentinels, SEN, https://owcdn.net/img/62875027c8e06.png, ['Zekken'])" + ) + + +""" def test_teamRoster(): + sen = Team(2, "Sentinels", "SEN", "https://owcdn.net/img/62875027c8e06.png", []) + sen.set_roster([Player.from_match_page(4004, "Zekken"), Player.from_match_page(2, "TenZ")]) + + assert sen.get_roster() == [Player.from_match_page(4004, "Zekken"), Player.from_match_page(2, "TenZ")] + + sen.add_to_roster(Player.from_match_page(3, "johnqt")) + assert sen.get_roster() == [Player.from_match_page(4004, "Zekken"), Player.from_match_page(2, "TenZ"), Player.from_match_page(3, "johnqt")] """ def test_getTeam(): # Valid team sen = Team.get_team(2) + assert sen is not None assert sen.get_id() == 2 assert sen.get_name() == "Sentinels" assert sen.get_logo() == "https://owcdn.net/img/62875027c8e06.png" - assert sen.get_fully_scraped() is True assert len(sen.get_roster()) == 8 assert_players( sen.get_roster()[0], @@ -53,7 +105,6 @@ def test_getTeam(): PlayerStatus.ACTIVE, ), ) - assert sen.get_roster()[0].is_fully_scraped() is True assert_players( sen.get_roster()[4], diff --git a/tests/test_utils.py b/tests/test_utils.py index 1e92e8f..f34ed08 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,3 +1,4 @@ +# type: ignore import pytest from contextlib import nullcontext @@ -58,7 +59,7 @@ def test_parse_name() -> None: assert utils.parse_first_last_name("Test Middle Name") == ("Test", "Name") assert utils.parse_first_last_name("Test Name (张钊)") == ("Test", "Name") - assert utils.parse_first_last_name("Test") == ("Test",) + assert utils.parse_first_last_name("Test") == ("Test", None) def test_parse_stat() -> None: diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..a7b0488 --- /dev/null +++ b/tox.ini @@ -0,0 +1,38 @@ +[tox] +skipsdist=true +envlist= py3{11,12,13} + +[testenv] +whitelist_externals = sh +commands = + python --version + pytest {posargs: tests/} --cov=src/vlrscraper + coverage html +deps = + -r requirements.txt + -e . + pytest + +[testenv:lint] +allowlist_externals = + ruff + pyright + mypy +description = + Run linters +extras = + lint +commands = + ruff check --fix + pyright + mypy . + +[testenv:ruff] +allowlist_externals = ruff +description = + Run ruff formatter and linter +extras = + lint +commands = + ruff check --fix . + ruff format .