diff --git a/.env.dist b/.env.dist index c173a6fb..5695b6fe 100644 --- a/.env.dist +++ b/.env.dist @@ -6,6 +6,7 @@ APP_PORT=80 APP_BASE_URL=https://overfast-api.tekrop.fr LOG_LEVEL=info STATUS_PAGE_URL= +PROFILING=false # Rate limiting BLIZZARD_RATE_LIMIT_RETRY_AFTER=5 diff --git a/app/config.py b/app/config.py index 04c2434a..0c966b9f 100644 --- a/app/config.py +++ b/app/config.py @@ -42,6 +42,9 @@ class Settings(BaseSettings): # Optional, status page URL if you have any to provide status_page_url: str | None = None + # Profiling with pyinstrument, for debug purposes + profiling: bool = False + ############ # RATE LIMITING ############ diff --git a/app/main.py b/app/main.py index d4ee8566..59d1138d 100644 --- a/app/main.py +++ b/app/main.py @@ -1,12 +1,13 @@ """Project main file containing FastAPI app and routes definitions""" +from collections.abc import Callable from contextlib import asynccontextmanager, suppress from fastapi import FastAPI, Request from fastapi.exceptions import ResponseValidationError from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html from fastapi.openapi.utils import get_openapi -from fastapi.responses import JSONResponse +from fastapi.responses import HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from starlette.exceptions import HTTPException as StarletteHTTPException @@ -22,6 +23,10 @@ from .players.commands.update_search_data_cache import update_search_data_cache from .roles import router as roles +# pyinstrument won't be installed on production, that's why we're checking it here +with suppress(ModuleNotFoundError): + from pyinstrument import Profiler + @asynccontextmanager async def lifespan(_: FastAPI): # pragma: no cover @@ -176,6 +181,27 @@ async def overridden_swagger(): return get_swagger_ui_html(**swagger_settings) +# In case enabled in settings, add the pyinstrument profiler middleware +if settings.profiling is True: + logger.info("Profiling is enabled") + + @app.middleware("http") + async def profile_request(request: Request, call_next: Callable): + """Profile the current request""" + # if the `profile=true` HTTP query argument is passed, we profile the request + if request.query_params.get("profile", False): + # we profile the request along with all additional middlewares, by interrupting + # the program every 1ms1 and records the entire stack at that point + with Profiler(interval=0.001, async_mode="enabled") as profiler: + await call_next(request) + + # we dump the profiling into a file + return HTMLResponse(profiler.output_html()) + + # Proceed without profiling + return await call_next(request) + + # Add application routers app.include_router(heroes.router, prefix="/heroes") app.include_router(roles.router, prefix="/roles") diff --git a/app/parsers.py b/app/parsers.py index 9b5bb0f4..2a86e79f 100644 --- a/app/parsers.py +++ b/app/parsers.py @@ -2,7 +2,7 @@ from typing import ClassVar import httpx -from bs4 import BeautifulSoup +from bs4 import BeautifulSoup, SoupStrainer from fastapi import status from .cache_manager import CacheManager @@ -137,8 +137,7 @@ class HTMLParser(APIParser): @property def root_tag_params(self) -> dict: """Returns the BeautifulSoup params kwargs, used to find the root Tag - on the page which will be used for searching and hashing (for cache). We - don't want to calculate an hash and do the data parsing on all the HTML. + on the page which will be used for searching data. """ return {"name": "main", "class_": "main-content", "recursive": False} @@ -147,9 +146,10 @@ def store_response_data(self, response: httpx.Response) -> None: self.create_bs_tag(response.text) def create_bs_tag(self, html_content: str) -> None: - self.root_tag = BeautifulSoup(html_content, "lxml").body.find( - **self.root_tag_params, - ) + soup_strainer = SoupStrainer(**self.root_tag_params) + self.root_tag = BeautifulSoup( + html_content, "lxml", parse_only=soup_strainer + ).main class JSONParser(APIParser): diff --git a/app/players/parsers/player_career_parser.py b/app/players/parsers/player_career_parser.py index 5e6a7eaf..16ce43a8 100644 --- a/app/players/parsers/player_career_parser.py +++ b/app/players/parsers/player_career_parser.py @@ -50,22 +50,36 @@ class PlayerCareerParser(BasePlayerParser): 404, # Player Not Found response, we want to handle it here ] - def filter_request_using_query(self, **kwargs) -> dict: - if kwargs.get("summary"): + # Filters coming from user query + filters: ClassVar[dict[str, bool | str]] + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._init_filters(**kwargs) + + def _init_filters(self, **kwargs) -> None: + self.filters = { + filter_key: kwargs.get(filter_key) + for filter_key in ("summary", "stats", "platform", "gamemode", "hero") + } + + def filter_request_using_query(self, **_) -> dict: + if self.filters["summary"]: return self.data.get("summary") - if kwargs.get("stats"): - return self._filter_stats(**kwargs) + if self.filters["stats"]: + return self._filter_stats() return { "summary": self.data["summary"], - "stats": self._filter_all_stats_data(**kwargs), + "stats": self._filter_all_stats_data(), } - def _filter_stats(self, **kwargs) -> dict: + def _filter_stats(self) -> dict: filtered_data = self.data["stats"] or {} - platform = kwargs.get("platform") + # We must have a valid platform filter here + platform = self.filters["platform"] if not platform: # Retrieve a "default" platform is the user didn't provided one if possible_platforms := [ @@ -83,12 +97,12 @@ def _filter_stats(self, **kwargs) -> dict: if not filtered_data: return {} - filtered_data = filtered_data.get(kwargs.get("gamemode")) or {} + filtered_data = filtered_data.get(self.filters["gamemode"]) or {} if not filtered_data: return {} filtered_data = filtered_data.get("career_stats") or {} - hero_filter = kwargs.get("hero") + hero_filter = self.filters["hero"] return { hero_key: statistics @@ -96,19 +110,17 @@ def _filter_stats(self, **kwargs) -> dict: if not hero_filter or hero_filter == hero_key } - def _filter_all_stats_data(self, **kwargs) -> dict: + def _filter_all_stats_data(self) -> dict: stats_data = self.data["stats"] or {} - platform_filter = kwargs.get("platform") - gamemode_filter = kwargs.get("gamemode") # Return early if no platform or gamemode is specified - if not platform_filter and not gamemode_filter: + if not self.filters["platform"] and not self.filters["gamemode"]: return stats_data filtered_data = {} for platform_key, platform_data in stats_data.items(): - if platform_filter and platform_key != platform_filter: + if self.filters["platform"] and platform_key != self.filters["platform"]: filtered_data[platform_key] = None continue @@ -116,13 +128,13 @@ def _filter_all_stats_data(self, **kwargs) -> dict: filtered_data[platform_key] = None continue - if gamemode_filter is None: + if self.filters["gamemode"] is None: filtered_data[platform_key] = platform_data continue filtered_data[platform_key] = { gamemode_key: ( - gamemode_data if gamemode_key == gamemode_filter else None + gamemode_data if gamemode_key == self.filters["gamemode"] else None ) for gamemode_key, gamemode_data in platform_data.items() } @@ -141,6 +153,10 @@ def parse_data(self) -> dict: return {"summary": self.__get_summary(), "stats": self.get_stats()} def __get_summary(self) -> dict: + # If the user filtered the page on stats, no need to parse the summary + if self.filters["stats"]: + return {} + profile_div = self.root_tag.find( "blz-section", class_="Profile-masthead", @@ -158,9 +174,7 @@ def __get_summary(self) -> dict: ) return { - "username": ( - summary_div.find("h1", class_="Profile-player--name").get_text() - ), + "username": summary_div.find("h1", class_="Profile-player--name").string, "avatar": ( summary_div.find( "img", @@ -198,7 +212,7 @@ def __get_title(profile_div: Tag) -> str | None: return None # Retrieve the title text - title = title_tag.get_text() or None + title = title_tag.string or None # Special case : the "no title" means there is no title return get_player_title(title) @@ -281,22 +295,23 @@ def __get_platform_competitive_ranks( return competitive_ranks def __get_last_season_played(self, platform_class: str) -> int | None: - profile_section = self.root_tag.select_one(f"div.Profile-view.{platform_class}") + profile_section = self.root_tag.find( + "div", + class_=platform_class, + recursive=False, + ) if not profile_section: return None - statistics_section = profile_section.select_one( - "blz-section.stats.competitive-view", - ) - return ( - int(statistics_section["data-latestherostatrankseasonow2"]) - if ( - statistics_section - and "data-latestherostatrankseasonow2" in statistics_section.attrs - ) - else None + statistics_section = profile_section.find( + "blz-section", + class_="competitive-view", + recursive=False, ) + last_season_played = statistics_section.get("data-latestherostatrankseasonow2") + return int(last_season_played) if last_season_played else None + @staticmethod def __get_role_icon(role_wrapper: Tag) -> str: """The role icon format may differ depending on the platform : img for @@ -309,17 +324,27 @@ def __get_role_icon(role_wrapper: Tag) -> str: return role_svg.find("use")["xlink:href"] def get_stats(self) -> dict | None: + # If the user filtered the page on summary, no need to parse the stats + if self.filters["summary"]: + return None + stats = { - platform.value: self.__get_platform_stats(platform_class) + platform.value: self.__get_platform_stats(platform, platform_class) for platform, platform_class in platforms_div_mapping.items() } - # If we don't have data for any platform, return None directly - return None if not any(stats.values()) else stats + # If we don't have data for any platform and we're consulting stats, return None directly + return None if not any(stats.values()) and self.filters["stats"] else stats + + def __get_platform_stats( + self, platform: PlayerPlatform, platform_class: str + ) -> dict | None: + # If the user decided to filter on another platform, stop here + if self.filters["platform"] and self.filters["platform"] != platform: + return None - def __get_platform_stats(self, platform_class: str) -> dict | None: - statistics_section = self.root_tag.select_one( - f"div.Profile-view.{platform_class}", + statistics_section = self.root_tag.find( + "div", class_=platform_class, recursive=False ) gamemodes_infos = { gamemode.value: self.__get_gamemode_infos(statistics_section, gamemode) @@ -335,6 +360,10 @@ def __get_gamemode_infos( statistics_section: Tag, gamemode: PlayerGamemode, ) -> dict | None: + # If the user decided to filter on another gamemode, stop here + if self.filters["gamemode"] and self.filters["gamemode"] != gamemode: + return None + if not statistics_section: return None @@ -342,15 +371,21 @@ def __get_gamemode_infos( "blz-section", class_="Profile-heroSummary", recursive=False, - ).select_one(f"div.Profile-heroSummary--view.{gamemodes_div_mapping[gamemode]}") + ).find( + "div", + class_=gamemodes_div_mapping[gamemode], + recursive=False, + ) # Check if we can find a select in the section. If not, it means there is # no data to show for this gamemode and platform, return nothing. if not top_heroes_section.find("select"): return None - career_stats_section = statistics_section.select_one( - f"blz-section.stats.{gamemodes_div_mapping[gamemode]}", + career_stats_section = statistics_section.find( + "blz-section", + class_=gamemodes_div_mapping[gamemode], + recursive=False, ) return { @@ -367,8 +402,8 @@ def __get_heroes_comparisons(self, top_heroes_section: Tag) -> dict: class_="Profile-heroSummary--header", recursive=False, ) - .find("select") - .find_all("option") + .find("select", recursive=False) + .children ) if option.get("option-id") } @@ -382,31 +417,24 @@ def __get_heroes_comparisons(self, top_heroes_section: Tag) -> dict: ), "values": [ { - "hero": ( - progress_bar.find("div", class_="Profile-progressBar--bar")[ - "data-hero-id" - ] - ), + # First div is "Profile-progressBar--bar" + "hero": progress_bar_container.contents[0]["data-hero-id"], + # Second div is "Profile-progressBar--textWrapper" "value": get_computed_stat_value( - progress_bar.find( - "div", - class_="Profile-progressBar-description", - ).get_text(), + # Second div is "Profile-progressBar-description" + progress_bar_container.contents[1].contents[1].string, ), } - for progress_bar in category.find_all( - "div", - class_="Profile-progressBar", - recursive=False, - ) + for progress_bar in category.children + for progress_bar_container in progress_bar.children + if progress_bar_container.name == "div" ], } - for category in top_heroes_section.find_all( - "div", - class_="Profile-progressBars", - recursive=False, + for category in top_heroes_section.children + if ( + "Profile-progressBars" in category["class"] + and category["data-category-id"] in categories ) - if category["data-category-id"] in categories } for category in CareerHeroesComparisonsCategory: @@ -430,19 +458,19 @@ def __get_career_stats(career_stats_section: Tag) -> dict: class_="Profile-heroSummary--header", recursive=False, ) - .find("select") - .find_all("option") + .find("select", recursive=False) + .children ) if option.get("option-id") } career_stats = {} - for hero_container in career_stats_section.find_all( - "span", - class_="stats-container", - recursive=False, - ): + for hero_container in career_stats_section.children: + # Hero container should be span with "stats-container" class + if hero_container.name != "span": + continue + stats_hero_class = get_stats_hero_class(hero_container["class"]) # Sometimes, Blizzard makes some weird things and options don't @@ -454,17 +482,13 @@ def __get_career_stats(career_stats_section: Tag) -> dict: hero_key = get_hero_keyname(heroes_options[stats_hero_class]) career_stats[hero_key] = [] - for card_stat in hero_container.find_all( - "div", - class_="category", - recursive=False, - ): - content_div = card_stat.find("div", class_="content", recursive=False) - category_label = content_div.find( - "div", - class_="header", - recursive=False, - ).get_text() + # Hero container children are div with "category" class + for card_stat in hero_container.children: + # Content div should be the only child ("content" class) + content_div = card_stat.contents[0] + + # Label should be the first div within content ("header" class) + category_label = content_div.contents[0].contents[0].string career_stats[hero_key].append( { @@ -473,14 +497,17 @@ def __get_career_stats(career_stats_section: Tag) -> dict: "stats": [], }, ) - for stat_row in content_div.find_all("div", class_="stat-item"): - stat_name = stat_row.find("p", class_="name").get_text() + for stat_row in content_div.children: + if "stat-item" not in stat_row["class"]: + continue + + stat_name = stat_row.contents[0].string career_stats[hero_key][-1]["stats"].append( { "key": get_plural_stat_key(string_to_snakecase(stat_name)), "label": stat_name, "value": get_computed_stat_value( - stat_row.find("p", class_="value").get_text(), + stat_row.contents[1].string, ), }, ) diff --git a/app/players/parsers/player_career_stats_parser.py b/app/players/parsers/player_career_stats_parser.py index 6e38fb1a..44a9274b 100644 --- a/app/players/parsers/player_career_stats_parser.py +++ b/app/players/parsers/player_career_stats_parser.py @@ -10,8 +10,8 @@ class PlayerCareerStatsParser(PlayerCareerParser): """Overwatch player career Parser class""" - def filter_request_using_query(self, **kwargs) -> dict: - return self._filter_stats(**kwargs) if self.data else {} + def filter_request_using_query(self, **_) -> dict: + return self._filter_stats() if self.data else {} def parse_data(self) -> dict | None: # We must check if we have the expected section for profile. If not, diff --git a/pyproject.toml b/pyproject.toml index e31e3b1f..af9b0037 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "overfast-api" -version = "3.1.0" +version = "3.2.0" description = "Overwatch API giving data about heroes, maps, and players statistics." license = {file = "LICENSE"} authors = [ @@ -37,6 +37,7 @@ dev-dependencies = [ "pytest-xdist==3.6.*", "ruff==0.7.*", "pre-commit==4.0.*", + "pyinstrument>=5.0.0", ] [tool.ruff] @@ -130,4 +131,4 @@ known-first-party = ["app"] [tool.pytest.ini_options] # Put this default value to prevent warnings -asyncio_default_fixture_loop_scope = "function" \ No newline at end of file +asyncio_default_fixture_loop_scope = "function" diff --git a/tests/players/parsers/test_player_career_parser.py b/tests/players/parsers/test_player_career_parser.py index 00acbab6..1e28edbe 100644 --- a/tests/players/parsers/test_player_career_parser.py +++ b/tests/players/parsers/test_player_career_parser.py @@ -71,6 +71,8 @@ async def test_filter_all_stats_data( player_search_response_mock: Mock, search_data_func: Callable[[str, str], str | None], ): + player_career_parser._init_filters(platform=platform, gamemode=gamemode) + with ( patch( "httpx.AsyncClient.get", @@ -89,9 +91,7 @@ async def test_filter_all_stats_data( await player_career_parser.parse() # Just check that the parsing is working properly - filtered_data = player_career_parser._filter_all_stats_data( - platform=platform, gamemode=gamemode - ) + filtered_data = player_career_parser._filter_all_stats_data() if platform: assert all( diff --git a/uv.lock b/uv.lock index 37c1f863..82ce25b8 100644 --- a/uv.lock +++ b/uv.lock @@ -540,7 +540,7 @@ wheels = [ [[package]] name = "overfast-api" -version = "3.0.0" +version = "3.1.0" source = { virtual = "." } dependencies = [ { name = "beautifulsoup4" }, @@ -558,6 +558,7 @@ dev = [ { name = "fakeredis" }, { name = "ipdb" }, { name = "pre-commit" }, + { name = "pyinstrument" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, @@ -583,6 +584,7 @@ dev = [ { name = "fakeredis", specifier = "==2.26.*" }, { name = "ipdb", specifier = "==0.13.*" }, { name = "pre-commit", specifier = "==4.0.*" }, + { name = "pyinstrument", specifier = ">=5.0.0" }, { name = "pytest", specifier = "==8.3.*" }, { name = "pytest-asyncio", specifier = "==0.24.*" }, { name = "pytest-cov", specifier = "==5.0.*" }, @@ -756,6 +758,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, ] +[[package]] +name = "pyinstrument" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/14/726f2e2553aca08f25b7166197d22a4426053d5fb423c53417342ac584b1/pyinstrument-5.0.0.tar.gz", hash = "sha256:144f98eb3086667ece461f66324bf1cc1ee0475b399ab3f9ded8449cc76b7c90", size = 262211 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/ec/fb3a3df90d561d5e0c6682627d2fb3d582af92c311d116633fb83f399ba9/pyinstrument-5.0.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:dec3529a5351ea160baeef1ef2a6e28b1a7a7b3fb5e9863fae8de6da73d0f69a", size = 128364 }, + { url = "https://files.pythonhosted.org/packages/73/fa/4b079dba81995a968b84ebcea0335dfe6e273b5ec9f079aee5a662e574c1/pyinstrument-5.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5a39e3ef84c56183f8274dfd584b8c2fae4783c6204f880513e70ab2440b9137", size = 120380 }, + { url = "https://files.pythonhosted.org/packages/2b/37/e51aa7a30f622e811d1d771c80f86eefdd98ca0ad7ed8f9d8cdfcdc9572f/pyinstrument-5.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3938f063ee065e05826628dadf1fb32c7d26b22df4a945c22f7fe25ea1ba6a2", size = 143834 }, + { url = "https://files.pythonhosted.org/packages/25/eb/8711a084acb173dc2d5df1034348a99968c1b0f9a4dc4d487d0ec04428ff/pyinstrument-5.0.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f18990cc16b2e23b54738aa2f222863e1d36daaaec8f67b1613ddfa41f5b24db", size = 142765 }, + { url = "https://files.pythonhosted.org/packages/67/a1/30ed993fe10921f25e69f67125685f708178311f531aa3b791c1424db877/pyinstrument-5.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3731412b5bfdcef8014518f145140c69384793e218863a33a39ccfe5fb42045", size = 144121 }, + { url = "https://files.pythonhosted.org/packages/21/35/bb28bde4803713ab2e7da2c9764eab25c6f28a1d52677c19eb159f666a6a/pyinstrument-5.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02b2eaf38460b14eea646d6bb7f373eb5bb5691d13f788e80bdcb3a4eaa2519e", size = 143816 }, + { url = "https://files.pythonhosted.org/packages/68/dd/3c0bc95901b9b92a8751b45236cff4493ec0f2061827b142cd25e6a08bf2/pyinstrument-5.0.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e57db06590f13657b2bce8c4d9cf8e9e2bd90bb729bcbbe421c531ba67ad7add", size = 143555 }, + { url = "https://files.pythonhosted.org/packages/52/bd/ac2f907152605b18cb7143de4dbbf825e79497273c940277d59e89832982/pyinstrument-5.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddaa3001c1b798ec9bf1266ef476bbc0834b74d547d531f5ed99e7d05ac5d81b", size = 143989 }, + { url = "https://files.pythonhosted.org/packages/98/dd/07d1a3c9c0abf4518ff3881c0da81f1064383dddb094f56f8c1f78748c8f/pyinstrument-5.0.0-cp312-cp312-win32.whl", hash = "sha256:b69ff982acf5ef2f4e0f32ce9b4b598f256faf88438f233ea3a72f1042707e5b", size = 121974 }, + { url = "https://files.pythonhosted.org/packages/a1/ed/2503309f485bf4c8893b76d585323505f422c5fa1e1885ee9d4a2bb57aa5/pyinstrument-5.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:0bf4ef061d60befe72366ce0ed4c75dee5be089644de38f9936d2df0bcf44af0", size = 122759 }, + { url = "https://files.pythonhosted.org/packages/49/c9/b2ed3db062bca45decb7fdcab2ed2cba6b1afb32b21bbde7166aafe5ecd3/pyinstrument-5.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79a54def2d4aa83a4ed37c6cffc5494ae5de140f0453169eb4f7c744cc249d3a", size = 128268 }, + { url = "https://files.pythonhosted.org/packages/0f/14/456f51598c2e8401b248c38591488c3815f38a4c0bca6babb3f81ab93a71/pyinstrument-5.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9538f746f166a40c8802ebe5c3e905d50f3faa189869cd71c083b8a639e574bb", size = 120299 }, + { url = "https://files.pythonhosted.org/packages/11/e8/abeecedfa5dc6e6651e569c8876f0a55b973c906ebeb90185504a792ddb2/pyinstrument-5.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bbab65cae1483ad8a18429511d1eac9e3efec9f7961f2fd1bf90e1e2d69ef15", size = 143953 }, + { url = "https://files.pythonhosted.org/packages/80/03/107d3889ea42a777b0231bf3b8e5da8f8370b5bed5a55d79bcf7607d2393/pyinstrument-5.0.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4351ad041d208c597e296a0e9c2e6e21cc96804608bcafa40cfa168f3c2b8f79", size = 142858 }, + { url = "https://files.pythonhosted.org/packages/72/6c/0f4af16e529d0ea290cbc72f97e0403a118692f954b2abdaf5547e05e026/pyinstrument-5.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ceee5252f4580abec29bcc5c965453c217b0d387c412a5ffb8afdcda4e648feb", size = 144259 }, + { url = "https://files.pythonhosted.org/packages/18/c7/1a8100197b67c03a8a733d0ffbc881c35f23ccbaf0f0e470c03b0e639da5/pyinstrument-5.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b3050a4e7033103a13cfff9802680e2070a9173e1a258fa3f15a80b4eb9ee278", size = 143951 }, + { url = "https://files.pythonhosted.org/packages/87/bb/9826f6a62f2fee88a54059e1ca36a9766dab6220f826c8745dc453c31e99/pyinstrument-5.0.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3b1f44a34da7810938df615fb7cbc43cd879b42ca6b5cd72e655aee92149d012", size = 143722 }, + { url = "https://files.pythonhosted.org/packages/42/2c/9a5b0cc42296637e23f50881e36add73edde2e668d34095e3ddbd899a1e6/pyinstrument-5.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fde075196c8a3b2be191b8da05b92ff909c78d308f82df56d01a8cfdd6da07b9", size = 144138 }, + { url = "https://files.pythonhosted.org/packages/66/96/85044622fae98feaabaf26dbee39a7151d9a7c8d020a870033cd90f326ca/pyinstrument-5.0.0-cp313-cp313-win32.whl", hash = "sha256:1a9b62a8b54e05e7723eb8b9595fadc43559b73290c87b3b1cb2dc5944559790", size = 121977 }, + { url = "https://files.pythonhosted.org/packages/dd/36/a6a44b5162a9d102b085ef7107299be766868679ab2c974a4888823c8a0f/pyinstrument-5.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:2478d2c55f77ad8e281e67b0dfe7c2176304bb824c307e86e11890f5e68d7feb", size = 122766 }, +] + [[package]] name = "pytest" version = "8.3.2"