├── tests ├── __init__.py ├── query │ ├── __init__.py │ ├── filters │ │ ├── test_nationality.py │ │ ├── test_franchise.py │ │ ├── test_home_road.py │ │ ├── test_experience.py │ │ ├── test_game_type.py │ │ ├── test_draft.py │ │ ├── test_shoot_catch.py │ │ ├── test_status.py │ │ ├── test_season.py │ │ ├── test_decision.py │ │ └── test_position.py │ └── test_builder.py ├── test_helpers.py ├── conftest.py ├── test_standings.py ├── test_players.py ├── test_game_center.py ├── test_schedule.py ├── test_stats.py ├── test_nhl_client.py ├── test_teams.py └── test_edge.py ├── nhlpy ├── api │ ├── __init__.py │ ├── query │ │ ├── sorting │ │ │ ├── __init__.py │ │ │ └── sorting_options.py │ │ ├── __init__.py │ │ ├── filters │ │ │ ├── game_type.py │ │ │ ├── franchise.py │ │ │ ├── home_road.py │ │ │ ├── shoot_catch.py │ │ │ ├── opponent.py │ │ │ ├── experience.py │ │ │ ├── season.py │ │ │ ├── draft.py │ │ │ ├── decision.py │ │ │ ├── position.py │ │ │ ├── nationality.py │ │ │ ├── status.py │ │ │ └── __init__.py │ │ └── builder.py │ ├── players.py │ ├── misc.py │ ├── standings.py │ ├── game_center.py │ ├── helpers.py │ ├── teams.py │ ├── schedule.py │ ├── edge.py │ └── stats.py ├── __init__.py ├── config.py ├── nhl_client.py ├── data │ ├── teams_20232024.json │ └── team_stat_ids.json └── http_client.py ├── .python-version ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── FUNDING.yml └── workflows │ └── python-app.yml ├── CONTRIBUTING.md ├── pyproject.toml └── nhl_api_2_3_0.json /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nhlpy/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/query/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.9.18 2 | -------------------------------------------------------------------------------- /nhlpy/api/query/sorting/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nhlpy/__init__.py: -------------------------------------------------------------------------------- 1 | from .nhl_client import NHLClient # noqa: F401 2 | -------------------------------------------------------------------------------- /nhlpy/api/query/__init__.py: -------------------------------------------------------------------------------- 1 | class InvalidQueryValueException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from nhlpy.nhl_client import NHLClient 4 | 5 | 6 | @pytest.fixture(scope="function") 7 | def nhl_client() -> NHLClient: 8 | yield NHLClient() 9 | -------------------------------------------------------------------------------- /tests/query/filters/test_nationality.py: -------------------------------------------------------------------------------- 1 | from nhlpy.api.query.filters.nationality import NationalityQuery 2 | 3 | 4 | def test_nation_usa(): 5 | nation = NationalityQuery(nation_code="USA") 6 | assert nation.to_query() == "nationalityCode='USA'" 7 | -------------------------------------------------------------------------------- /tests/query/filters/test_franchise.py: -------------------------------------------------------------------------------- 1 | from nhlpy.api.query.filters.franchise import FranchiseQuery 2 | 3 | 4 | def test_franchise_query(): 5 | franchise_query = FranchiseQuery(franchise_id="1") 6 | assert franchise_query.to_query() == "franchiseId=1" 7 | -------------------------------------------------------------------------------- /tests/query/filters/test_home_road.py: -------------------------------------------------------------------------------- 1 | from nhlpy.api.query.filters.home_road import HomeRoadQuery 2 | 3 | 4 | def test_home_game(): 5 | home_road = HomeRoadQuery(home_road="H") 6 | assert home_road.to_query() == "homeRoad='H'" 7 | 8 | 9 | def test_road_game(): 10 | home_road = HomeRoadQuery(home_road="R") 11 | assert home_road.to_query() == "homeRoad='R'" 12 | -------------------------------------------------------------------------------- /tests/query/filters/test_experience.py: -------------------------------------------------------------------------------- 1 | from nhlpy.api.query.filters.experience import ExperienceQuery 2 | 3 | 4 | def test_is_rookie(): 5 | experience = ExperienceQuery(is_rookie=True) 6 | assert experience.to_query() == "isRookie='1'" 7 | 8 | 9 | def test_is_veteran(): 10 | experience = ExperienceQuery(is_rookie=False) 11 | assert experience.to_query() == "isRookie='0'" 12 | -------------------------------------------------------------------------------- /tests/query/filters/test_game_type.py: -------------------------------------------------------------------------------- 1 | from nhlpy.api.query.filters.game_type import GameTypeQuery 2 | 3 | 4 | def test_game_type_preseason(): 5 | game_type = GameTypeQuery(game_type="1") 6 | assert game_type.to_query() == "gameTypeId=1" 7 | 8 | 9 | def test_game_type_regular(): 10 | game_type = GameTypeQuery(game_type="2") 11 | assert game_type.to_query() == "gameTypeId=2" 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .ipynb_checkpoints 3 | .mypy_cache 4 | .vscode 5 | __pycache__ 6 | .pytest_cache 7 | htmlcov 8 | dist 9 | site 10 | .coverage 11 | coverage.xml 12 | .netlify 13 | test.db 14 | log.txt 15 | Pipfile.lock 16 | env3.* 17 | env 18 | docs_build 19 | venv 20 | docs.zip 21 | archive.zip 22 | 23 | # vim temporary files 24 | *~ 25 | .*.sw? 26 | 27 | */.DS_Store 28 | 29 | CLAUDE.md 30 | v3.md -------------------------------------------------------------------------------- /tests/query/filters/test_draft.py: -------------------------------------------------------------------------------- 1 | from nhlpy.api.query.filters.draft import DraftQuery 2 | 3 | 4 | def test_draft_year_with_round(): 5 | draft = DraftQuery(year="2020", draft_round="2") 6 | assert draft.to_query() == "draftYear=2020 and draftRound=2" 7 | 8 | 9 | def test_draft_year_without_round(): 10 | draft = DraftQuery(year="2020") 11 | assert draft.to_query() == "draftYear=2020" 12 | -------------------------------------------------------------------------------- /tests/query/filters/test_shoot_catch.py: -------------------------------------------------------------------------------- 1 | from nhlpy.api.query.filters.shoot_catch import ShootCatchesQuery 2 | 3 | 4 | def test_shoot_catch_l(): 5 | shoot_catch = ShootCatchesQuery(shoot_catch="L") 6 | assert shoot_catch.to_query() == "shootsCatches='L'" 7 | 8 | 9 | def test_shoot_catch_r(): 10 | shoot_catch = ShootCatchesQuery(shoot_catch="R") 11 | assert shoot_catch.to_query() == "shootsCatches='R'" 12 | -------------------------------------------------------------------------------- /nhlpy/api/query/filters/game_type.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from nhlpy.api.query.builder import QueryBase 4 | 5 | 6 | class GameTypeQuery(QueryBase): 7 | def __init__(self, game_type: str): 8 | self.game_type = game_type 9 | self._game_type_q = "gameTypeId" 10 | 11 | def to_query(self) -> str: 12 | return f"{self._game_type_q}={self.game_type}" 13 | 14 | def validate(self) -> Union[bool, None]: 15 | return True 16 | -------------------------------------------------------------------------------- /nhlpy/api/query/filters/franchise.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from nhlpy.api.query.builder import QueryBase 4 | 5 | 6 | class FranchiseQuery(QueryBase): 7 | def __init__(self, franchise_id: str): 8 | self.franchise_id = franchise_id 9 | self._franchise_q = "franchiseId" 10 | 11 | def to_query(self) -> str: 12 | return f"{self._franchise_q}={self.franchise_id}" 13 | 14 | def validate(self) -> Union[bool, None]: 15 | return True 16 | -------------------------------------------------------------------------------- /nhlpy/config.py: -------------------------------------------------------------------------------- 1 | class ClientConfig: 2 | def __init__( 3 | self, debug: bool = False, timeout: int = 10, ssl_verify: bool = True, follow_redirects: bool = True 4 | ) -> None: 5 | self.debug = debug 6 | self.timeout = timeout 7 | self.ssl_verify = ssl_verify 8 | self.follow_redirects = follow_redirects 9 | 10 | self.api_web_base_url = "https://api-web.nhle.com" 11 | self.api_base_url = "https://api.nhle.com" 12 | self.api_web_api_ver = "/v1/" 13 | -------------------------------------------------------------------------------- /nhlpy/api/query/filters/home_road.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from nhlpy.api.query.filters import QueryBase 4 | 5 | 6 | class HomeRoadQuery(QueryBase): 7 | def __init__(self, home_road: str): 8 | """ 9 | H or R to indicate home or road games. 10 | :param home_road: 11 | """ 12 | self.home_road = home_road 13 | self._home_road_q = "homeRoad" 14 | 15 | def to_query(self) -> str: 16 | return f"{self._home_road_q}='{self.home_road}'" 17 | 18 | def validate(self) -> Union[bool, None]: 19 | return True 20 | -------------------------------------------------------------------------------- /tests/test_standings.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | 4 | @mock.patch("httpx.Client.get") 5 | def test_get_standings(h_m, nhl_client): 6 | nhl_client.standings.league_standings() 7 | h_m.assert_called_once() 8 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/standings/now" 9 | 10 | 11 | @mock.patch("httpx.Client.get") 12 | def test_get_standings_manifest(h_m, nhl_client): 13 | nhl_client.standings.season_standing_manifest() 14 | h_m.assert_called_once() 15 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/standings-season" 16 | -------------------------------------------------------------------------------- /nhlpy/api/query/filters/shoot_catch.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from nhlpy.api.query.builder import QueryBase 4 | 5 | 6 | class ShootCatchesQuery(QueryBase): 7 | def __init__(self, shoot_catch: str): 8 | """ 9 | Shoot / catch filter. L or R, for both I believe its nothing. 10 | :param shoot_catch: L, R 11 | """ 12 | self.shoot_catch = shoot_catch 13 | self.shoot_catch_q = "shootsCatches" 14 | 15 | def to_query(self) -> str: 16 | return f"{self.shoot_catch_q}='{self.shoot_catch}'" 17 | 18 | def validate(self) -> Union[bool, None]: 19 | return True 20 | -------------------------------------------------------------------------------- /nhlpy/api/query/filters/opponent.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from nhlpy.api.query.filters import QueryBase 4 | 5 | 6 | class OpponentQuery(QueryBase): 7 | def __init__(self, opponent_franchise_id: str): 8 | """ 9 | Opponent filter. Takes in the ID of the franchise. 10 | :param opponent_id: int 11 | """ 12 | self.opponent_id: str = opponent_franchise_id 13 | self._opponent_q = "opponentFranchiseId" 14 | 15 | def to_query(self) -> str: 16 | return f"{self._opponent_q}={self.opponent_id}" 17 | 18 | def validate(self) -> Union[bool, None]: 19 | return True 20 | -------------------------------------------------------------------------------- /nhlpy/api/query/filters/experience.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from nhlpy.api.query.filters import QueryBase 4 | 5 | 6 | class ExperienceQuery(QueryBase): 7 | def __init__(self, is_rookie: bool): 8 | """ 9 | Experience filter. R=rookie, S=sophomore, V=veteran 10 | :param experience: R, S, V 11 | """ 12 | self.is_rookie: bool = is_rookie 13 | self._experience_q = "isRookie" 14 | 15 | def to_query(self) -> str: 16 | val = "1" if self.is_rookie else "0" 17 | return f"{self._experience_q}='{val}'" 18 | 19 | def validate(self) -> Union[bool, None]: 20 | return True 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /tests/query/filters/test_status.py: -------------------------------------------------------------------------------- 1 | from nhlpy.api.query.filters.status import StatusQuery 2 | 3 | 4 | def test_active_player(): 5 | status = StatusQuery(is_active=True) 6 | assert status.to_query() == "active=1" 7 | 8 | 9 | def test_hall_of_fame_player(): 10 | status = StatusQuery(is_hall_of_fame=True) 11 | assert status.to_query() == "isInHallOfFame=1" 12 | 13 | 14 | def test_active_and_hof_should_return_hof(): 15 | status = StatusQuery(is_active=True, is_hall_of_fame=True) 16 | assert status.to_query() == "isInHallOfFame=1" 17 | 18 | 19 | def test_inactive_not_hof_returns_empty(): 20 | status = StatusQuery(is_active=False, is_hall_of_fame=False) 21 | assert status.to_query() == "" 22 | -------------------------------------------------------------------------------- /tests/query/filters/test_season.py: -------------------------------------------------------------------------------- 1 | from nhlpy.api.query.filters.season import SeasonQuery 2 | 3 | 4 | def test_season_query_range(): 5 | season_query = SeasonQuery(season_start="20202021", season_end="20232024") 6 | assert season_query.to_query() == "seasonId >= 20202021 and seasonId <= 20232024" 7 | 8 | 9 | def test_season_query_same_year(): 10 | season_query = SeasonQuery(season_start="20202021", season_end="20202021") 11 | assert season_query.to_query() == "seasonId >= 20202021 and seasonId <= 20202021" 12 | 13 | 14 | def test_season_query_wrong_range(): 15 | season_query = SeasonQuery(season_start="20232024", season_end="20202020") 16 | assert season_query.to_query() == "seasonId >= 20232024 and seasonId <= 20202020" 17 | -------------------------------------------------------------------------------- /tests/query/filters/test_decision.py: -------------------------------------------------------------------------------- 1 | from pytest import raises 2 | 3 | from nhlpy.api.query import InvalidQueryValueException 4 | from nhlpy.api.query.filters.decision import DecisionQuery 5 | 6 | 7 | def test_win_outcome(): 8 | decision = DecisionQuery(decision="W") 9 | assert decision.to_query() == "decision='W'" 10 | 11 | 12 | def test_loss_outcome(): 13 | decision = DecisionQuery(decision="L") 14 | assert decision.to_query() == "decision='L'" 15 | 16 | 17 | def test_overtime_loss_outcome(): 18 | decision = DecisionQuery(decision="O") 19 | assert decision.to_query() == "decision='O'" 20 | 21 | 22 | def test_invalid_data(): 23 | decision = DecisionQuery(decision="A") 24 | with raises(InvalidQueryValueException): 25 | assert decision.validate() is False 26 | -------------------------------------------------------------------------------- /nhlpy/api/query/filters/season.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from nhlpy.api.query.filters import QueryBase 4 | 5 | 6 | class SeasonQuery(QueryBase): 7 | def __init__(self, season_start: str, season_end: str): 8 | self.season_start = season_start 9 | self.season_end = season_end 10 | self._season_start_q = "seasonId" 11 | self._season_start_q_exp = ">=" 12 | self._season_end_q = "seasonId" 13 | self._season_end_q_exp = "<=" 14 | 15 | def to_query(self) -> str: 16 | query = f"{self._season_start_q} {self._season_start_q_exp} {self.season_start}" 17 | query += " and " 18 | query += f"{self._season_end_q} {self._season_end_q_exp} {self.season_end}" 19 | return query 20 | 21 | def validate(self) -> Union[bool, None]: 22 | return True 23 | -------------------------------------------------------------------------------- /nhlpy/api/query/filters/draft.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | 3 | from nhlpy.api.query.filters import QueryBase 4 | 5 | 6 | class DraftQuery(QueryBase): 7 | def __init__(self, year: str, draft_round: Optional[str] = None): 8 | """ 9 | 10 | :param year: 11 | :param draft_round: This seems to default to "1" on the API. Should 12 | check not supplying it. 13 | """ 14 | self.year = year 15 | self.round = draft_round 16 | self._year_q = "draftYear" 17 | self._round_q = "draftRound" 18 | 19 | def to_query(self) -> str: 20 | query = f"{self._year_q}={self.year}" 21 | if self.round: 22 | query += " and " 23 | query += f"{self._round_q}={self.round}" 24 | return query 25 | 26 | def validate(self) -> Union[bool, None]: 27 | return True 28 | -------------------------------------------------------------------------------- /nhlpy/api/query/filters/decision.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Union 3 | 4 | from nhlpy.api.query import InvalidQueryValueException 5 | from nhlpy.api.query.filters import QueryBase 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class DecisionQuery(QueryBase): 12 | def __init__(self, decision: str): 13 | """ 14 | Decision filter. W=win, L=loss, O=overtime loss, 15 | :param decision: W, L, O 16 | """ 17 | self.decision = decision 18 | self._decision_q = "decision" 19 | 20 | def __str__(self): 21 | return f"DecisionQuery: Value={self.decision}" 22 | 23 | def to_query(self) -> str: 24 | return f"{self._decision_q}='{self.decision}'" 25 | 26 | def validate(self) -> Union[bool, None]: 27 | if self.decision not in ["W", "L", "O"]: 28 | raise InvalidQueryValueException("Decision value must be one of [W, L, O]") 29 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: coreyjs 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /tests/query/filters/test_position.py: -------------------------------------------------------------------------------- 1 | from nhlpy.api.query.filters.position import PositionQuery, PositionTypes 2 | 3 | 4 | def test_centers(): 5 | position = PositionQuery(position=PositionTypes.CENTER) 6 | assert position.to_query() == "positionCode='C'" 7 | 8 | 9 | def test_left_wings(): 10 | position = PositionQuery(position=PositionTypes.LEFT_WING) 11 | assert position.to_query() == "positionCode='L'" 12 | 13 | 14 | def test_right_wings(): 15 | position = PositionQuery(position=PositionTypes.RIGHT_WING) 16 | assert position.to_query() == "positionCode='R'" 17 | 18 | 19 | def test_forwards(): 20 | position = PositionQuery(position=PositionTypes.ALL_FORWARDS) 21 | assert position.to_query() == "(positionCode='L' or positionCode='R' or positionCode='C')" 22 | 23 | 24 | def test_defense(): 25 | position = PositionQuery(position=PositionTypes.DEFENSE) 26 | assert position.to_query() == "positionCode='D'" 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /nhlpy/api/query/filters/position.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | from enum import Enum 3 | 4 | from nhlpy.api.query.builder import QueryBase 5 | 6 | 7 | class PositionTypes(str, Enum): 8 | ALL_FORWARDS = "F" 9 | CENTER = "C" 10 | LEFT_WING = "L" 11 | RIGHT_WING = "R" 12 | DEFENSE = "D" 13 | 14 | 15 | class PositionQuery(QueryBase): 16 | def __init__(self, position: PositionTypes): 17 | self.position = position 18 | self._position_q = "positionCode" 19 | 20 | def to_query(self) -> str: 21 | # All forwards require an OR clause 22 | if self.position == PositionTypes.ALL_FORWARDS: 23 | return ( 24 | f"({self._position_q}='{PositionTypes.LEFT_WING.value}' " 25 | f"or {self._position_q}='{PositionTypes.RIGHT_WING.value}' " 26 | f"or {self._position_q}='{PositionTypes.CENTER.value}')" 27 | ) 28 | 29 | return f"{self._position_q}='{self.position.value}'" 30 | 31 | def validate(self) -> Union[bool, None]: 32 | return True 33 | -------------------------------------------------------------------------------- /nhlpy/api/query/filters/nationality.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from nhlpy.api.query.builder import QueryBase 4 | 5 | 6 | class NationalityQuery(QueryBase): 7 | """ 8 | Country/Nationality codes can be found via client.misc.countries() endpoint. As of 2/15/24 these are the codes" 9 | [ 10 | "AUS", "AUT", "BEL", "BHS", "BLR", "BRA", 11 | "CAN", "CHE", "CHN", "DEU", "DNK", "EST", 12 | "FIN", "FRA", "GBR", "GRC", "GUY", "HRV", 13 | "HTI", "HUN", "IRL", "ISR", "ITA", "JAM", 14 | "JPN", "KAZ", "KOR", "LBN", "LTU", "LVA", 15 | "MEX", "NGA", "NLD", "NOR", "POL", "PRY", 16 | "ROU", "RUS", "SRB", "SVK", "SVN", "SWE", 17 | "THA", "UKR", "USA", "VEN", "YUG", "ZAF", 18 | "CZE" 19 | ] 20 | 21 | """ 22 | 23 | def __init__(self, nation_code: str): 24 | self.nation_code = nation_code 25 | self._nation_q = "nationalityCode" 26 | 27 | def validate(self) -> Union[bool, None]: 28 | return True 29 | 30 | def to_query(self) -> str: 31 | return f"{self._nation_q}='{self.nation_code}'" 32 | -------------------------------------------------------------------------------- /nhlpy/api/query/filters/status.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from nhlpy.api.query.filters import QueryBase 4 | 5 | # Not thrilled with this implementation, having 2 bools with the later overridding the first. 6 | # Ill think of a better design pattern for this. 7 | 8 | 9 | class StatusQuery(QueryBase): 10 | def __init__(self, is_active: bool = False, is_hall_of_fame: bool = False): 11 | """ 12 | Player status. is_active=True for current active players, not suppling this 13 | defaults to active/inactive. OR you can specify is_hall_of_fame=True, for 14 | only HOF Players 15 | :param is_active: 16 | :param is_hall_of_fame: 17 | """ 18 | self.is_active: bool = is_active 19 | self.is_hall_of_fame: bool = is_hall_of_fame 20 | self._active_q = "active" 21 | self._hof_q = "isInHallOfFame" 22 | 23 | def to_query(self) -> str: 24 | if self.is_hall_of_fame: 25 | return f"{self._hof_q}=1" 26 | elif self.is_active: 27 | return f"{self._active_q}=1" 28 | else: 29 | return "" 30 | 31 | def validate(self) -> Union[bool, None]: 32 | return True 33 | -------------------------------------------------------------------------------- /nhlpy/api/players.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any 2 | 3 | from nhlpy.http_client import Endpoint, HttpClient 4 | 5 | 6 | class Players: 7 | def __init__(self, http_client: HttpClient): 8 | self.client = http_client 9 | 10 | def prospects_by_team(self, team_abbr: str) -> dict: 11 | """Gets prospects for a specific team. 12 | 13 | Args: 14 | team_abbr (str): Team abbreviation (e.g., BUF, TOR) 15 | 16 | Returns: 17 | dict: Prospects data for the specified team. 18 | """ 19 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=f"prospects/{team_abbr}").json() 20 | 21 | def players_by_team(self, team_abbr: str, season: str) -> Dict[str, Any]: 22 | """Get the roster/players for the given team and season. This is the same as teams.roster_by_team(), 23 | but it's a separate endpoint to avoid confusion. 24 | 25 | This method provides the same functionality as teams.roster_by_team(), 26 | offering a convenient way to access team rosters through the Players API. 27 | 28 | Args: 29 | team_abbr (str): Team abbreviation (e.g., BUF, TOR) 30 | season (str): Season in format YYYYYYYY (e.g., 20202021, 20212022) 31 | 32 | Returns: 33 | Dict[str, Any]: Dictionary containing roster information for the specified team and season. 34 | """ 35 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=f"roster/{team_abbr}/{season}").json() 36 | -------------------------------------------------------------------------------- /nhlpy/api/misc.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from nhlpy.http_client import HttpClient, Endpoint 4 | 5 | 6 | class Misc: 7 | def __init__(self, http_client: HttpClient) -> None: 8 | self.client = http_client 9 | 10 | def glossary(self) -> List[dict]: 11 | """Get the glossary for the NHL API. 12 | 13 | Returns: 14 | dict: NHL API glossary data 15 | """ 16 | response = self.client.get(endpoint=Endpoint.API_CORE, resource="stats/rest/en/glossary?sort=fullName").json() 17 | return response.get("data", []) 18 | 19 | def config(self) -> dict: 20 | """Get available filter options. 21 | 22 | Returns: 23 | dict: Dictionary of filter options 24 | """ 25 | return self.client.get(endpoint=Endpoint.API_CORE, resource="stats/rest/en/config").json() 26 | 27 | def countries(self) -> List[dict]: 28 | """Get list of countries from NHL API. 29 | 30 | Returns: 31 | dict: Dictionary of country data 32 | """ 33 | response = self.client.get(endpoint=Endpoint.API_CORE, resource="stats/rest/en/country").json() 34 | return response.get("data", []) 35 | 36 | def season_specific_rules_and_info(self) -> List[dict]: 37 | """Get NHL season rules and information. 38 | 39 | Returns: 40 | dict: Dictionary containing season-specific rules and information 41 | """ 42 | response = self.client.get(endpoint=Endpoint.API_CORE, resource="stats/rest/en/season").json() 43 | return response.get("data", []) 44 | 45 | def draft_year_and_rounds(self) -> List[dict]: 46 | """Get NHL draft year and round information. 47 | 48 | Returns: 49 | dict: Draft data containing 'id', 'draftYear', and 'rounds count' 50 | """ 51 | response = self.client.get(endpoint=Endpoint.API_CORE, resource="stats/rest/en/draft").json() 52 | return response.get("data", []) 53 | -------------------------------------------------------------------------------- /nhlpy/nhl_client.py: -------------------------------------------------------------------------------- 1 | from nhlpy.api import teams, standings, schedule, game_center, stats, misc, helpers, players, edge 2 | from nhlpy.http_client import HttpClient 3 | from nhlpy.config import ClientConfig 4 | 5 | 6 | class NHLClient: 7 | """ 8 | This is the main class that is used to access the NHL API. 9 | 10 | You can instantiate this class and then access the various endpoints of the API, 11 | such as: 12 | client = NHLClient() 13 | client = NHLClient(debug=True) # for a lil extra logging 14 | """ 15 | 16 | def __init__( 17 | self, debug: bool = False, timeout: int = 10, ssl_verify: bool = True, follow_redirects: bool = True 18 | ) -> None: 19 | """ 20 | :param follow_redirects: bool. Some of these endpoints use redirects (ew). This is the case when using 21 | endpoints that use "/now" in them, which will redirect to todays data. 22 | :param debug: bool, Defaults to False. Set to True for extra logging. 23 | :param timeout: int, Defaults to 10 seconds. 24 | :param ssl_verify: bool, Defaults to True. Set to false if you want to ignore SSL verification. 25 | """ 26 | # This config type setup isnt doing what I thought it would. This will be reworked later on. 27 | self._config = ClientConfig( 28 | debug=debug, timeout=timeout, ssl_verify=ssl_verify, follow_redirects=follow_redirects 29 | ) 30 | self._http_client = HttpClient(self._config) 31 | 32 | self.teams = teams.Teams(http_client=self._http_client) 33 | self.standings = standings.Standings(http_client=self._http_client) 34 | self.schedule = schedule.Schedule(http_client=self._http_client) 35 | self.game_center = game_center.GameCenter(http_client=self._http_client) 36 | self.stats = stats.Stats(http_client=self._http_client) 37 | self.misc = misc.Misc(http_client=self._http_client) 38 | self.helpers = helpers.Helpers(http_client=self._http_client) 39 | self.players = players.Players(http_client=self._http_client) 40 | self.edge = edge.Edge(http_client=self._http_client) 41 | -------------------------------------------------------------------------------- /tests/test_players.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from unittest.mock import MagicMock 3 | 4 | 5 | @mock.patch("httpx.Client.get") 6 | def test_prospects_by_team(mock_get, nhl_client): 7 | """Test the prospects_by_team method.""" 8 | mock_response = MagicMock() 9 | mock_response.json.return_value = {"prospects": [{"name": "Test Prospect"}]} 10 | mock_get.return_value = mock_response 11 | 12 | result = nhl_client.players.prospects_by_team(team_abbr="BUF") 13 | 14 | mock_get.assert_called_once() 15 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/prospects/BUF" 16 | assert result == {"prospects": [{"name": "Test Prospect"}]} 17 | 18 | 19 | @mock.patch("httpx.Client.get") 20 | def test_players_by_team(mock_get, nhl_client): 21 | """Test the players_by_team method - should behave identically to teams.roster_by_team.""" 22 | mock_response = MagicMock() 23 | mock_response.json.return_value = {"roster": [{"name": "Test Player"}]} 24 | mock_get.return_value = mock_response 25 | 26 | result = nhl_client.players.players_by_team(team_abbr="BUF", season="20202021") 27 | 28 | mock_get.assert_called_once() 29 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/roster/BUF/20202021" 30 | assert result == {"roster": [{"name": "Test Player"}]} 31 | 32 | 33 | @mock.patch("httpx.Client.get") 34 | def test_players_by_team_same_as_teams_roster(mock_get, nhl_client): 35 | """Test that players_by_team returns the same data as teams.roster_by_team.""" 36 | mock_response = MagicMock() 37 | mock_response.json.return_value = {"roster": [{"name": "Test Player", "position": "C"}]} 38 | mock_get.return_value = mock_response 39 | 40 | # Call both methods with the same parameters 41 | players_result = nhl_client.players.players_by_team(team_abbr="TOR", season="20232024") 42 | teams_result = nhl_client.teams.team_roster(team_abbr="TOR", season="20232024") 43 | 44 | # Both should make the same API call 45 | assert mock_get.call_count == 2 46 | for call in mock_get.call_args_list: 47 | assert call[1]["url"] == "https://api-web.nhle.com/v1/roster/TOR/20232024" 48 | 49 | # Both should return the same result 50 | assert players_result == teams_result 51 | assert players_result == {"roster": [{"name": "Test Player", "position": "C"}]} 52 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to nhl-api-py 2 | 3 | Thank you for considering contributing to nhl-api-py! We appreciate your time and effort in helping us improve the package. The following guidelines will help you understand how to contribute effectively. 4 | 5 | ## How to Contribute 6 | 7 | 1. Fork the repository and create a new branch for your contributions. 8 | 2. Make your changes, enhancements, or bug fixes in the new branch. 9 | 3. Test your changes locally to ensure they are functioning as expected. 10 | 4. Commit your changes with clear and descriptive messages. 11 | 5. Push your changes to your forked repository. 12 | 6. Create a pull request (PR) from your branch to the main repository. 13 | 14 | ## Guidelines for Contributions 15 | 16 | - Before starting any significant work, please open an issue or join an existing discussion to ensure that your contribution aligns with the project's goals and avoids duplication of effort. 17 | - Follow the Python style guide (PEP 8) when writing or modifying code. Maintain consistent code formatting and ensure your changes pass the linting checks. 18 | - Include unit tests to validate the changes you've made. Ensure that all existing tests pass successfully. 19 | - Document any new features, modifications, or bug fixes using the appropriate format in the code and/or in the project's documentation. 20 | - Be responsive to any feedback or comments on your pull request and make necessary updates as requested. 21 | - Respect the code of conduct. Be considerate, inclusive, and respectful in all interactions and communications. 22 | 23 | ## Issue Reporting 24 | 25 | If you encounter any issues, bugs, or have suggestions for improvements, please open a GitHub issue. When reporting an issue, provide as much relevant information as possible, including the steps to reproduce the problem. 26 | 27 | ## Feature Requests 28 | 29 | If you have ideas for new features or enhancements, we encourage you to open an issue on GitHub. Explain your feature request in detail, including its purpose and potential benefits. 30 | 31 | ## Code of Conduct 32 | 33 | By participating in this project, you are expected to adhere to the project's [Code of Conduct](CODE_OF_CONDUCT.md). Please familiarize yourself with it and ensure that all interactions are respectful and inclusive. 34 | 35 | ## Licensing 36 | 37 | Contributions to nhl-api-py are subject to the same license as the main repository. By contributing code, you agree to license your contributions under the project's existing license. 38 | -------------------------------------------------------------------------------- /nhlpy/data/teams_20232024.json: -------------------------------------------------------------------------------- 1 | { 2 | "teams": [ 3 | { "id": "24", "abbreviation": "ANA", "name": "Anaheim Ducks" }, 4 | { "id": "53", "abbreviation": "ARI", "name": "Arizona Coyotes" }, 5 | { "id": "6", "abbreviation": "BOS", "name": "Boston Bruins" }, 6 | { "id": "7", "abbreviation": "BUF", "name": "Buffalo Sabres" }, 7 | { "id": "20", "abbreviation": "CGY", "name": "Calgary Flames" }, 8 | { "id": "12", "abbreviation": "CAR", "name": "Carolina Hurricanes" }, 9 | { "id": "16", "abbreviation": "CHI", "name": "Chicago Blackhawks" }, 10 | { "id": "21", "abbreviation": "COL", "name": "Colorado Avalanche" }, 11 | { "id": "29", "abbreviation": "CBJ", "name": "Columbus Blue Jackets" }, 12 | { "id": "25", "abbreviation": "DAL", "name": "Dallas Stars" }, 13 | { "id": "17", "abbreviation": "DET", "name": "Detroit Red Wings" }, 14 | { "id": "22", "abbreviation": "EDM", "name": "Edmonton Oilers" }, 15 | { "id": "13", "abbreviation": "FLA", "name": "Florida Panthers" }, 16 | { "id": "26", "abbreviation": "LAK", "name": "Los Angeles Kings" }, 17 | { "id": "30", "abbreviation": "MIN", "name": "Minnesota Wild" }, 18 | { "id": "8", "abbreviation": "MTL", "name": "Montreal Canadiens" }, 19 | { "id": "18", "abbreviation": "NSH", "name": "Nashville Predators" }, 20 | { "id": "1", "abbreviation": "NJD", "name": "New Jersey Devils" }, 21 | { "id": "2", "abbreviation": "NYI", "name": "New York Islanders" }, 22 | { "id": "3", "abbreviation": "NYR", "name": "New York Rangers" }, 23 | { "id": "9", "abbreviation": "OTT", "name": "Ottawa Senators" }, 24 | { "id": "4", "abbreviation": "PHI", "name": "Philadelphia Flyers" }, 25 | { "id": "5", "abbreviation": "PIT", "name": "Pittsburgh Penguins" }, 26 | { "id": "28", "abbreviation": "SJS", "name": "San Jose Sharks" }, 27 | { "id": "55", "abbreviation": "SEA", "name": "Seattle Kraken"}, 28 | { "id": "19", "abbreviation": "STL", "name": "St. Louis Blues" }, 29 | { "id": "14", "abbreviation": "TBL", "name": "Tampa Bay Lightning" }, 30 | { "id": "10", "abbreviation": "TOR", "name": "Toronto Maple Leafs" }, 31 | { "id": "23", "abbreviation": "VAN", "name": "Vancouver Canucks" }, 32 | { "id": "54", "abbreviation": "VGK", "name": "Vegas Golden Knights" }, 33 | { "id": "15", "abbreviation": "WSH", "name": "Washington Capitals" }, 34 | { "id": "52", "abbreviation": "WPG", "name": "Winnipeg Jets" }, 35 | { "id": "40", "abbreviation": "UTA", "name": "Utah Hockey Club"} 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /nhlpy/api/query/filters/__init__.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Union, List 3 | 4 | 5 | class QueryBase(ABC): 6 | @abstractmethod 7 | def to_query(self) -> str: 8 | pass 9 | 10 | @abstractmethod 11 | def validate(self) -> Union[bool, None]: 12 | return True 13 | 14 | 15 | def _goalie_stats_sorts(report: str) -> List[dict]: 16 | """ 17 | This is default criteria for sorting on goalie stats. I hate this method 18 | :param report: 19 | :return: 20 | """ 21 | if report == "summary": 22 | return [ 23 | {"property": "wins", "direction": "DESC"}, 24 | {"property": "gamesPlayed", "direction": "ASC"}, 25 | {"property": "playerId", "direction": "ASC"}, 26 | ] 27 | elif report == "advanced": 28 | return [ 29 | {"property": "qualityStart", "direction": "DESC"}, 30 | {"property": "goalsAgainstAverage", "direction": "ASC"}, 31 | {"property": "playerId", "direction": "ASC"}, 32 | ] 33 | elif report == "bios": 34 | return [ 35 | {"property": "lastName", "direction": "ASC_CI"}, 36 | {"property": "goalieFullName", "direction": "ASC_CI"}, 37 | {"property": "playerId", "direction": "ASC"}, 38 | ] 39 | elif report == "daysrest": 40 | return [ 41 | {"property": "wins", "direction": "DESC"}, 42 | {"property": "savePct", "direction": "DESC"}, 43 | {"property": "playerId", "direction": "ASC"}, 44 | ] 45 | elif report == "penaltyShots": 46 | return [ 47 | {"property": "penaltyShotsSaves", "direction": "DESC"}, 48 | {"property": "penaltyShotSavePct", "direction": "DESC"}, 49 | {"property": "playerId", "direction": "ASC"}, 50 | ] 51 | elif report == "savesByStrength": 52 | return [ 53 | {"property": "wins", "direction": "DESC"}, 54 | {"property": "savePct", "direction": "DESC"}, 55 | {"property": "playerId", "direction": "ASC"}, 56 | ] 57 | elif report == "shootout": 58 | return [ 59 | {"property": "shootoutWins", "direction": "DESC"}, 60 | {"property": "shootoutSavePct", "direction": "DESC"}, 61 | {"property": "playerId", "direction": "ASC"}, 62 | ] 63 | elif report == "startedVsRelieved": 64 | return [ 65 | {"property": "gamesStarted", "direction": "DESC"}, 66 | {"property": "gamesStartedSavePct", "direction": "DESC"}, 67 | {"property": "playerId", "direction": "ASC"}, 68 | ] 69 | else: 70 | return [{}] 71 | -------------------------------------------------------------------------------- /nhlpy/api/standings.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from nhlpy.http_client import Endpoint 4 | 5 | 6 | class Standings: 7 | def __init__(self, http_client): 8 | self.client = http_client 9 | 10 | def league_standings(self, date: Optional[str] = None, season: Optional[str] = None) -> dict: 11 | """Gets league standings for a specified season or date. 12 | 13 | Retrieves NHL standings either for a specific date or for the end of a season. 14 | If both parameters are provided, season takes precedence. 15 | 16 | Args: 17 | date (str, optional): Date in YYYY-MM-DD format. Defaults to current date. 18 | season (str, optional): Season identifier to get final standings. 19 | Takes precedence over date parameter if both are provided. 20 | 21 | Returns: 22 | dict: Dictionary containing league standings data 23 | """ 24 | 25 | # We need to look up the last date of the season and use that as the date, since it doesnt seem to take 26 | # season as a param. 27 | if season: 28 | seasons = self.season_standing_manifest() 29 | 30 | season_data = next((s for s in seasons if s.get("id") == int(season)), None) 31 | if not season_data: 32 | raise ValueError(f"Invalid Season Id {season}") 33 | date = season_data.get("standingsEnd") 34 | 35 | res = date if date else "now" 36 | 37 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=f"standings/{res}").json() 38 | 39 | def season_standing_manifest(self) -> List[dict]: 40 | """Gets metadata for all NHL seasons. 41 | Returns information about what seems like every season. Start date, end date, etc. 42 | 43 | Args: 44 | None 45 | 46 | Returns: 47 | dict: Season metadata including dates, conference/division usage, and scoring rules. 48 | 49 | Example: 50 | Response format: 51 | [{ 52 | "id": 20232024, 53 | "conferencesInUse": true, 54 | "divisionsInUse": true, 55 | "pointForOTlossInUse": true, 56 | "regulationWinsInUse": true, 57 | "rowInUse": true, 58 | "standingsEnd": "2023-11-10", 59 | "standingsStart": "2023-10-10", 60 | "tiesInUse": false, 61 | "wildcardInUse": true 62 | }] 63 | """ 64 | response = self.client.get(endpoint=Endpoint.API_WEB_V1, resource="standings-season").json() 65 | return response.get("seasons", []) 66 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry-core"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | [tool.poetry] 6 | name = "nhl-api-py" 7 | version = "3.1.1" 8 | description = "NHL API (Updated for 2025/2026) and EDGE Stats. For standings, team stats, outcomes, player information. Contains each individual API endpoint as well as convience methods as well as pythonic query builder for more indepth EDGE stats." 9 | authors = ["Corey Schaf "] 10 | readme = "README.md" 11 | packages = [{include = "nhlpy"}] 12 | license = "GPL-3.0-or-later" 13 | homepage = "https://github.com/coreyjs/nhl-api-py" 14 | repository = "https://github.com/coreyjs/nhl-api-py" 15 | keywords = ["nhl", "api", "wrapper", "hockey", "sports", "edge", "edge stats", "edge analytics", "edge sports", 16 | "edge hockey", "edge nhl", "edge nhl stats", "edge nhl analytics", "edge nhl sports", "edge nhl hockey", 17 | "edge nhl data", "edge nhl data analytics", "edge nhl data stats", "edge nhl data sports", "edge nhl data hockey", 18 | "edge nhl data stats analytics", "edge nhl data stats sports", "edge nhl data stats hockey", "hockey ai", "hockey machine learning", "nhl ML", "nhl AI", 19 | "nhl machine learning", "nhl stats", "nhl analytics", "nhl sports", "nhl hockey", "nhl nhl", "nhl nhl stats", "nhl nhl analytics", "nhl nhl sports", 20 | "edge nhl data hockey stats"] 21 | classifiers = [ 22 | "Development Status :: 5 - Production/Stable", 23 | "Intended Audience :: Developers", 24 | "Intended Audience :: Education", 25 | "License :: OSI Approved :: MIT License", 26 | "Programming Language :: Python :: 3", 27 | "Programming Language :: Python :: 3.9", 28 | "Programming Language :: Python :: 3.10", 29 | "Programming Language :: Python :: 3.11", 30 | "Programming Language :: Python :: 3.12", 31 | "Programming Language :: Python :: 3 :: Only", 32 | "Topic :: Software Development :: Libraries :: Python Modules", 33 | "Topic :: Software Development :: Libraries", 34 | "Topic :: Software Development :: Libraries :: Application Frameworks", 35 | "Topic :: Software Development :: Libraries :: Python Modules" 36 | ] 37 | include = [ 38 | "nhlpy/data/*", 39 | ] 40 | 41 | [tool.poetry.dependencies] 42 | python = "^3.9" 43 | httpx = "*" 44 | 45 | [tool.poetry.group.dev.dependencies] 46 | pytest="^7.1.3" 47 | pytest-mock = "*" 48 | mypy = "*" 49 | ruff = "*" 50 | black = "*" 51 | ipykernel = "*" 52 | 53 | [tool.ruff] 54 | exclude = [ 55 | ".bzr", 56 | ".direnv", 57 | ".eggs", 58 | ".git", 59 | ".git-rewrite", 60 | ".hg", 61 | ".mypy_cache", 62 | ".nox", 63 | ".pants.d", 64 | ".pytype", 65 | ".ruff_cache", 66 | ".svn", 67 | ".tox", 68 | ".venv", 69 | "__pypackages__", 70 | "_build", 71 | "buck-out", 72 | "build", 73 | "dist", 74 | "node_modules", 75 | "venv", 76 | ] 77 | line-length = 121 78 | 79 | 80 | 81 | [tool.black] 82 | line-length = 121 -------------------------------------------------------------------------------- /tests/query/test_builder.py: -------------------------------------------------------------------------------- 1 | from nhlpy.api.query.builder import QueryBuilder, QueryContext 2 | from nhlpy.api.query.filters.decision import DecisionQuery 3 | from nhlpy.api.query.filters.draft import DraftQuery 4 | from nhlpy.api.query.filters.game_type import GameTypeQuery 5 | from nhlpy.api.query.filters.position import PositionQuery, PositionTypes 6 | from nhlpy.api.query.filters.season import SeasonQuery 7 | 8 | 9 | def test_query_builder_empty_filters(): 10 | qb = QueryBuilder() 11 | context: QueryContext = qb.build(filters=[]) 12 | 13 | assert context.query_str == "" 14 | 15 | 16 | def test_query_builder_invalid_filter(): 17 | qb = QueryBuilder() 18 | context: QueryContext = qb.build(filters=["invalid"]) 19 | 20 | assert context.query_str == "" 21 | 22 | 23 | def test_qb_draft_year(): 24 | qb = QueryBuilder() 25 | filters = [DraftQuery(year="2020", draft_round="2")] 26 | context: QueryContext = qb.build(filters=filters) 27 | 28 | assert context.query_str == "draftYear=2020 and draftRound=2" 29 | assert len(context.filters) == 1 30 | 31 | 32 | def test_qb_multi_filter(): 33 | qb = QueryBuilder() 34 | filters = [ 35 | GameTypeQuery(game_type="2"), 36 | DraftQuery(year="2020", draft_round="2"), 37 | SeasonQuery(season_start="20202021", season_end="20232024"), 38 | ] 39 | context: QueryContext = qb.build(filters=filters) 40 | 41 | assert ( 42 | context.query_str 43 | == "gameTypeId=2 and draftYear=2020 and draftRound=2 and seasonId >= 20202021 and seasonId <= 20232024" 44 | ) 45 | 46 | 47 | def test_position_draft_query(): 48 | qb = QueryBuilder() 49 | filters = [ 50 | GameTypeQuery(game_type="2"), 51 | DraftQuery(year="2020", draft_round="1"), 52 | PositionQuery(position=PositionTypes.CENTER), 53 | ] 54 | context: QueryContext = qb.build(filters=filters) 55 | 56 | assert context.query_str == "gameTypeId=2 and draftYear=2020 and draftRound=1 and positionCode='C'" 57 | assert len(context.filters) == 3 58 | 59 | 60 | def test_all_forwards_playoffs_season_query(): 61 | qb = QueryBuilder() 62 | filters = [ 63 | GameTypeQuery(game_type="3"), 64 | SeasonQuery(season_start="20222023", season_end="20222023"), 65 | PositionQuery(position=PositionTypes.ALL_FORWARDS), 66 | ] 67 | context: QueryContext = qb.build(filters=filters) 68 | 69 | assert ( 70 | context.query_str 71 | == "gameTypeId=3 and seasonId >= 20222023 and seasonId <= 20222023 and (positionCode='L' or positionCode='R' " 72 | "or positionCode='C')" 73 | ) 74 | assert len(context.filters) == 3 75 | 76 | 77 | def test_query_with_invalid_filter_mixed_in(): 78 | qb = QueryBuilder(debug=True) 79 | filters = [ 80 | GameTypeQuery(game_type="3"), 81 | SeasonQuery(season_start="20222023", season_end="20222023"), 82 | PositionQuery(position=PositionTypes.ALL_FORWARDS), 83 | DecisionQuery(decision="Win"), 84 | ] 85 | context: QueryContext = qb.build(filters=filters) 86 | 87 | assert context.is_valid() is False 88 | 89 | assert ( 90 | context.query_str 91 | == "gameTypeId=3 and seasonId >= 20222023 and seasonId <= 20222023 and (positionCode='L' or positionCode='R' " 92 | "or positionCode='C')" 93 | ) 94 | -------------------------------------------------------------------------------- /tests/test_game_center.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | 4 | @mock.patch("httpx.Client.get") 5 | def test_boxscore(h_m, nhl_client): 6 | nhl_client.game_center.boxscore(game_id="2020020001") 7 | h_m.assert_called_once() 8 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/gamecenter/2020020001/boxscore" 9 | 10 | 11 | @mock.patch("httpx.Client.get") 12 | def test_play_by_play(h_m, nhl_client): 13 | nhl_client.game_center.play_by_play(game_id="2020020001") 14 | h_m.assert_called_once() 15 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/gamecenter/2020020001/play-by-play" 16 | 17 | 18 | @mock.patch("httpx.Client.get") 19 | def test_match_up(h_m, nhl_client): 20 | nhl_client.game_center.match_up(game_id="2020020001") 21 | h_m.assert_called_once() 22 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/gamecenter/2020020001/landing" 23 | 24 | 25 | @mock.patch("httpx.Client.get") 26 | def test_daily_scores_now(h_m, nhl_client): 27 | nhl_client.game_center.daily_scores() 28 | h_m.assert_called_once() 29 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/score/now" 30 | 31 | 32 | @mock.patch("httpx.Client.get") 33 | def test_daily_scores_with_date(h_m, nhl_client): 34 | nhl_client.game_center.daily_scores(date="2023-10-15") 35 | h_m.assert_called_once() 36 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/score/2023-10-15" 37 | 38 | 39 | @mock.patch("httpx.Client.get") 40 | def test_shift_chart_data_default_excludes(h_m, nhl_client): 41 | nhl_client.game_center.shift_chart_data(game_id="2020020001") 42 | h_m.assert_called_once() 43 | assert ( 44 | h_m.call_args[1]["url"] == "https://api.nhle.com/stats/rest/en/shiftcharts?cayenneExp=gameId=2020020001 and " 45 | "((duration != '00:00' and typeCode = 517) or typeCode != 517 )&exclude=eventDetails" 46 | ) 47 | 48 | 49 | @mock.patch("httpx.Client.get") 50 | def test_shift_chart_data_custom_excludes(h_m, nhl_client): 51 | nhl_client.game_center.shift_chart_data(game_id="2020020001", excludes=["eventDetails", "playerStats"]) 52 | h_m.assert_called_once() 53 | assert ( 54 | h_m.call_args[1]["url"] == "https://api.nhle.com/stats/rest/en/shiftcharts?cayenneExp=gameId=2020020001 and " 55 | "((duration != '00:00' and typeCode = 517) or typeCode != 517 )&exclude=eventDetails,playerStats" 56 | ) 57 | 58 | 59 | @mock.patch("httpx.Client.get") 60 | def test_shift_chart_data_empty_excludes(h_m, nhl_client): 61 | nhl_client.game_center.shift_chart_data(game_id="2020020001", excludes=[]) 62 | h_m.assert_called_once() 63 | assert ( 64 | h_m.call_args[1]["url"] == "https://api.nhle.com/stats/rest/en/shiftcharts?cayenneExp=gameId=2020020001 and " 65 | "((duration != '00:00' and typeCode = 517) or typeCode != 517 )&exclude=" 66 | ) 67 | 68 | 69 | @mock.patch("httpx.Client.get") 70 | def test_season_series_matchup(h_m, nhl_client): 71 | nhl_client.game_center.season_series_matchup(game_id="2020020001") 72 | h_m.assert_called_once() 73 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/gamecenter/2020020001/right-rail" 74 | 75 | 76 | @mock.patch("httpx.Client.get") 77 | def test_game_story(h_m, nhl_client): 78 | nhl_client.game_center.game_story(game_id="2020020001") 79 | h_m.assert_called_once() 80 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/wsc/game-story/2020020001" 81 | -------------------------------------------------------------------------------- /nhlpy/api/query/builder.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | import logging 3 | 4 | from nhlpy.api.query import InvalidQueryValueException 5 | from nhlpy.api.query.filters import QueryBase 6 | 7 | 8 | class QueryContext: 9 | """A container for query information and validation state. 10 | 11 | This class holds the constructed query string, original filters, any validation 12 | errors, and a base fact query. It provides methods to check query validity. 13 | 14 | Attributes: 15 | query_str (str): The constructed query string from all valid filters 16 | filters (List[QueryBase]): List of original query filter objects 17 | errors (List[str]): List of validation error messages 18 | fact_query (str): Base fact query, defaults to "gamesPlayed>=1" 19 | """ 20 | 21 | def __init__(self, query: str, filters: List[QueryBase], fact_query: str = None, errors: List[str] = None): 22 | self.query_str = query 23 | self.filters = filters 24 | self.errors = errors 25 | self.fact_query = fact_query if fact_query else "gamesPlayed>=1" 26 | 27 | def is_valid(self) -> bool: 28 | """Check if the query context is valid. 29 | 30 | Returns: 31 | bool: True if there are no validation errors, False otherwise 32 | """ 33 | return len(self.errors) == 0 34 | 35 | 36 | class QueryBuilder: 37 | """Builds and validates query strings from a list of query filters. 38 | 39 | This class processes a list of QueryBase filters, validates them, and combines 40 | them into a single query string. It handles validation errors and provides 41 | optional verbose logging. 42 | 43 | Attributes: 44 | debug (bool): When True, enables detailed logging of the build process 45 | """ 46 | 47 | def __init__(self, debug: bool = False): 48 | self.debug = debug 49 | if self.debug: 50 | logging.basicConfig(level=logging.INFO) 51 | 52 | def build(self, filters: List[QueryBase]) -> QueryContext: 53 | """Build a query string from a list of filters. 54 | 55 | Processes each filter in the list, validates it, and combines valid filters 56 | into a single query string using 'and' as the connector. 57 | 58 | Args: 59 | filters (List[QueryBase]): List of query filter objects to process 60 | 61 | Returns: 62 | QueryContext: A context object containing the query string, original filters, 63 | and any validation errors 64 | 65 | Notes: 66 | - Skips filters that aren't instances of QueryBase 67 | - Collects validation errors but continues processing remaining filters 68 | - Combines valid filters with 'and' operator 69 | - Returns empty query string if no valid filters are found 70 | """ 71 | result_query: str = "" 72 | output_filters: List[str] = [] 73 | errors: List[str] = [] 74 | for f in filters: 75 | if not isinstance(f, QueryBase): 76 | if self.debug: 77 | logging.info(f"Input filter is not of type QueryBase: {f.__name__}") 78 | continue 79 | 80 | # Validate the filter 81 | try: 82 | if not f.validate(): 83 | raise InvalidQueryValueException(f"Filter failed validation: {str(f)}") 84 | except InvalidQueryValueException as e: 85 | if self.debug: 86 | logging.error(e) 87 | errors.append(str(e)) 88 | continue 89 | 90 | output_filters.append(f.to_query()) 91 | else: 92 | if len(output_filters) > 0: 93 | result_query = " and ".join(output_filters) 94 | 95 | return QueryContext(query=result_query, filters=filters, errors=errors) 96 | -------------------------------------------------------------------------------- /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: NHL-API-PY 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | test-ruff-black: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | # If you wanted to use multiple Python versions, you'd have specify a matrix in the job and 22 | # reference the matrixe python version here. 23 | - uses: actions/setup-python@v5 24 | with: 25 | python-version: 3.9.18 26 | 27 | # Cache the installation of Poetry itself, e.g. the next step. This prevents the workflow 28 | # from installing Poetry every time, which can be slow. Note the use of the Poetry version 29 | # number in the cache key, and the "-0" suffix: this allows you to invalidate the cache 30 | # manually if/when you want to upgrade Poetry, or if something goes wrong. This could be 31 | # mildly cleaner by using an environment variable, but I don't really care. 32 | - name: cache poetry install 33 | uses: actions/cache@v4 34 | with: 35 | path: ~/.local 36 | key: poetry-1.5.1-0 37 | 38 | # Install Poetry. You could do this manually, or there are several actions that do this. 39 | # `snok/install-poetry` seems to be minimal yet complete, and really just calls out to 40 | # Poetry's default install script, which feels correct. I pin the Poetry version here 41 | # because Poetry does occasionally change APIs between versions and I don't want my 42 | # actions to break if it does. 43 | # 44 | # The key configuration value here is `virtualenvs-in-project: true`: this creates the 45 | # venv as a `.venv` in your testing directory, which allows the next step to easily 46 | # cache it. 47 | - uses: snok/install-poetry@v1 48 | with: 49 | version: 1.5.1 50 | virtualenvs-create: true 51 | virtualenvs-in-project: true 52 | 53 | # Cache your dependencies (i.e. all the stuff in your `pyproject.toml`). Note the cache 54 | # key: if you're using multiple Python versions, or multiple OSes, you'd need to include 55 | # them in the cache key. I'm not, so it can be simple and just depend on the poetry.lock. 56 | - name: cache deps 57 | id: cache-deps 58 | uses: actions/cache@v3 59 | with: 60 | path: .venv 61 | key: pydeps-${{ hashFiles('**/poetry.lock') }} 62 | 63 | # Install dependencies. `--no-root` means "install all dependencies but not the project 64 | # itself", which is what you want to avoid caching _your_ code. The `if` statement 65 | # ensures this only runs on a cache miss. 66 | - run: poetry install --no-interaction --no-root 67 | if: steps.cache-deps.outputs.cache-hit != 'true' 68 | 69 | # Now install _your_ project. This isn't necessary for many types of projects -- particularly 70 | # things like Django apps don't need this. But it's a good idea since it fully-exercises the 71 | # pyproject.toml and makes that if you add things like console-scripts at some point that 72 | # they'll be installed and working. 73 | - run: poetry install --no-interaction 74 | 75 | # And finally run tests. I'm using pytest and all my pytest config is in my `pyproject.toml` 76 | # so this line is super-simple. But it could be as complex as you need. 77 | - run: poetry run pytest 78 | 79 | # run a check for black 80 | - name: poetry run black . --check 81 | run: poetry run black . --check 82 | 83 | # run a lint check with ruff 84 | - name: poetry run ruff check 85 | run: poetry run ruff check . -------------------------------------------------------------------------------- /tests/test_schedule.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | 6 | @mock.patch("httpx.Client.get") 7 | def test_get_schedule_with_date(h_m, nhl_client): 8 | nhl_client.schedule.daily_schedule(date="2021-01-01") 9 | h_m.assert_called_once() 10 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/schedule/2021-01-01" 11 | 12 | 13 | @mock.patch("httpx.Client.get") 14 | def test_get_schedule_with_fixable_date(h_m, nhl_client): 15 | nhl_client.schedule.daily_schedule("2024-10-9") 16 | h_m.assert_called_once() 17 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/schedule/2024-10-09" 18 | 19 | 20 | @mock.patch("httpx.Client.get") 21 | def test_get_schedule_will_error_with_bad_date(h_m, nhl_client): 22 | with pytest.raises(ValueError): 23 | nhl_client.schedule.daily_schedule("2024-10-09-") 24 | 25 | 26 | @mock.patch("httpx.Client.get") 27 | def test_get_weekly_schedule_with_date(h_m, nhl_client): 28 | nhl_client.schedule.weekly_schedule(date="2021-01-01") 29 | h_m.assert_called_once() 30 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/schedule/2021-01-01" 31 | 32 | 33 | @mock.patch("httpx.Client.get") 34 | def test_get_weekly_schedule_with_no_date(h_m, nhl_client): 35 | nhl_client.schedule.weekly_schedule() 36 | h_m.assert_called_once() 37 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/schedule/now" 38 | 39 | 40 | @mock.patch("httpx.Client.get") 41 | def test_get_schedule_by_team_by_month_with_month(h_m, nhl_client): 42 | nhl_client.schedule.team_monthly_schedule(team_abbr="BUF", month="2023-11") 43 | h_m.assert_called_once() 44 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/club-schedule/BUF/month/2023-11" 45 | 46 | 47 | @mock.patch("httpx.Client.get") 48 | def test_get_schedule_by_team_by_month_with_no_month(h_m, nhl_client): 49 | nhl_client.schedule.team_monthly_schedule(team_abbr="BUF") 50 | h_m.assert_called_once() 51 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/club-schedule/BUF/month/now" 52 | 53 | 54 | @mock.patch("httpx.Client.get") 55 | def test_get_schedule_by_team_by_week(h_m, nhl_client): 56 | nhl_client.schedule.team_weekly_schedule(team_abbr="BUF") 57 | h_m.assert_called_once() 58 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/club-schedule/BUF/week/now" 59 | 60 | 61 | @mock.patch("httpx.Client.get") 62 | def test_get_schedule_by_team_by_week_with_date(h_m, nhl_client): 63 | nhl_client.schedule.team_weekly_schedule(team_abbr="BUF", date="2024-02-10") 64 | h_m.assert_called_once() 65 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/club-schedule/BUF/week/2024-02-10" 66 | 67 | 68 | @mock.patch("httpx.Client.get") 69 | def test_get_season_schedule(h_m, nhl_client): 70 | nhl_client.schedule.team_season_schedule(team_abbr="BUF", season="20202021") 71 | h_m.assert_called_once() 72 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/club-schedule-season/BUF/20202021" 73 | 74 | 75 | @mock.patch("httpx.Client.get") 76 | def test_carousel(h_m, nhl_client): 77 | nhl_client.schedule.playoff_carousel(season="20232024") 78 | h_m.assert_called_once() 79 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/playoff-series/carousel/20232024" 80 | 81 | 82 | @mock.patch("httpx.Client.get") 83 | def test_schedule(h_m, nhl_client): 84 | nhl_client.schedule.playoff_series_schedule(season="20232024", series="a") 85 | h_m.assert_called_once() 86 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/schedule/playoff-series/20232024/a" 87 | 88 | 89 | @mock.patch("httpx.Client.get") 90 | def test_bracket(h_m, nhl_client): 91 | nhl_client.schedule.playoff_bracket(year="2024") 92 | h_m.assert_called_once() 93 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/playoff-bracket/2024" 94 | -------------------------------------------------------------------------------- /nhlpy/api/game_center.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | from nhlpy.http_client import HttpClient, Endpoint 3 | 4 | 5 | class GameCenter: 6 | def __init__(self, http_client: HttpClient): 7 | self.client = http_client 8 | 9 | def boxscore(self, game_id: str) -> dict: 10 | """Get boxscore data for a specific NHL game. GameIds can be retrieved from the schedule endpoint. 11 | 12 | Args: 13 | game_id (str): The game_id for the game you want the boxscore for 14 | 15 | Example: 16 | API endpoint format: https://api-web.nhle.com/v1/gamecenter/2023020280/boxscore 17 | 18 | Returns: 19 | dict: Game boxscore data 20 | """ 21 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=f"gamecenter/{game_id}/boxscore").json() 22 | 23 | def play_by_play(self, game_id: str) -> dict: 24 | """Get play-by-play data for a specific NHL game. GameIds can be retrieved from the schedule endpoint. 25 | 26 | Args: 27 | game_id (str): The game_id for the game you want the play by play for 28 | 29 | Returns: 30 | dict: Play-by-play game data 31 | """ 32 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=f"gamecenter/{game_id}/play-by-play").json() 33 | 34 | def match_up(self, game_id: str) -> dict: 35 | """Get detailed match up information for a specific NHL game. GameIds can be retrieved 36 | from the schedule endpoint. 37 | 38 | Args: 39 | game_id (str): The game_id for the game you want the landing page for 40 | 41 | Returns: 42 | dict: Detailed game matchup data 43 | """ 44 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=f"gamecenter/{game_id}/landing").json() 45 | 46 | def daily_scores(self, date: Optional[str] = None) -> dict: 47 | """Get scores for NHL games on a specific date or current day. 48 | 49 | Args: 50 | date (str, optional): Date to check scores in YYYY-MM-DD format. 51 | If not provided, returns current day's scores. 52 | 53 | Returns: 54 | dict: Game scores and status information 55 | """ 56 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=f"score/{date if date else 'now'}").json() 57 | 58 | def shift_chart_data(self, game_id: str, excludes: List[str] = None) -> dict: 59 | """Gets shift chart data for a specific game. 60 | 61 | Args: 62 | game_id (str): ID of the game to retrieve shift data for. Game IDs can be retrieved 63 | from the schedule endpoint. 64 | excludes (List[str]): List of items to exclude from the response. 65 | 66 | Returns: 67 | Dict containing the shift chart data. 68 | """ 69 | if excludes is None: 70 | excludes = ["eventDetails"] 71 | 72 | exclude_p: str = ",".join(excludes) 73 | expr_p: str = f"gameId={game_id} and ((duration != '00:00' and typeCode = 517) or typeCode != 517 )" 74 | return self.client.get( 75 | endpoint=Endpoint.API_STATS, resource=f"en/shiftcharts?cayenneExp={expr_p}&exclude={exclude_p}" 76 | ).json() 77 | 78 | def season_series_matchup(self, game_id: str) -> dict: 79 | """Gets game stats and season series information for a specific game. 80 | 81 | Args: 82 | game_id (str): ID of the game to retrieve stats for. Game IDs can be retrieved 83 | from the schedule endpoint. 84 | 85 | Returns: 86 | Dict containing game stats and season series data. 87 | """ 88 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=f"gamecenter/{game_id}/right-rail").json() 89 | 90 | def game_story(self, game_id: str) -> dict: 91 | """Gets game story information for a specific game. 92 | 93 | Args: 94 | game_id (str): ID of the game to retrieve story for. Game IDs can be retrieved 95 | from the schedule endpoint. 96 | 97 | Returns: 98 | Dict containing game story data. 99 | """ 100 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=f"wsc/game-story/{game_id}").json() 101 | -------------------------------------------------------------------------------- /nhlpy/api/helpers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from typing import List, Any 4 | 5 | from nhlpy.api.query.builder import QueryBuilder 6 | from nhlpy.api.query.filters.franchise import FranchiseQuery 7 | from nhlpy.api.query.filters.season import SeasonQuery 8 | from nhlpy.api.stats import Stats 9 | from nhlpy.api.teams import Teams 10 | from nhlpy.http_client import HttpClient 11 | 12 | 13 | class Helpers: 14 | def __init__(self, http_client: HttpClient) -> None: 15 | self.client = http_client 16 | 17 | def _clean_name(self, ntype, name): 18 | """ 19 | Clean the name of the player or team 20 | """ 21 | return name[ntype]["default"] 22 | 23 | def game_ids_by_season(self, season: str, game_types: List[int] = None, api_sleep_rate: float = 1) -> List[str]: 24 | """Gets all game IDs for a specified season. 25 | 26 | Args: 27 | season (str): Season to retrieve game IDs for in YYYYYYYY format (e.g., 20232024). 28 | game_types (List[int]): List of game types to include. Valid types: 29 | 1: Preseason 30 | 2: Regular season 31 | 3: Playoffs 32 | api_sleep_rate (float): Sleep rate in seconds between API calls to avoid hitting rate limits. 33 | 34 | Returns: 35 | List of game IDs for the specified season and game types. 36 | """ 37 | from nhlpy.api.teams import Teams 38 | from nhlpy.api.schedule import Schedule 39 | 40 | teams = Teams(self.client).teams() 41 | 42 | game_ids = [] 43 | schedule_api = Schedule(self.client) 44 | for team in teams: 45 | team_abbr = team.get("abbr", "") 46 | if not team_abbr: 47 | continue 48 | 49 | time.sleep(api_sleep_rate) 50 | schedule = schedule_api.team_season_schedule(team_abbr, season) 51 | games = schedule.get("games", []) 52 | 53 | for game in games: 54 | game_type = game.get("gameType") 55 | game_id = game.get("id") 56 | 57 | if game_id and (not game_types or game_type in game_types): 58 | game_ids.append(game_id) 59 | 60 | return game_ids 61 | 62 | def all_players(self, season: str, api_sleep_rate: float = 0.5) -> List[dict[str, Any]]: 63 | """Gets all player base stats. 64 | 65 | Args: 66 | api_sleep_rate (float): Sleep rate in seconds between API calls to avoid hitting rate limits. 67 | 68 | Returns: 69 | List of player base stats. 70 | """ 71 | from nhlpy.api.teams import Teams 72 | 73 | teams_client = Teams(self.client) 74 | teams = teams_client.teams() 75 | 76 | print("Fetching all player base stats. This may take a while...") 77 | out_data = [] 78 | for team in teams: 79 | time.sleep(api_sleep_rate) 80 | players = teams_client.roster_by_team(team_abbr=team["abbr"], season=season) 81 | 82 | # Tweak and clean some player data 83 | for p in players["forwards"] + players["defensemen"] + players["goalies"]: 84 | p["team"] = team["abbr"] 85 | p["firstName"] = self._clean_name("firstName", p) 86 | p["lastName"] = self._clean_name("lastName", p) 87 | 88 | out_data.append(p) 89 | 90 | return out_data 91 | 92 | def all_players_summary_statistics(self, season: str, api_sleep_rate: float = 1): 93 | """Gets all player summary statistics for a specified season.""" 94 | logging.warning( 95 | "This method will take a while to run. In the event of rate limiting, you may need to increase the api_sleep_rate." 96 | ) 97 | players = self.all_players(season, api_sleep_rate=api_sleep_rate) 98 | teams = Teams(self.client).teams() 99 | stats_client = Stats(self.client) 100 | 101 | season_query = SeasonQuery(season_start=season, season_end=season) 102 | query_builder = QueryBuilder() 103 | 104 | out_data = [] 105 | for team in teams: 106 | time.sleep(api_sleep_rate) 107 | fran_query = FranchiseQuery(franchise_id=team["franchise_id"]) 108 | context = query_builder.build(filters=[fran_query, season_query]) 109 | 110 | data = stats_client.skater_stats_with_query_context( 111 | report_type="summary", query_context=context, aggregate=True 112 | ) 113 | out_data.extend(data.get("data", [])) 114 | 115 | # Create a dictionary for fast player lookup by id 116 | player_dict = {player["id"]: player for player in players} 117 | 118 | # Merge player data with stats data 119 | merged_data = [] 120 | for stat_entry in out_data: 121 | player_id = stat_entry.get("playerId") 122 | if player_id and player_id in player_dict: 123 | # Merge player data with stats data 124 | merged_entry = {**player_dict[player_id], **stat_entry} 125 | merged_data.append(merged_entry) 126 | else: 127 | # Include stats entry even if no matching player found 128 | merged_data.append(stat_entry) 129 | 130 | return merged_data 131 | -------------------------------------------------------------------------------- /nhlpy/api/query/sorting/sorting_options.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | skater_summary_default_sorting = [ 7 | {"property": "points", "direction": "DESC"}, 8 | {"property": "gamesPlayed", "direction": "ASC"}, 9 | {"property": "playerId", "direction": "ASC"}, 10 | ] 11 | 12 | skater_bios_default_sorting = [ 13 | {"property": "lastName", "direction": "ASC_CI"}, 14 | {"property": "skaterFullName", "direction": "ASC_CI"}, 15 | {"property": "playerId", "direction": "ASC"}, 16 | ] 17 | 18 | faceoffs_default_sorting = [ 19 | {"property": "totalFaceoffs", "direction": "DESC"}, 20 | {"property": "playerId", "direction": "ASC"}, 21 | ] 22 | 23 | faceoff_wins_default_sorting = [ 24 | {"property": "totalFaceoffWins", "direction": "DESC"}, 25 | {"property": "faceoffWinPct", "direction": "DESC"}, 26 | {"property": "playerId", "direction": "ASC"}, 27 | ] 28 | 29 | goalsForAgainst_default_sorting = [ 30 | {"property": "evenStrengthGoalDifference", "direction": "DESC"}, 31 | {"property": "playerId", "direction": "ASC"}, 32 | ] 33 | 34 | 35 | realtime_default_sorting = [{"property": "hits", "direction": "DESC"}, {"property": "playerId", "direction": "ASC"}] 36 | 37 | penalties_default_sorting = [ 38 | {"property": "penaltyMinutes", "direction": "DESC"}, 39 | {"property": "playerId", "direction": "ASC"}, 40 | ] 41 | 42 | penaltyKill_default_sorting = [ 43 | {"property": "shTimeOnIce", "direction": "DESC"}, 44 | {"property": "playerId", "direction": "ASC"}, 45 | ] 46 | 47 | penalty_shot_default_sorting = [ 48 | {"property": "penaltyShotsGoals", "direction": "DESC"}, 49 | {"property": "playerId", "direction": "ASC"}, 50 | ] 51 | 52 | powerplay_default_sorting = [ 53 | {"property": "ppTimeOnIce", "direction": "DESC"}, 54 | {"property": "playerId", "direction": "ASC"}, 55 | ] 56 | 57 | puckposs_default_sorting = [{"property": "satPct", "direction": "DESC"}, {"property": "playerId", "direction": "ASC"}] 58 | 59 | summary_shooting_default_sorting = [ 60 | {"property": "satTotal", "direction": "DESC"}, 61 | {"property": "usatTotal", "direction": "DESC"}, 62 | {"property": "playerId", "direction": "ASC"}, 63 | ] 64 | 65 | percentages_default_sorting = [ 66 | {"property": "satPercentage", "direction": "DESC"}, 67 | {"property": "playerId", "direction": "ASC"}, 68 | ] 69 | 70 | scoringratesdefault_sorting = [ 71 | {"property": "pointsPer605v5", "direction": "DESC"}, 72 | {"property": "goalsPer605v5", "direction": "DESC"}, 73 | {"property": "playerId", "direction": "ASC"}, 74 | ] 75 | 76 | scoring_per_game_default_sorting = [ 77 | {"property": "pointsPerGame", "direction": "DESC"}, 78 | {"property": "goalsPerGame", "direction": "DESC"}, 79 | {"property": "playerId", "direction": "ASC"}, 80 | ] 81 | 82 | shootout_default_scoring = [ 83 | {"property": "shootoutGoals", "direction": "DESC"}, 84 | {"property": "playerId", "direction": "ASC"}, 85 | ] 86 | 87 | shottype_default_sorting = [ 88 | {"property": "shootingPct", "direction": "DESC"}, 89 | {"property": "shootingPctBat", "direction": "DESC"}, 90 | {"property": "playerId", "direction": "ASC"}, 91 | ] 92 | 93 | 94 | time_on_ice_default_sorting = [ 95 | {"property": "timeOnIce", "direction": "DESC"}, 96 | {"property": "playerId", "direction": "ASC"}, 97 | ] 98 | 99 | 100 | class SortingOptions: 101 | @staticmethod 102 | def get_default_sorting_for_report(report: str) -> List[dict]: 103 | """ 104 | I know this us ugly. But hopefully its out of sight out of mind. 105 | :param report: 106 | :return: 107 | """ 108 | if report == "summary": 109 | return skater_summary_default_sorting 110 | elif report == "bios": 111 | return skater_bios_default_sorting 112 | elif report == "faceoffpercentages": 113 | return faceoffs_default_sorting 114 | elif report == "faceoffwins": 115 | return faceoff_wins_default_sorting 116 | elif report == "goalsForAgainst": 117 | return goalsForAgainst_default_sorting 118 | elif report == "realtime": 119 | return realtime_default_sorting 120 | elif report == "penalties": 121 | return penalties_default_sorting 122 | elif report == "penaltykill": 123 | return penaltyKill_default_sorting 124 | elif report == "penaltyShots": 125 | return penalty_shot_default_sorting 126 | elif report == "powerplay": 127 | return powerplay_default_sorting 128 | elif report == "puckPossessions": 129 | return puckposs_default_sorting 130 | elif report == "summaryshooting": 131 | return summary_shooting_default_sorting 132 | elif report == "percentages": 133 | return percentages_default_sorting 134 | elif report == "scoringRates": 135 | return scoringratesdefault_sorting 136 | elif report == "scoringpergame": 137 | return scoring_per_game_default_sorting 138 | elif report == "shootout": 139 | return shootout_default_scoring 140 | elif report == "shottype": 141 | return shottype_default_sorting 142 | elif report == "timeonice": 143 | return time_on_ice_default_sorting 144 | else: 145 | logger.info("No default sort criteria setup for this report type, defaulting to skater summary") 146 | return skater_summary_default_sorting 147 | -------------------------------------------------------------------------------- /tests/test_stats.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | 4 | @mock.patch("httpx.Client.get") 5 | def test_stats_season(h_m, nhl_client): 6 | nhl_client.stats.gametypes_per_season_directory_by_team(team_abbr="BUF") 7 | h_m.assert_called_once() 8 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/club-stats-season/BUF" 9 | 10 | 11 | @mock.patch("httpx.Client.get") 12 | def test_player_career_stats(h_m, nhl_client): 13 | nhl_client.stats.player_career_stats(player_id=8481528) 14 | h_m.assert_called_once() 15 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/player/8481528/landing" 16 | 17 | 18 | @mock.patch("httpx.Client.get") 19 | def test_team_summary_single_year(h_m, nhl_client): 20 | nhl_client.stats.team_summary(start_season="20232024", end_season="20232024") 21 | h_m.assert_called_once() 22 | assert h_m.call_args[1]["url"] == "https://api.nhle.com/stats/rest/en/team/summary" 23 | assert h_m.call_args[1]["params"] == { 24 | "isAggregate": False, 25 | "isGame": False, 26 | "limit": 50, 27 | "start": 0, 28 | "factCayenneExp": "gamesPlayed>1", 29 | "sort": '[{"property": "points", "direction": "DESC"}, {"property": "wins", "direction": "DESC"}, ' 30 | '{"property": "teamId", "direction": "ASC"}]', 31 | "cayenneExp": "gameTypeId=2 and seasonId<=20232024 and seasonId>=20232024", 32 | } 33 | 34 | 35 | @mock.patch("httpx.Client.get") 36 | def team_test_summary_year_range(h_m, nhl_client): 37 | nhl_client.stats.team_summary(start_season="20202021", end_season="20232024") 38 | h_m.assert_called_once() 39 | assert h_m.call_args[1]["url"] == "https://api.nhle.com/stats/rest/en/team/summary" 40 | assert h_m.call_args[1]["params"] == { 41 | "isAggregate": False, 42 | "isGame": False, 43 | "limit": 50, 44 | "start": 0, 45 | "factCayenneExp": "gamesPlayed>1", 46 | "sort": "%5B%7B%22property%22%3A%20%22points%22%2C%20%22direction%22%3A%20%22DESC%22%7D%2C%20%7B%22" 47 | "property%22%3A%20%22wins%22%2C%20%22direction%22%3A%20%22DESC%22%7D%2C%20%7B%22property%22" 48 | "%3A%20%22teamId%22%2C%20%22direction%22%3A%20%22ASC%22%7D%5D", 49 | "cayenneExp": "gameTypeId=2 and seasonId<=20232024 and seasonId>=20202021", 50 | } 51 | 52 | 53 | @mock.patch("httpx.Client.get") 54 | def team_test_summary_year_range_playoffs(h_m, nhl_client): 55 | nhl_client.stats.team_summary(start_season="20182019", end_season="20222023", game_type_id=3) 56 | h_m.assert_called_once() 57 | assert h_m.call_args[1]["url"] == "https://api.nhle.com/stats/rest/en/team/summary" 58 | assert h_m.call_args[1]["params"] == { 59 | "isAggregate": False, 60 | "isGame": False, 61 | "limit": 50, 62 | "start": 0, 63 | "factCayenneExp": "gamesPlayed>1", 64 | "sort": "%5B%7B%22property%22%3A%20%22points%22%2C%20%22direction%22%3A%20%22DESC%22%7D%2C%20%7" 65 | "B%22property%22%3A%20%22wins%22%2C%20%22direction%22%3A%20%22DESC%22%7D%2C%20%7B%22pro" 66 | "perty%22%3A%20%22teamId%22%2C%20%22direction%22%3A%20%22ASC%22%7D%5D", 67 | "cayenneExp": "gameTypeId=3 and seasonId<=20222023 and seasonId>=20182019", 68 | } 69 | 70 | 71 | @mock.patch("httpx.Client.get") 72 | def test_skater_stats_summary(h_m, nhl_client): 73 | nhl_client.stats.skater_stats_summary(start_season="20232024", end_season="20232024") 74 | h_m.assert_called_once() 75 | assert h_m.call_args[1]["url"] == "https://api.nhle.com/stats/rest/en/skater/summary" 76 | assert h_m.call_args[1]["params"] == { 77 | "isAggregate": False, 78 | "isGame": False, 79 | "limit": 25, 80 | "start": 0, 81 | "factCayenneExp": "gamesPlayed>=1", 82 | "sort": '[{"property": "points", "direction": "DESC"}, {"property": ' 83 | '"gamesPlayed", "direction": "ASC"}, {"property": "playerId", ' 84 | '"direction": "ASC"}]', 85 | "cayenneExp": "gameTypeId=2 and seasonId<=20232024 and seasonId>=20232024", 86 | } 87 | 88 | 89 | @mock.patch("httpx.Client.get") 90 | def test_skater_stats_summary_franchise(h_m, nhl_client): 91 | nhl_client.stats.skater_stats_summary(start_season="20232024", end_season="20232024", franchise_id=19) 92 | h_m.assert_called_once() 93 | assert h_m.call_args[1]["url"] == "https://api.nhle.com/stats/rest/en/skater/summary" 94 | assert h_m.call_args[1]["params"] == { 95 | "isAggregate": False, 96 | "isGame": False, 97 | "limit": 25, 98 | "start": 0, 99 | "factCayenneExp": "gamesPlayed>=1", 100 | "sort": '[{"property": "points", "direction": "DESC"}, {"property": ' 101 | '"gamesPlayed", "direction": "ASC"}, {"property": "playerId", ' 102 | '"direction": "ASC"}]', 103 | "cayenneExp": "franchiseId=19 and gameTypeId=2 and seasonId<=20232024 and seasonId>=20232024", 104 | } 105 | 106 | 107 | @mock.patch("httpx.Client.get") 108 | def test_player_game_log(h_m, nhl_client): 109 | nhl_client.stats.player_game_log(player_id="8481528", season_id="20232024", game_type=2) 110 | h_m.assert_called_once() 111 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/player/8481528/game-log/20232024/2" 112 | 113 | 114 | @mock.patch("httpx.Client.get") 115 | def test_player_game_log_playoffs(h_m, nhl_client): 116 | nhl_client.stats.player_game_log(player_id="8481528", season_id="20232024", game_type=3) 117 | h_m.assert_called_once() 118 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/player/8481528/game-log/20232024/3" 119 | -------------------------------------------------------------------------------- /tests/test_nhl_client.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import Mock, patch 3 | from nhlpy.nhl_client import NHLClient 4 | from nhlpy.api import teams, standings, schedule 5 | from nhlpy.http_client import ( 6 | NHLApiException, 7 | ResourceNotFoundException, 8 | RateLimitExceededException, 9 | ServerErrorException, 10 | BadRequestException, 11 | UnauthorizedException, 12 | HttpClient, 13 | Endpoint, 14 | ) 15 | 16 | 17 | class MockResponse: 18 | """Mock httpx.Response for testing""" 19 | 20 | def __init__(self, status_code, json_data=None): 21 | self.status_code = status_code 22 | self._json_data = json_data or {} 23 | self.url = "https://api.nhle.com/test" 24 | 25 | def json(self): 26 | return self._json_data 27 | 28 | @property 29 | def is_success(self): 30 | return 200 <= self.status_code < 300 31 | 32 | 33 | @pytest.fixture 34 | def mock_config(): 35 | """Fixture for config object""" 36 | config = Mock() 37 | config.debug = False 38 | config.ssl_verify = True 39 | config.timeout = 30 40 | config.follow_redirects = True 41 | config.api_web_base_url = "https://api.nhl.com" 42 | config.api_web_api_ver = "/v1" 43 | return config 44 | 45 | 46 | @pytest.fixture 47 | def http_client(mock_config): 48 | """Fixture for HttpClient instance""" 49 | return HttpClient(mock_config) 50 | 51 | 52 | def test_nhl_client_responds_to_teams(): 53 | c = NHLClient() 54 | assert c.teams is not None 55 | assert isinstance(c.teams, teams.Teams) 56 | 57 | 58 | def test_nhl_client_responds_to_standings(): 59 | c = NHLClient() 60 | assert c.standings is not None 61 | assert isinstance(c.standings, standings.Standings) 62 | 63 | 64 | def test_nhl_client_responds_to_schedule(): 65 | c = NHLClient() 66 | assert c.schedule is not None 67 | assert isinstance(c.schedule, schedule.Schedule) 68 | 69 | 70 | @pytest.mark.parametrize( 71 | "status_code,expected_exception", 72 | [ 73 | (404, ResourceNotFoundException), 74 | (429, RateLimitExceededException), 75 | (400, BadRequestException), 76 | (401, UnauthorizedException), 77 | (500, ServerErrorException), 78 | (502, ServerErrorException), 79 | (599, NHLApiException), 80 | ], 81 | ) 82 | def test_http_client_error_handling(http_client, status_code, expected_exception): 83 | """Test different HTTP error status codes raise appropriate exceptions""" 84 | mock_response = MockResponse(status_code=status_code, json_data={"message": "Test error message"}) 85 | 86 | with patch("httpx.Client") as mock_client: 87 | mock_client.return_value.__enter__.return_value.get.return_value = mock_response 88 | 89 | with pytest.raises(expected_exception) as exc_info: 90 | http_client.get(endpoint=Endpoint.API_CORE, resource="/test") 91 | 92 | assert exc_info.value.status_code == status_code 93 | assert "Test error message" in str(exc_info.value) 94 | 95 | 96 | def test_http_client_success_response(http_client): 97 | """Test successful HTTP response""" 98 | mock_response = MockResponse(status_code=200, json_data={"data": "test"}) 99 | 100 | with patch("httpx.Client") as mock_client: 101 | mock_client.return_value.__enter__.return_value.get.return_value = mock_response 102 | response = http_client.get(endpoint=Endpoint.API_CORE, resource="/test") 103 | assert response.status_code == 200 104 | 105 | 106 | def test_http_client_non_json_error_response(http_client): 107 | """Test error response with non-JSON body still works""" 108 | mock_response = MockResponse(status_code=500) 109 | mock_response.json = Mock(side_effect=ValueError) # Simulate JSON decode error 110 | 111 | with patch("httpx.Client") as mock_client: 112 | mock_client.return_value.__enter__.return_value.get.return_value = mock_response 113 | 114 | with pytest.raises(ServerErrorException) as exc_info: 115 | http_client.get(endpoint=Endpoint.API_CORE, resource="test") 116 | 117 | assert exc_info.value.status_code == 500 118 | assert "Request to" in str(exc_info.value) 119 | 120 | 121 | def test_http_client_get_by_url_with_params(http_client): 122 | """Test get_by_url method with query parameters""" 123 | mock_response = MockResponse(status_code=200, json_data={"data": "test"}) 124 | query_params = {"season": "20232024"} 125 | 126 | with patch("httpx.Client") as mock_client: 127 | mock_instance = mock_client.return_value.__enter__.return_value 128 | mock_instance.get.return_value = mock_response 129 | 130 | response = http_client.get(endpoint=Endpoint.API_CORE, resource="test", query_params=query_params) 131 | 132 | mock_instance.get.assert_called_once_with(url="https://api.nhle.com/test", params=query_params) 133 | assert response.status_code == 200 134 | 135 | 136 | def test_http_client_custom_error_message(http_client): 137 | """Test custom error message in JSON response""" 138 | custom_message = "Custom API error explanation" 139 | mock_response = MockResponse(status_code=400, json_data={"message": custom_message}) 140 | 141 | with patch("httpx.Client") as mock_client: 142 | mock_client.return_value.__enter__.return_value.get.return_value = mock_response 143 | 144 | with pytest.raises(BadRequestException) as exc_info: 145 | http_client.get(endpoint=Endpoint.API_CORE, resource="/test") 146 | 147 | assert custom_message in str(exc_info.value) 148 | -------------------------------------------------------------------------------- /nhlpy/http_client.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Optional 3 | 4 | import httpx 5 | import logging 6 | 7 | 8 | class Endpoint(Enum): 9 | API_WEB_V1 = "https://api-web.nhle.com/v1/" 10 | API_CORE = "https://api.nhle.com/" 11 | API_STATS = "https://api.nhle.com/stats/rest/" 12 | 13 | 14 | class NHLApiErrorCode(Enum): 15 | """Enum for NHL API specific error codes if any""" 16 | 17 | RESOURCE_NOT_FOUND = "RESOURCE_NOT_FOUND" 18 | RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED" 19 | SERVER_ERROR = "SERVER_ERROR" 20 | BAD_REQUEST = "BAD_REQUEST" 21 | UNAUTHORIZED = "UNAUTHORIZED" 22 | 23 | 24 | class NHLApiException(Exception): 25 | """Base exception for NHL API errors""" 26 | 27 | def __init__(self, message: str, status_code: int, error_code: Optional[NHLApiErrorCode] = None): 28 | self.message = message 29 | self.status_code = status_code 30 | self.error_code = error_code 31 | super().__init__(self.message) 32 | 33 | 34 | class ResourceNotFoundException(NHLApiException): 35 | """Raised when a resource is not found (404)""" 36 | 37 | def __init__(self, message: str, status_code: int = 404): 38 | super().__init__(message, status_code, NHLApiErrorCode.RESOURCE_NOT_FOUND) 39 | 40 | 41 | class RateLimitExceededException(NHLApiException): 42 | """Raised when rate limit is exceeded (429)""" 43 | 44 | def __init__(self, message: str, status_code: int = 429): 45 | super().__init__(message, status_code, NHLApiErrorCode.RATE_LIMIT_EXCEEDED) 46 | 47 | 48 | class ServerErrorException(NHLApiException): 49 | """Raised for server errors (5xx)""" 50 | 51 | def __init__(self, message: str, status_code: int): 52 | super().__init__(message, status_code, NHLApiErrorCode.SERVER_ERROR) 53 | 54 | 55 | class BadRequestException(NHLApiException): 56 | """Raised for client errors (400)""" 57 | 58 | def __init__(self, message: str, status_code: int = 400): 59 | super().__init__(message, status_code, NHLApiErrorCode.BAD_REQUEST) 60 | 61 | 62 | class UnauthorizedException(NHLApiException): 63 | """Raised for authentication errors (401)""" 64 | 65 | def __init__(self, message: str, status_code: int = 401): 66 | super().__init__(message, status_code, NHLApiErrorCode.UNAUTHORIZED) 67 | 68 | 69 | class HttpClient: 70 | def __init__(self, config) -> None: 71 | self._config = config 72 | self._logger = logging.getLogger(__name__) 73 | if self._config.debug: 74 | self._logger.setLevel(logging.DEBUG) 75 | if not self._logger.handlers: 76 | handler = logging.StreamHandler() 77 | formatter = logging.Formatter("%(name)s - %(levelname)s - %(message)s") 78 | handler.setFormatter(formatter) 79 | self._logger.addHandler(handler) 80 | else: 81 | self._logger.setLevel(logging.WARNING) 82 | 83 | def _handle_response(self, response: httpx.Response, url: str) -> None: 84 | """Handle different HTTP status codes and raise appropriate exceptions""" 85 | 86 | if response.is_success: 87 | return 88 | 89 | # Build error message 90 | error_message = f"Request to {url} failed" 91 | try: 92 | response_json = response.json() 93 | if isinstance(response_json, dict): 94 | error_detail = response_json.get("message") 95 | if error_detail: 96 | error_message = f"{error_message}: {error_detail}" 97 | except Exception: 98 | # If response isn't JSON or doesn't have a message field 99 | pass 100 | 101 | if response.status_code == 404: 102 | raise ResourceNotFoundException(error_message) 103 | elif response.status_code == 429: 104 | raise RateLimitExceededException(error_message) 105 | elif response.status_code == 400: 106 | raise BadRequestException(error_message) 107 | elif response.status_code == 401: 108 | raise UnauthorizedException(error_message) 109 | elif 500 <= response.status_code < 600: 110 | raise ServerErrorException(error_message, response.status_code) 111 | else: 112 | raise NHLApiException(f"Unexpected error: {error_message}", response.status_code) 113 | 114 | def get(self, endpoint: Endpoint, resource: str, query_params: dict = None) -> httpx.Response: 115 | """ 116 | Private method to make a get request to the NHL API. This wraps the lib httpx functionality. 117 | :param query_params: 118 | :param endpoint: 119 | :param resource: 120 | :return: httpx.Response 121 | :raises: 122 | ResourceNotFoundException: When the resource is not found 123 | RateLimitExceededException: When rate limit is exceeded 124 | ServerErrorException: When server returns 5xx error 125 | BadRequestException: When request is malformed 126 | UnauthorizedException: When authentication fails 127 | NHLApiException: For other unexpected errors 128 | 129 | url=f"{self._config.api_web_base_url}{self._config.api_web_api_ver}{resource}" 130 | ) 131 | """ 132 | with httpx.Client( 133 | verify=self._config.ssl_verify, timeout=self._config.timeout, follow_redirects=self._config.follow_redirects 134 | ) as client: 135 | full_url = f"{endpoint.value}{resource}" 136 | if self._config.debug: 137 | self._logger.debug(f"GET: {full_url}") 138 | r: httpx.Response = client.get(url=full_url, params=query_params) 139 | 140 | self._handle_response(r, resource) 141 | return r 142 | -------------------------------------------------------------------------------- /nhlpy/data/team_stat_ids.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "id": 1, 5 | "fullName": "Montréal Canadiens", 6 | "teamCommonName": "Canadiens", 7 | "teamPlaceName": "Montréal" 8 | }, 9 | { 10 | "id": 2, 11 | "fullName": "Montreal Wanderers", 12 | "teamCommonName": "Wanderers", 13 | "teamPlaceName": "Montreal" 14 | }, 15 | { 16 | "id": 3, 17 | "fullName": "St. Louis Eagles", 18 | "teamCommonName": "Eagles", 19 | "teamPlaceName": "St. Louis" 20 | }, 21 | { 22 | "id": 4, 23 | "fullName": "Hamilton Tigers", 24 | "teamCommonName": "Tigers", 25 | "teamPlaceName": "Hamilton" 26 | }, 27 | { 28 | "id": 5, 29 | "fullName": "Toronto Maple Leafs", 30 | "teamCommonName": "Maple Leafs", 31 | "teamPlaceName": "Toronto" 32 | }, 33 | { 34 | "id": 6, 35 | "fullName": "Boston Bruins", 36 | "teamCommonName": "Bruins", 37 | "teamPlaceName": "Boston" 38 | }, 39 | { 40 | "id": 7, 41 | "fullName": "Montreal Maroons", 42 | "teamCommonName": "Maroons", 43 | "teamPlaceName": "Montreal" 44 | }, 45 | { 46 | "id": 8, 47 | "fullName": "Brooklyn Americans", 48 | "teamCommonName": "Americans", 49 | "teamPlaceName": "Brooklyn" 50 | }, 51 | { 52 | "id": 9, 53 | "fullName": "Philadelphia Quakers", 54 | "teamCommonName": "Quakers", 55 | "teamPlaceName": "Philadelphia" 56 | }, 57 | { 58 | "id": 10, 59 | "fullName": "New York Rangers", 60 | "teamCommonName": "Rangers", 61 | "teamPlaceName": "New York" 62 | }, 63 | { 64 | "id": 11, 65 | "fullName": "Chicago Blackhawks", 66 | "teamCommonName": "Blackhawks", 67 | "teamPlaceName": "Chicago" 68 | }, 69 | { 70 | "id": 12, 71 | "fullName": "Detroit Red Wings", 72 | "teamCommonName": "Red Wings", 73 | "teamPlaceName": "Detroit" 74 | }, 75 | { 76 | "id": 13, 77 | "fullName": "Cleveland Barons", 78 | "teamCommonName": "Barons", 79 | "teamPlaceName": "Cleveland" 80 | }, 81 | { 82 | "id": 14, 83 | "fullName": "Los Angeles Kings", 84 | "teamCommonName": "Kings", 85 | "teamPlaceName": "Los Angeles" 86 | }, 87 | { 88 | "id": 15, 89 | "fullName": "Dallas Stars", 90 | "teamCommonName": "Stars", 91 | "teamPlaceName": "Dallas" 92 | }, 93 | { 94 | "id": 16, 95 | "fullName": "Philadelphia Flyers", 96 | "teamCommonName": "Flyers", 97 | "teamPlaceName": "Philadelphia" 98 | }, 99 | { 100 | "id": 17, 101 | "fullName": "Pittsburgh Penguins", 102 | "teamCommonName": "Penguins", 103 | "teamPlaceName": "Pittsburgh" 104 | }, 105 | { 106 | "id": 18, 107 | "fullName": "St. Louis Blues", 108 | "teamCommonName": "Blues", 109 | "teamPlaceName": "St. Louis" 110 | }, 111 | { 112 | "id": 19, 113 | "fullName": "Buffalo Sabres", 114 | "teamCommonName": "Sabres", 115 | "teamPlaceName": "Buffalo" 116 | }, 117 | { 118 | "id": 20, 119 | "fullName": "Vancouver Canucks", 120 | "teamCommonName": "Canucks", 121 | "teamPlaceName": "Vancouver" 122 | }, 123 | { 124 | "id": 21, 125 | "fullName": "Calgary Flames", 126 | "teamCommonName": "Flames", 127 | "teamPlaceName": "Calgary" 128 | }, 129 | { 130 | "id": 22, 131 | "fullName": "New York Islanders", 132 | "teamCommonName": "Islanders", 133 | "teamPlaceName": "New York" 134 | }, 135 | { 136 | "id": 23, 137 | "fullName": "New Jersey Devils", 138 | "teamCommonName": "Devils", 139 | "teamPlaceName": "New Jersey" 140 | }, 141 | { 142 | "id": 24, 143 | "fullName": "Washington Capitals", 144 | "teamCommonName": "Capitals", 145 | "teamPlaceName": "Washington" 146 | }, 147 | { 148 | "id": 25, 149 | "fullName": "Edmonton Oilers", 150 | "teamCommonName": "Oilers", 151 | "teamPlaceName": "Edmonton" 152 | }, 153 | { 154 | "id": 26, 155 | "fullName": "Carolina Hurricanes", 156 | "teamCommonName": "Hurricanes", 157 | "teamPlaceName": "Carolina" 158 | }, 159 | { 160 | "id": 27, 161 | "fullName": "Colorado Avalanche", 162 | "teamCommonName": "Avalanche", 163 | "teamPlaceName": "Colorado" 164 | }, 165 | { 166 | "id": 28, 167 | "fullName": "Arizona Coyotes", 168 | "teamCommonName": "Coyotes", 169 | "teamPlaceName": "Arizona" 170 | }, 171 | { 172 | "id": 29, 173 | "fullName": "San Jose Sharks", 174 | "teamCommonName": "Sharks", 175 | "teamPlaceName": "San Jose" 176 | }, 177 | { 178 | "id": 30, 179 | "fullName": "Ottawa Senators", 180 | "teamCommonName": "Senators", 181 | "teamPlaceName": "Ottawa" 182 | }, 183 | { 184 | "id": 31, 185 | "fullName": "Tampa Bay Lightning", 186 | "teamCommonName": "Lightning", 187 | "teamPlaceName": "Tampa Bay" 188 | }, 189 | { 190 | "id": 32, 191 | "fullName": "Anaheim Ducks", 192 | "teamCommonName": "Ducks", 193 | "teamPlaceName": "Anaheim" 194 | }, 195 | { 196 | "id": 33, 197 | "fullName": "Florida Panthers", 198 | "teamCommonName": "Panthers", 199 | "teamPlaceName": "Florida" 200 | }, 201 | { 202 | "id": 34, 203 | "fullName": "Nashville Predators", 204 | "teamCommonName": "Predators", 205 | "teamPlaceName": "Nashville" 206 | }, 207 | { 208 | "id": 35, 209 | "fullName": "Winnipeg Jets", 210 | "teamCommonName": "Jets", 211 | "teamPlaceName": "Winnipeg" 212 | }, 213 | { 214 | "id": 36, 215 | "fullName": "Columbus Blue Jackets", 216 | "teamCommonName": "Blue Jackets", 217 | "teamPlaceName": "Columbus" 218 | }, 219 | { 220 | "id": 37, 221 | "fullName": "Minnesota Wild", 222 | "teamCommonName": "Wild", 223 | "teamPlaceName": "Minnesota" 224 | }, 225 | { 226 | "id": 38, 227 | "fullName": "Vegas Golden Knights", 228 | "teamCommonName": "Golden Knights", 229 | "teamPlaceName": "Vegas" 230 | }, 231 | { 232 | "id": 39, 233 | "fullName": "Seattle Kraken", 234 | "teamCommonName": "Kraken", 235 | "teamPlaceName": "Seattle" 236 | }, 237 | { 238 | "id": 40, 239 | "fullName": "Utah Hockey Club", 240 | "teamCommonName": "Hockey Club", 241 | "teamPlaceName": "Utah" 242 | } 243 | ], 244 | "total": 40 245 | } -------------------------------------------------------------------------------- /tests/test_teams.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | from unittest.mock import MagicMock 3 | 4 | 5 | @mock.patch("httpx.Client.get") 6 | def test_roster(h_m, nhl_client): 7 | nhl_client.teams.team_roster(team_abbr="BUF", season="20202021") 8 | h_m.assert_called_once() 9 | assert h_m.call_args[1]["url"] == "https://api-web.nhle.com/v1/roster/BUF/20202021" 10 | 11 | 12 | @mock.patch("httpx.Client.get") 13 | def test_all_teams(mock_get, nhl_client): 14 | # Create mock responses for the two API calls 15 | 16 | # Mock response for standings data 17 | standings_mock_response = MagicMock() 18 | standings_mock_response.json.return_value = { 19 | "standings": [ 20 | { 21 | "conferenceAbbrev": "E", 22 | "conferenceName": "Eastern", 23 | "divisionAbbrev": "A", 24 | "divisionName": "Atlantic", 25 | "teamName": {"default": "Boston Bruins"}, 26 | "teamCommonName": {"default": "Bruins"}, 27 | "teamAbbrev": {"default": "BOS"}, 28 | "teamLogo": "https://assets.nhle.com/logos/nhl/svg/BOS_light.svg", 29 | }, 30 | { 31 | "conferenceAbbrev": "W", 32 | "conferenceName": "Western", 33 | "divisionAbbrev": "C", 34 | "divisionName": "Central", 35 | "teamName": {"default": "Colorado Avalanche"}, 36 | "teamCommonName": {"default": "Avalanche"}, 37 | "teamAbbrev": {"default": "COL"}, 38 | "teamLogo": "https://assets.nhle.com/logos/nhl/svg/COL_light.svg", 39 | }, 40 | { 41 | "conferenceAbbrev": "E", 42 | "conferenceName": "Eastern", 43 | "divisionAbbrev": "A", 44 | "divisionName": "Atlantic", 45 | "teamName": {"default": "Montreal Canadiens"}, 46 | "teamCommonName": {"default": "Canadiens"}, 47 | "teamAbbrev": {"default": "MTL"}, 48 | "teamLogo": "https://assets.nhle.com/logos/nhl/svg/MTL_light.svg", 49 | }, 50 | { 51 | "conferenceAbbrev": "E", 52 | "conferenceName": "Eastern", 53 | "divisionAbbrev": "M", 54 | "divisionName": "Metropolitan", 55 | "teamName": {"default": "New Team"}, 56 | "teamCommonName": {"default": "New Team"}, 57 | "teamAbbrev": {"default": "NEW"}, 58 | "teamLogo": "https://assets.nhle.com/logos/nhl/svg/NEW_light.svg", 59 | }, 60 | ] 61 | } 62 | 63 | # Mock response for franchise data 64 | franchise_mock_response = MagicMock() 65 | franchise_mock_response.json.return_value = { 66 | "data": [ 67 | {"id": 6, "fullName": "Boston Bruins", "teamCommonName": "Bruins"}, 68 | {"id": 27, "fullName": "Colorado Avalanche", "teamCommonName": "Avalanche"}, 69 | { 70 | "id": 1, 71 | "fullName": "Montréal Canadiens", # Note the accent, different from "Montreal Canadiens" 72 | "teamCommonName": "Canadiens", 73 | }, 74 | # No entry for "New Team" - testing case where franchise ID is not found 75 | ] 76 | } 77 | 78 | # Configure the mock to return different responses based on the URL 79 | def side_effect(url, **kwargs): 80 | if "standings" in url: 81 | return standings_mock_response 82 | elif "franchise" in url: 83 | return franchise_mock_response 84 | return MagicMock() 85 | 86 | mock_get.side_effect = side_effect 87 | 88 | # Call the method being tested 89 | teams = nhl_client.teams.teams() 90 | 91 | # Verify the mock was called twice with the correct URLs 92 | assert mock_get.call_count == 2 93 | calls = mock_get.call_args_list 94 | assert "standings/now" in calls[0][1]["url"] 95 | assert "franchise" in calls[1][1]["url"] 96 | 97 | # Verify the output contains the expected data 98 | assert len(teams) == 4 99 | 100 | # Check first team - direct match 101 | assert teams[0]["name"] == "Boston Bruins" 102 | assert teams[0]["common_name"] == "Bruins" 103 | assert teams[0]["abbr"] == "BOS" 104 | assert teams[0]["conference"]["abbr"] == "E" 105 | assert teams[0]["conference"]["name"] == "Eastern" 106 | assert teams[0]["division"]["abbr"] == "A" 107 | assert teams[0]["division"]["name"] == "Atlantic" 108 | assert teams[0]["franchise_id"] == 6 109 | 110 | # Check second team - direct match 111 | assert teams[1]["name"] == "Colorado Avalanche" 112 | assert teams[1]["common_name"] == "Avalanche" 113 | assert teams[1]["abbr"] == "COL" 114 | assert teams[1]["conference"]["abbr"] == "W" 115 | assert teams[1]["conference"]["name"] == "Western" 116 | assert teams[1]["division"]["abbr"] == "C" 117 | assert teams[1]["division"]["name"] == "Central" 118 | assert teams[1]["franchise_id"] == 27 119 | 120 | # Check third team - special case for Canadiens (partial match) 121 | assert teams[2]["name"] == "Montreal Canadiens" 122 | assert teams[2]["common_name"] == "Canadiens" 123 | assert teams[2]["abbr"] == "MTL" 124 | assert teams[2]["conference"]["abbr"] == "E" 125 | assert teams[2]["conference"]["name"] == "Eastern" 126 | assert teams[2]["division"]["abbr"] == "A" 127 | assert teams[2]["division"]["name"] == "Atlantic" 128 | assert teams[2]["franchise_id"] == 1 # Should find the franchise ID despite different spelling 129 | 130 | # Check fourth team - no franchise ID match 131 | assert teams[3]["name"] == "New Team" 132 | assert teams[3]["common_name"] == "New Team" 133 | assert teams[3]["abbr"] == "NEW" 134 | assert teams[3]["conference"]["abbr"] == "E" 135 | assert teams[3]["conference"]["name"] == "Eastern" 136 | assert teams[3]["division"]["abbr"] == "M" 137 | assert teams[3]["division"]["name"] == "Metropolitan" 138 | assert "franchise_id" not in teams[3] # Should not have a franchise_id 139 | -------------------------------------------------------------------------------- /nhlpy/api/teams.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict, Optional, Any 2 | from nhlpy.http_client import Endpoint, HttpClient 3 | 4 | 5 | # @dataclass 6 | # class TeamInfo: 7 | # """Data class for team information.""" 8 | # 9 | # name: str 10 | # common_name: str 11 | # abbr: str 12 | # logo: str 13 | # conference: Dict[str, str] 14 | # division: Dict[str, str] 15 | # franchise_id: Optional[int] = None 16 | 17 | 18 | class Teams: 19 | """NHL Teams API client.""" 20 | 21 | # Constants for API endpoints 22 | # NHL_WEB_API_BASE = "https://api-web.nhle.com" 23 | # NHL_STATS_API_BASE = "https://api.nhle.com/stats/rest" 24 | 25 | def __init__(self, http_client: HttpClient) -> None: 26 | self.client = http_client 27 | # self.base_url = "https://api.nhle.com" 28 | self.api_ver = "/stats/rest/" 29 | 30 | def _fetch_standings_data(self, date: str) -> List[Dict[str, Any]]: 31 | """Fetch standings data from NHL API.""" 32 | response = self.client.get(endpoint=Endpoint.API_WEB_V1, resource=f"standings/{date}").json() 33 | return response.get("standings", []) 34 | 35 | def _parse_teams_from_standings(self, standings_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]: 36 | """Parse team information from standings data.""" 37 | teams = [] 38 | 39 | for team_data in standings_data: 40 | team = self._create_team_dict(team_data) 41 | teams.append(team) 42 | 43 | return teams 44 | 45 | def _create_team_dict(self, team_data: Dict[str, Any]) -> Dict[str, Any]: 46 | """Create a standardized team dictionary from API data.""" 47 | return { 48 | "conference": {"abbr": team_data.get("conferenceAbbrev", ""), "name": team_data.get("conferenceName", "")}, 49 | "division": {"abbr": team_data.get("divisionAbbrev", ""), "name": team_data.get("divisionName", "")}, 50 | "name": self._extract_nested_default(team_data, "teamName"), 51 | "common_name": self._extract_nested_default(team_data, "teamCommonName"), 52 | "abbr": self._extract_nested_default(team_data, "teamAbbrev"), 53 | "logo": team_data.get("teamLogo", ""), 54 | } 55 | 56 | def _extract_nested_default(self, data: Dict[str, Any], key: str) -> str: 57 | """Extract default value from nested dictionary structure.""" 58 | return data.get(key, {}).get("default", "") 59 | 60 | def _enrich_teams_with_franchise_ids(self, teams: List[Dict[str, Any]]) -> None: 61 | """Add franchise IDs to teams using franchise data.""" 62 | franchises = self.franchises() 63 | franchise_lookup = self._create_franchise_lookup(franchises) 64 | 65 | for team in teams: 66 | team_name = team.get("name", "") 67 | franchise_id = self._find_franchise_id(team_name, franchise_lookup) 68 | if franchise_id: 69 | team["franchise_id"] = franchise_id 70 | 71 | def _create_franchise_lookup(self, franchises: List[Dict[str, Any]]) -> Dict[str, int]: 72 | """Create a lookup dictionary for franchise names to IDs.""" 73 | lookup = {} 74 | for franchise in franchises: 75 | full_name = franchise.get("fullName", "") 76 | franchise_id = franchise.get("id") 77 | if full_name and franchise_id: 78 | lookup[full_name] = franchise_id 79 | return lookup 80 | 81 | def _find_franchise_id(self, team_name: str, franchise_lookup: Dict[str, int]) -> Optional[int]: 82 | """Find franchise ID for a given team name.""" 83 | # Direct match first 84 | if team_name in franchise_lookup: 85 | return franchise_lookup[team_name] 86 | 87 | # Special case for Canadiens (could be made configurable) 88 | if "Canadiens" in team_name: 89 | for franchise_name, franchise_id in franchise_lookup.items(): 90 | if "Canadiens" in franchise_name: 91 | return franchise_id 92 | elif "Utah" in team_name: 93 | for franchise_name, franchise_id in franchise_lookup.items(): 94 | if "Utah" in franchise_name: 95 | return franchise_id 96 | 97 | return None 98 | 99 | def teams(self, date: str = "now") -> List[Dict[str, Any]]: 100 | """Get a list of all NHL teams with their conference, division, and franchise information. 101 | 102 | Args: 103 | date: Date in format YYYY-MM-DD. Defaults to "now". 104 | Note that while the NHL API uses "now" to default to the current date, 105 | during preseason this may default to last year's season. To get accurate 106 | teams for the current season, supply a date (YYYY-MM-DD) at the start of 107 | the upcoming season. For example: 108 | - 2024-04-18 for season 2023-2024 109 | - 2024-10-04 for season 2024-2025 110 | 111 | Returns: 112 | List of dictionaries containing team information including conference, 113 | division, and franchise ID. Data is aggregated from the current standings 114 | API and joined with franchise information. 115 | 116 | Note: 117 | Updated in 2.10.0: Now pulls from current standings API, aggregates team 118 | conference/division data, and joins with franchise ID. This workaround is 119 | necessary due to NHL API limitations preventing this data from being retrieved 120 | in a single request. 121 | """ 122 | standings_data = self._fetch_standings_data(date) 123 | teams = self._parse_teams_from_standings(standings_data) 124 | self._enrich_teams_with_franchise_ids(teams) 125 | return teams 126 | 127 | def team_roster(self, team_abbr: str, season: str) -> Dict[str, Any]: 128 | """Get the roster for the given team and season. 129 | 130 | Args: 131 | team_abbr: Team abbreviation (e.g., BUF, TOR) 132 | season: Season in format YYYYYYYY (e.g., 20202021, 20212022) 133 | 134 | Returns: 135 | Dictionary containing roster information for the specified team and season. 136 | """ 137 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=f"roster/{team_abbr}/{season}").json() 138 | 139 | def franchises(self) -> List[Dict[str, Any]]: 140 | """Get a list of all past and current NHL franchises. 141 | 142 | Returns: 143 | List of all NHL franchises, including historical/defunct teams. 144 | """ 145 | # franchise_url = f"{self.NHL_STATS_API_BASE}/en/franchise" 146 | response = self.client.get(endpoint=Endpoint.API_STATS, resource="en/franchise").json() 147 | return response.get("data", []) 148 | -------------------------------------------------------------------------------- /nhlpy/api/schedule.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional, List 3 | 4 | from nhlpy.http_client import HttpClient, Endpoint 5 | 6 | 7 | class Schedule: 8 | def __init__(self, http_client: HttpClient) -> None: 9 | self.client = http_client 10 | 11 | def daily_schedule(self, date: Optional[str] = None) -> dict: 12 | """Gets NHL schedule for a specific date. 13 | 14 | Args: 15 | date (str): Date in YYYY-MM-DD format. 16 | 17 | Returns: 18 | dict: Game schedule data for the specified date. 19 | """ 20 | try: 21 | if not date: 22 | date = datetime.now().strftime("%Y-%m-%d") # Default to today's date 23 | else: 24 | # Parse and reformat the date to ensure YYYY-MM-DD 25 | date = datetime.strptime(date, "%Y-%m-%d").strftime("%Y-%m-%d") 26 | except ValueError: 27 | raise ValueError("Invalid date format. Please use YYYY-MM-DD.") 28 | 29 | schedule_data: dict = self.client.get(endpoint=Endpoint.API_WEB_V1, resource=f"schedule/{date}").json() 30 | response_payload = { 31 | "nextStartDate": schedule_data.get("nextStartDate", None), 32 | "previousStartDate": schedule_data.get("previousStartDate", None), 33 | "date": date, 34 | "oddsPartners": schedule_data.get("oddsPartners", None), 35 | } 36 | 37 | game_week = schedule_data.get("gameWeek", []) 38 | matching_day = next((day for day in game_week if day.get("date") == date), None) 39 | 40 | if matching_day: 41 | games = matching_day.get("games", []) 42 | response_payload["games"] = games 43 | response_payload["numberOfGames"] = len(games) 44 | 45 | return response_payload 46 | 47 | def weekly_schedule(self, date: Optional[str] = None) -> dict: 48 | """Gets NHL schedule for a week starting from the specified date. 49 | 50 | Args: 51 | date (str, optional): Date in YYYY-MM-DD format. Defaults to today's date. 52 | Note: NHL's "today" typically shifts around 12:00 EST. 53 | 54 | Returns: 55 | dict: Weekly game schedule data. 56 | """ 57 | res = date if date else "now" 58 | 59 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=f"schedule/{res}").json() 60 | 61 | def team_monthly_schedule(self, team_abbr: str, month: Optional[str] = None) -> List[dict]: 62 | """Gets monthly schedule for specified team or the given month. If no month is supplied it will default to now. 63 | 64 | Args: 65 | team_abbr (str): Three-letter team abbreviation (e.g., BUF, TOR) 66 | month (str, optional): Month in YYYY-MM format (e.g., 2021-10). Defaults to current month. 67 | 68 | Returns: 69 | List[dict]: List of games in the monthly schedule. 70 | """ 71 | resource = f"club-schedule/{team_abbr}/month/{month if month else 'now'}" 72 | response = self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 73 | return response.get("games", []) 74 | 75 | def team_weekly_schedule(self, team_abbr: str, date: Optional[str] = None) -> List[dict]: 76 | """Gets weekly schedule for specified team. If no date is supplied it will default to current week. 77 | 78 | Args: 79 | team_abbr (str): Three-letter team abbreviation (e.g., BUF, TOR) 80 | date (str, optional): Date in YYYY-MM-DD format. Gets schedule for week containing this date. 81 | Defaults to current week. 82 | 83 | Returns: 84 | List[dict]: List of games in the weekly schedule. 85 | """ 86 | resource = f"club-schedule/{team_abbr}/week/{date if date else 'now'}" 87 | response = self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 88 | return response.get("games", []) 89 | 90 | def team_season_schedule(self, team_abbr: str, season: str) -> dict: 91 | """Gets full season schedule for specified team. 92 | 93 | Args: 94 | team_abbr (str): Three-letter team abbreviation (e.g., BUF, TOR) 95 | season (str): Season in YYYYYYYY format (e.g., 20232024) 96 | 97 | Returns: 98 | dict: Complete season schedule data including metadata. 99 | """ 100 | request = self.client.get(endpoint=Endpoint.API_WEB_V1, resource=f"club-schedule-season/{team_abbr}/{season}") 101 | 102 | return request.json() 103 | 104 | def calendar_schedule(self, date: str) -> dict: 105 | """Gets schedule in calendar format for specified date. Im not really sure 106 | how this is diff from the other endppoints. 107 | 108 | Args: 109 | date (str): Date in YYYY-MM-DD format (e.g., 2023-11-23) 110 | 111 | Returns: 112 | dict: Calendar-formatted schedule data. 113 | 114 | Example: 115 | API endpoint: https://api-web.nhle.com/v1/schedule-calendar/2023-11-08 116 | """ 117 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=f"schedule-calendar/{date}").json() 118 | 119 | def playoff_carousel(self, season: str) -> dict: 120 | """Gets list of all series games up to current playoff round. 121 | 122 | Args: 123 | season (str): Season in YYYYYYYY format (e.g., "20232024") 124 | 125 | Returns: 126 | dict: Playoff series data for the specified season. 127 | 128 | Example: 129 | API endpoint: https://api-web.nhle.com/v1/playoff-series/carousel/20232024/ 130 | """ 131 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=f"playoff-series/carousel/{season}").json() 132 | 133 | def playoff_series_schedule(self, season: str, series: str) -> dict: 134 | """Returns the schedule for a specified playoff series. 135 | 136 | Args: 137 | season (str): Season in YYYYYYYY format (e.g., "20232024") 138 | series (str): Series identifier (a-h) for Round 1 139 | 140 | Returns: 141 | dict: Schedule data for the specified playoff series. 142 | 143 | Example: 144 | API endpoint: https://api-web.nhle.com/v1/schedule/playoff-series/20232024/a/ 145 | """ 146 | 147 | return self.client.get( 148 | endpoint=Endpoint.API_WEB_V1, resource=f"schedule/playoff-series/{season}/{series}" 149 | ).json() 150 | 151 | def playoff_bracket(self, year: str) -> dict: 152 | """Returns the playoff bracket. 153 | 154 | Args: 155 | year (str): Year playoffs take place (e.g., "2024") 156 | 157 | Returns: 158 | dict: Playoff bracket data. 159 | 160 | Example: 161 | API endpoint: https://api-web.nhle.com/v1/playoff-bracket/2024 162 | """ 163 | 164 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=f"playoff-bracket/{year}").json() 165 | -------------------------------------------------------------------------------- /tests/test_edge.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | 4 | @mock.patch("httpx.Client.get") 5 | def test_skater_detail_now(mock_get, nhl_client): 6 | nhl_client.edge.skater_detail(player_id=8478402) 7 | mock_get.assert_called_once() 8 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/skater-detail/8478402/now" 9 | 10 | 11 | @mock.patch("httpx.Client.get") 12 | def test_skater_detail_with_season_and_game_type(mock_get, nhl_client): 13 | nhl_client.edge.skater_detail(player_id=8478402, season=20242025, game_type=2) 14 | mock_get.assert_called_once() 15 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/skater-detail/8478402/20242025/2" 16 | 17 | 18 | @mock.patch("httpx.Client.get") 19 | def test_skater_detail_with_season(mock_get, nhl_client): 20 | nhl_client.edge.skater_detail(player_id=8478402, season=20232024, game_type=3) 21 | mock_get.assert_called_once() 22 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/skater-detail/8478402/20232024/3" 23 | 24 | 25 | @mock.patch("httpx.Client.get") 26 | def test_goalie_detail_now(mock_get, nhl_client): 27 | nhl_client.edge.goalie_detail(player_id=8476945) 28 | mock_get.assert_called_once() 29 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/goalie-detail/8476945/now" 30 | 31 | 32 | @mock.patch("httpx.Client.get") 33 | def test_goalie_detail_with_season(mock_get, nhl_client): 34 | nhl_client.edge.goalie_detail(player_id=8476945, season=20232024, game_type=2) 35 | mock_get.assert_called_once() 36 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/goalie-detail/8476945/20232024/2" 37 | 38 | 39 | @mock.patch("httpx.Client.get") 40 | def test_skater_shot_speed_detail_now(mock_get, nhl_client): 41 | nhl_client.edge.skater_shot_speed_detail(player_id=1) 42 | mock_get.assert_called_once() 43 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/skater-shot-speed-detail/1/now" 44 | 45 | 46 | @mock.patch("httpx.Client.get") 47 | def test_skater_shot_speed_detail_with_season(mock_get, nhl_client): 48 | nhl_client.edge.skater_shot_speed_detail(player_id=1, season=20232024, game_type=2) 49 | mock_get.assert_called_once() 50 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/skater-shot-speed-detail/1/20232024/2" 51 | 52 | 53 | @mock.patch("httpx.Client.get") 54 | def test_skater_skating_speed_detail_now(mock_get, nhl_client): 55 | nhl_client.edge.skater_skating_speed_detail(player_id=2) 56 | mock_get.assert_called_once() 57 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/skater-skating-speed-detail/2/now" 58 | 59 | 60 | @mock.patch("httpx.Client.get") 61 | def test_skater_skating_speed_detail_with_season(mock_get, nhl_client): 62 | nhl_client.edge.skater_skating_speed_detail(player_id=2, season=20232024, game_type=2) 63 | mock_get.assert_called_once() 64 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/skater-skating-speed-detail/2/20232024/2" 65 | 66 | 67 | @mock.patch("httpx.Client.get") 68 | def test_skater_shot_location_detail_now(mock_get, nhl_client): 69 | nhl_client.edge.skater_shot_location_detail(player_id=3) 70 | mock_get.assert_called_once() 71 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/skater-shot-location-detail/3/now" 72 | 73 | 74 | @mock.patch("httpx.Client.get") 75 | def test_skater_shot_location_detail_with_season(mock_get, nhl_client): 76 | nhl_client.edge.skater_shot_location_detail(player_id=3, season=20232024, game_type=2) 77 | mock_get.assert_called_once() 78 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/skater-shot-location-detail/3/20232024/2" 79 | 80 | 81 | @mock.patch("httpx.Client.get") 82 | def test_skater_skating_distance_detail_now(mock_get, nhl_client): 83 | nhl_client.edge.skater_skating_distance_detail(player_id=4) 84 | mock_get.assert_called_once() 85 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/skater-skating-distance-detail/4/now" 86 | 87 | 88 | @mock.patch("httpx.Client.get") 89 | def test_skater_skating_distance_detail_with_season(mock_get, nhl_client): 90 | nhl_client.edge.skater_skating_distance_detail(player_id=4, season=20232024, game_type=2) 91 | mock_get.assert_called_once() 92 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/skater-skating-distance-detail/4/20232024/2" 93 | 94 | 95 | @mock.patch("httpx.Client.get") 96 | def test_skater_comparison_now(mock_get, nhl_client): 97 | nhl_client.edge.skater_comparison(player_id=5) 98 | mock_get.assert_called_once() 99 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/skater-comparison/5/now" 100 | 101 | 102 | @mock.patch("httpx.Client.get") 103 | def test_skater_comparison_with_season(mock_get, nhl_client): 104 | nhl_client.edge.skater_comparison(player_id=5, season=20232024, game_type=2) 105 | mock_get.assert_called_once() 106 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/skater-comparison/5/20232024/2" 107 | 108 | 109 | @mock.patch("httpx.Client.get") 110 | def test_skater_zone_time_now(mock_get, nhl_client): 111 | nhl_client.edge.skater_zone_time(player_id=6) 112 | mock_get.assert_called_once() 113 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/skater-zone-time/6/now" 114 | 115 | 116 | @mock.patch("httpx.Client.get") 117 | def test_skater_zone_time_with_season(mock_get, nhl_client): 118 | nhl_client.edge.skater_zone_time(player_id=6, season=20232024, game_type=2) 119 | mock_get.assert_called_once() 120 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/skater-zone-time/6/20232024/2" 121 | 122 | 123 | @mock.patch("httpx.Client.get") 124 | def test_skater_landing_now(mock_get, nhl_client): 125 | nhl_client.edge.skater_landing() 126 | mock_get.assert_called_once() 127 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/skater-landing/now" 128 | 129 | 130 | @mock.patch("httpx.Client.get") 131 | def test_skater_landing_with_season(mock_get, nhl_client): 132 | nhl_client.edge.skater_landing(season=20232024, game_type=2) 133 | mock_get.assert_called_once() 134 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/skater-landing/20232024/2" 135 | 136 | 137 | @mock.patch("httpx.Client.get") 138 | def test_cat_skater_detail_now(mock_get, nhl_client): 139 | nhl_client.edge.cat_skater_detail(player_id=7) 140 | mock_get.assert_called_once() 141 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/cat/edge/skater-detail/7/now" 142 | 143 | 144 | @mock.patch("httpx.Client.get") 145 | def test_cat_skater_detail_with_season(mock_get, nhl_client): 146 | nhl_client.edge.cat_skater_detail(player_id=7, season=20232024, game_type=2) 147 | mock_get.assert_called_once() 148 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/cat/edge/skater-detail/7/20232024/2" 149 | 150 | 151 | @mock.patch("httpx.Client.get") 152 | def test_goalie_shot_location_detail_now(mock_get, nhl_client): 153 | nhl_client.edge.goalie_shot_location_detail(player_id=8) 154 | mock_get.assert_called_once() 155 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/goalie-shot-location-detail/8/now" 156 | 157 | 158 | @mock.patch("httpx.Client.get") 159 | def test_goalie_shot_location_detail_with_season(mock_get, nhl_client): 160 | nhl_client.edge.goalie_shot_location_detail(player_id=8, season=20232024, game_type=2) 161 | mock_get.assert_called_once() 162 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/goalie-shot-location-detail/8/20232024/2" 163 | 164 | 165 | @mock.patch("httpx.Client.get") 166 | def test_goalie_5v5_detail_now(mock_get, nhl_client): 167 | nhl_client.edge.goalie_5v5_detail(player_id=9) 168 | mock_get.assert_called_once() 169 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/goalie-5v5-detail/9/now" 170 | 171 | 172 | @mock.patch("httpx.Client.get") 173 | def test_goalie_5v5_detail_with_season(mock_get, nhl_client): 174 | nhl_client.edge.goalie_5v5_detail(player_id=9, season=20232024, game_type=2) 175 | mock_get.assert_called_once() 176 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/goalie-5v5-detail/9/20232024/2" 177 | 178 | 179 | @mock.patch("httpx.Client.get") 180 | def test_goalie_comparison_now(mock_get, nhl_client): 181 | nhl_client.edge.goalie_comparison(player_id=10) 182 | mock_get.assert_called_once() 183 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/goalie-comparison/10/now" 184 | 185 | 186 | @mock.patch("httpx.Client.get") 187 | def test_goalie_comparison_with_season(mock_get, nhl_client): 188 | nhl_client.edge.goalie_comparison(player_id=10, season=20232024, game_type=2) 189 | mock_get.assert_called_once() 190 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/goalie-comparison/10/20232024/2" 191 | 192 | 193 | @mock.patch("httpx.Client.get") 194 | def test_goalie_save_percentage_detail_now(mock_get, nhl_client): 195 | nhl_client.edge.goalie_save_percentage_detail(player_id=11) 196 | mock_get.assert_called_once() 197 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/goalie-save-percentage-detail/11/now" 198 | 199 | 200 | @mock.patch("httpx.Client.get") 201 | def test_goalie_save_percentage_detail_with_season(mock_get, nhl_client): 202 | nhl_client.edge.goalie_save_percentage_detail(player_id=11, season=20232024, game_type=2) 203 | mock_get.assert_called_once() 204 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/goalie-save-percentage-detail/11/20232024/2" 205 | 206 | 207 | @mock.patch("httpx.Client.get") 208 | def test_goalie_landing_now(mock_get, nhl_client): 209 | nhl_client.edge.goalie_landing() 210 | mock_get.assert_called_once() 211 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/goalie-landing/now" 212 | 213 | 214 | @mock.patch("httpx.Client.get") 215 | def test_goalie_landing_with_season(mock_get, nhl_client): 216 | nhl_client.edge.goalie_landing(season=20232024, game_type=2) 217 | mock_get.assert_called_once() 218 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/goalie-landing/20232024/2" 219 | 220 | 221 | @mock.patch("httpx.Client.get") 222 | def test_cat_goalie_detail_now(mock_get, nhl_client): 223 | nhl_client.edge.cat_goalie_detail(player_id=12) 224 | mock_get.assert_called_once() 225 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/cat/edge/goalie-detail/12/now" 226 | 227 | 228 | @mock.patch("httpx.Client.get") 229 | def test_cat_goalie_detail_with_season(mock_get, nhl_client): 230 | nhl_client.edge.cat_goalie_detail(player_id=12, season=20232024, game_type=2) 231 | mock_get.assert_called_once() 232 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/cat/edge/goalie-detail/12/20232024/2" 233 | 234 | 235 | @mock.patch("httpx.Client.get") 236 | def test_team_detail_now(mock_get, nhl_client): 237 | nhl_client.edge.team_detail(team_id=13) 238 | mock_get.assert_called_once() 239 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/team-detail/13/now" 240 | 241 | 242 | @mock.patch("httpx.Client.get") 243 | def test_team_detail_with_season(mock_get, nhl_client): 244 | nhl_client.edge.team_detail(team_id=13, season=20232024, game_type=2) 245 | mock_get.assert_called_once() 246 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/team-detail/13/20232024/2" 247 | 248 | 249 | @mock.patch("httpx.Client.get") 250 | def test_team_skating_distance_detail_now(mock_get, nhl_client): 251 | nhl_client.edge.team_skating_distance_detail(team_id=14) 252 | mock_get.assert_called_once() 253 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/team-skating-distance-detail/14/now" 254 | 255 | 256 | @mock.patch("httpx.Client.get") 257 | def test_team_skating_distance_detail_with_season(mock_get, nhl_client): 258 | nhl_client.edge.team_skating_distance_detail(team_id=14, season=20232024, game_type=2) 259 | mock_get.assert_called_once() 260 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/team-skating-distance-detail/14/20232024/2" 261 | 262 | 263 | @mock.patch("httpx.Client.get") 264 | def test_team_zone_time_details_now(mock_get, nhl_client): 265 | nhl_client.edge.team_zone_time_details(team_id=15) 266 | mock_get.assert_called_once() 267 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/team-zone-time-details/15/now" 268 | 269 | 270 | @mock.patch("httpx.Client.get") 271 | def test_team_zone_time_details_with_season(mock_get, nhl_client): 272 | nhl_client.edge.team_zone_time_details(team_id=15, season=20232024, game_type=2) 273 | mock_get.assert_called_once() 274 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/team-zone-time-details/15/20232024/2" 275 | 276 | 277 | @mock.patch("httpx.Client.get") 278 | def test_team_shot_location_detail_now(mock_get, nhl_client): 279 | nhl_client.edge.team_shot_location_detail(team_id=16) 280 | mock_get.assert_called_once() 281 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/team-shot-location-detail/16/now" 282 | 283 | 284 | @mock.patch("httpx.Client.get") 285 | def test_team_shot_location_detail_with_season(mock_get, nhl_client): 286 | nhl_client.edge.team_shot_location_detail(team_id=16, season=20232024, game_type=2) 287 | mock_get.assert_called_once() 288 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/team-shot-location-detail/16/20232024/2" 289 | 290 | 291 | @mock.patch("httpx.Client.get") 292 | def test_team_landing_now(mock_get, nhl_client): 293 | nhl_client.edge.team_landing() 294 | mock_get.assert_called_once() 295 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/team-landing/now" 296 | 297 | 298 | @mock.patch("httpx.Client.get") 299 | def test_team_landing_with_season(mock_get, nhl_client): 300 | nhl_client.edge.team_landing(season=20232024, game_type=2) 301 | mock_get.assert_called_once() 302 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/team-landing/20232024/2" 303 | 304 | 305 | @mock.patch("httpx.Client.get") 306 | def test_team_shot_speed_detail_now(mock_get, nhl_client): 307 | nhl_client.edge.team_shot_speed_detail(team_id=17) 308 | mock_get.assert_called_once() 309 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/team-shot-speed-detail/17/now" 310 | 311 | 312 | @mock.patch("httpx.Client.get") 313 | def test_team_shot_speed_detail_with_season(mock_get, nhl_client): 314 | nhl_client.edge.team_shot_speed_detail(team_id=17, season=20232024, game_type=2) 315 | mock_get.assert_called_once() 316 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/team-shot-speed-detail/17/20232024/2" 317 | 318 | 319 | @mock.patch("httpx.Client.get") 320 | def test_team_skating_speed_detail_now(mock_get, nhl_client): 321 | nhl_client.edge.team_skating_speed_detail(team_id=18) 322 | mock_get.assert_called_once() 323 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/team-skating-speed-detail/18/now" 324 | 325 | 326 | @mock.patch("httpx.Client.get") 327 | def test_team_skating_speed_detail_with_season(mock_get, nhl_client): 328 | nhl_client.edge.team_skating_speed_detail(team_id=18, season=20232024, game_type=2) 329 | mock_get.assert_called_once() 330 | assert mock_get.call_args[1]["url"] == "https://api-web.nhle.com/v1/edge/team-skating-speed-detail/18/20232024/2" 331 | -------------------------------------------------------------------------------- /nhl_api_2_3_0.json: -------------------------------------------------------------------------------- 1 | {"_type":"export","__export_format":4,"__export_date":"2024-02-15T20:46:14.608Z","__export_source":"insomnia.desktop.app:v8.6.1","resources":[{"_id":"req_0480976539ba499183de1034a85118c9","parentId":"fld_9cc7cf9d180e438887ceedc3387332b5","modified":1707349526885,"created":1707349436961,"url":"https://api.nhle.com/stats/rest/en/draft?","name":"season specifics","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnia/8.6.1"}],"authentication":{},"metaSortKey":-1707349436961,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_9cc7cf9d180e438887ceedc3387332b5","parentId":"wrk_99b45ce56b994ef6b63cae0f80b8c840","modified":1707349372856,"created":1707349372856,"name":"Misc","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1707349372856,"_type":"request_group"},{"_id":"wrk_99b45ce56b994ef6b63cae0f80b8c840","parentId":null,"modified":1700602776803,"created":1700602776803,"name":"NHL API","description":"","scope":"collection","_type":"workspace"},{"_id":"req_0315e7a6fb7c4790a7381fd98c326194","parentId":"fld_9cc7cf9d180e438887ceedc3387332b5","modified":1708027314441,"created":1707349379690,"url":"https://api.nhle.com/stats/rest/en/country?","name":"countries","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnia/8.6.1"}],"authentication":{},"metaSortKey":-1707349379690,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_ec605f61d7f14f69891d7c0bd4513381","parentId":"fld_c881ef4f75c945128f84f418bd70e375","modified":1707862367424,"created":1707861147763,"url":"https://api-web.nhle.com/v1/player/8476453/game-log/20222023/2","name":"player gamelog","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnia/8.6.1"}],"authentication":{},"metaSortKey":-1707861147763,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_c881ef4f75c945128f84f418bd70e375","parentId":"wrk_99b45ce56b994ef6b63cae0f80b8c840","modified":1707182204260,"created":1707182204260,"name":"Stats","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1707182204260,"_type":"request_group"},{"_id":"req_2b5f840c20e24d5f8efcb9d2cec02a88","parentId":"fld_c881ef4f75c945128f84f418bd70e375","modified":1707350567064,"created":1707349801992,"url":"https://api.nhle.com/stats/rest/en/skater/summary","name":"Skater Stats summary","description":"","method":"GET","body":{},"parameters":[{"name":"isAggregate","value":"false","id":"pair_0cc2bf1a40d84481b99a038ea9adcee8"},{"name":"isGame","value":"false","id":"pair_8d9e01ee3d7c48f09bf73ff23c0afa06"},{"name":"sort","value":"[{\"property\":\"points\",\"direction\":\"DESC\"},{\"property\":\"gamesPlayed\",\"direction\":\"ASC\"},{\"property\":\"playerId\",\"direction\":\"ASC\"}]","id":"pair_24ce5eed3b8746ffbcd669aebf41061f"},{"name":"start","value":"0","id":"pair_c301087c1eae4e47b2a79a1ac89efb5a"},{"name":"limit","value":"10","id":"pair_932bc26db9d44396a1094bf3f8e7c559"},{"name":"factCayenneExp","value":"gamesPlayed>=1","id":"pair_bf115daac2f54baab4742306537f208f"},{"name":"cayenneExp","value":"gameTypeId=2 and seasonId<=20232024 and seasonId>=20232024","id":"pair_bc3c7fed6ba64e78ab5e0508783ca774"}],"headers":[{"name":"User-Agent","value":"insomnia/8.6.1"}],"authentication":{},"metaSortKey":-1707349801992,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_0f6b8b2ed72b4992a0d56716dca607e1","parentId":"fld_c881ef4f75c945128f84f418bd70e375","modified":1707317239017,"created":1707314281816,"url":"https://api.nhle.com/stats/rest/en/team/summary","name":"Team Stats Summary","description":"","method":"GET","body":{},"parameters":[{"name":"isAggregate","value":"false"},{"name":"isGame","value":"false"},{"name":"sort","value":"[{\"property\":\"points\",\"direction\":\"DESC\"},{\"property\":\"wins\",\"direction\":\"DESC\"},{\"property\":\"teamId\",\"direction\":\"ASC\"}]"},{"name":"start","value":"0"},{"name":"limit","value":"50"},{"name":"factCayenneExp","value":"gamesPlayed>=1"},{"name":"cayenneExp","value":"gameTypeId=2 and seasonId<=20232024 and seasonId>=20232024"}],"headers":[{"name":"User-Agent","value":"insomnia/8.6.1"}],"authentication":{},"metaSortKey":-1707314281816,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_02fd6a9157fd4a1a897bd59dbb1b375d","parentId":"fld_c881ef4f75c945128f84f418bd70e375","modified":1707182285031,"created":1707182208364,"url":"https://api-web.nhle.com/v1/club-stats-season/BUF","name":"Club Stats Season","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnia/8.4.5"}],"authentication":{},"metaSortKey":-1707182208364,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_c815e74a3aa140c7bae0427374b8af2a","parentId":"fld_c881ef4f75c945128f84f418bd70e375","modified":1707324381413,"created":1707182296597,"url":"https://api-web.nhle.com/v1/player/8480045/landing","name":"Player Stats","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnia/8.4.5"}],"authentication":{},"metaSortKey":-1704097754543.5,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_119c61ff426849ad94ef4592599de614","parentId":"fld_0a164c32cf894dda9e4e01342d9d6096","modified":1707350408275,"created":1707350357013,"url":"https://api.nhle.com/stats/rest/en/franchise","name":"franchise","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnia/8.6.1"}],"authentication":{},"metaSortKey":-1707350357013,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_0a164c32cf894dda9e4e01342d9d6096","parentId":"wrk_99b45ce56b994ef6b63cae0f80b8c840","modified":1700677055390,"created":1700677055390,"name":"Teams","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1700677055390,"_type":"request_group"},{"_id":"req_306d3c7b26aa4f2f8b3c355cfece25a0","parentId":"fld_0a164c32cf894dda9e4e01342d9d6096","modified":1701016559924,"created":1701011877087,"url":"https://api-web.nhle.com/v1/roster/BUF/20232024","name":"roster","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnia/8.4.4"}],"authentication":{},"metaSortKey":-1701011877087,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_6455801ab3ba4148b3d6dc570b179a30","parentId":"fld_0a164c32cf894dda9e4e01342d9d6096","modified":1700677061232,"created":1699714308830,"url":"https://api.nhle.com/stats/rest/en/team/summary?","name":"Team Stats Summary","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnia/8.4.0"}],"authentication":{},"metaSortKey":-1700677061196,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_8704273712d04ba8bd0497391c2dbecc","parentId":"fld_0a164c32cf894dda9e4e01342d9d6096","modified":1700677118186,"created":1700677071513,"url":"https://api-web.nhle.com/v1/roster-season/BUF","name":"Team Roster","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnia/8.4.0"}],"authentication":{},"metaSortKey":-1700646562645,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_44977479c368439db2c6331a329a882e","parentId":"fld_7154c8f2df85461fa685bde21011fb2a","modified":1700961984501,"created":1700615630845,"url":"https://api-web.nhle.com/v1/gamecenter/2023020310/boxscore","name":"GameCenter - BoxScore","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnia/8.4.2"}],"authentication":{},"metaSortKey":-1700616064094,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_7154c8f2df85461fa685bde21011fb2a","parentId":"wrk_99b45ce56b994ef6b63cae0f80b8c840","modified":1700616051787,"created":1700616051787,"name":"Game Center","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1700616051787,"_type":"request_group"},{"_id":"req_a207e29ea51847cb853f593fbd178d2b","parentId":"fld_7154c8f2df85461fa685bde21011fb2a","modified":1700616823135,"created":1700616814036,"url":"https://api-web.nhle.com/v1/score/now","name":"GameCenter - Score Now","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnia/8.4.2"}],"authentication":{},"metaSortKey":-1700616052195,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_7a8da7415a8147549b76d3d326425f42","parentId":"fld_7154c8f2df85461fa685bde21011fb2a","modified":1700962024971,"created":1700616095982,"url":"https://api-web.nhle.com/v1/gamecenter/2023020310/play-by-play","name":"GameCenter - PlayByPlay","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnia/8.4.2"}],"authentication":{},"metaSortKey":-1700616040296,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_5c37e19afd9446c29b501d10a2221dc4","parentId":"fld_7154c8f2df85461fa685bde21011fb2a","modified":1700616512883,"created":1700616507407,"url":"https://api-web.nhle.com/v1/gamecenter/2023020285/landing","name":"GameCenter - Landing","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnia/8.4.2"}],"authentication":{},"metaSortKey":-1700616028397,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_ea0ea40c376a4d74b4496a301713df29","parentId":"fld_a1b982f4f15f47f5a8e81ddcfbbbb288","modified":1700677761891,"created":1700677744770,"url":"https://api-web.nhle.com/v1/schedule-calendar/2023-11-22","name":"Schedule Calendar","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnia/8.4.2"}],"authentication":{},"metaSortKey":-1700677744770,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_a1b982f4f15f47f5a8e81ddcfbbbb288","parentId":"wrk_99b45ce56b994ef6b63cae0f80b8c840","modified":1700616000421,"created":1700616000421,"name":"Schedule","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1700616000421,"_type":"request_group"},{"_id":"req_1c805c50423d4853a4d7f8586e6f26ae","parentId":"fld_a1b982f4f15f47f5a8e81ddcfbbbb288","modified":1700961969246,"created":1699629547871,"url":"https://api-web.nhle.com/v1/schedule/2023-11-25","name":"Schedule By Date","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnia/8.3.0"}],"authentication":{},"metaSortKey":-1700616016498,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_f4b06b02014643688ce2adba6aa78da8","parentId":"fld_a1b982f4f15f47f5a8e81ddcfbbbb288","modified":1700616031808,"created":1698326129771,"url":"https://api-web.nhle.com/v1/schedule/now","name":"Schedule Now","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnia/8.3.0"}],"authentication":{},"metaSortKey":-1700616016398,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_299f4997b0384de8aa2785bbb3fb06e3","parentId":"fld_a1b982f4f15f47f5a8e81ddcfbbbb288","modified":1700616026777,"created":1699634789997,"url":"https://api-web.nhle.com/v1/club-schedule/BUF/month/now","name":"Club Schedule by Month Now","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnia/8.3.0"}],"authentication":{},"metaSortKey":-1700616016298,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_0c03a29b2c924a769b8561317f9b644d","parentId":"fld_a1b982f4f15f47f5a8e81ddcfbbbb288","modified":1700616022893,"created":1699631380607,"url":"https://api-web.nhle.com/v1/club-schedule/BUF/month/2023-11","name":"Club Schedule by Month","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnia/8.3.0"}],"authentication":{},"metaSortKey":-1700616016198,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_67fddd56d13a4cc29122f95abe65037f","parentId":"fld_a1b982f4f15f47f5a8e81ddcfbbbb288","modified":1700616019954,"created":1699632450492,"url":"https://api-web.nhle.com/v1/club-schedule/BUF/week/now","name":"Club Schedule by week","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnia/8.3.0"}],"authentication":{},"metaSortKey":-1700616016098,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_6dd56acc7608427d8643fe3eca82ff08","parentId":"fld_a1b982f4f15f47f5a8e81ddcfbbbb288","modified":1700616016035,"created":1699631552023,"url":"https://api-web.nhle.com/v1/club-schedule-season/BUF/20232024","name":"Club Schedule by Year","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnia/8.3.0"}],"authentication":{},"metaSortKey":-1700616015998,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_c6f2a92e978040748964dba407944c13","parentId":"fld_9185266547564925aed97555b4070a23","modified":1700615988944,"created":1699632326802,"url":"https://api-web.nhle.com/v1/standings/now","name":"Get Standings Now","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnia/8.3.0"}],"authentication":{},"metaSortKey":-1700615984150,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"fld_9185266547564925aed97555b4070a23","parentId":"wrk_99b45ce56b994ef6b63cae0f80b8c840","modified":1700615973724,"created":1700615973724,"name":"Standings","description":"","environment":{},"environmentPropertyOrder":null,"metaSortKey":-1700615973724,"_type":"request_group"},{"_id":"req_77f077f01ca046b886f4cfb827f13195","parentId":"fld_9185266547564925aed97555b4070a23","modified":1700615984089,"created":1699644667168,"url":"https://api-web.nhle.com/v1/standings-season/","name":"Get Standings Season","description":"","method":"GET","body":{},"parameters":[],"headers":[{"name":"User-Agent","value":"insomnia/8.3.0"}],"authentication":{},"metaSortKey":-1700615984050,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_32a714f5c8194cefbfde6daed74d3dca","parentId":"wrk_99b45ce56b994ef6b63cae0f80b8c840","modified":1707351662049,"created":1699645690565,"url":"https://api.nhle.com/stats/rest/en/skater/summary","name":"test","description":"","method":"GET","body":{},"parameters":[{"name":"isAggregate","value":"true","id":"pair_2599316deb3b439496549cd36eeb12d6"},{"name":"isGame","value":"false","id":"pair_55b04aa132bc4c3e916877b1bee2e89d"},{"name":"sort","value":"[{\"property\":\"points\",\"direction\":\"DESC\"},{\"property\":\"gamesPlayed\",\"direction\":\"ASC\"},{\"property\":\"playerId\",\"direction\":\"ASC\"}]","id":"pair_e2ba7d7677c64ba2a5e16754b6b7daaa"},{"name":"start","value":"0","id":"pair_1446d0aa039842d3bb9666fe5d7f9642"},{"name":"limit","value":"100","id":"pair_a49d43a1d009480b8af6b434558cbe08"},{"name":"factCayenneExp","value":"gamesPlayed>=1","id":"pair_e62c86e403d84910974ae4e163ed45e3"},{"name":"cayenneExp","value":"gameTypeId=2 and seasonId<=20232024 and seasonId>=20222023","id":"pair_4c990ef1c4604327baf1f4eed039a953"}],"headers":[{"name":"User-Agent","value":"insomnia/8.4.0"}],"authentication":{},"metaSortKey":-1699645690565,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_d62d9fb99dd84bbaa0254e500da82bee","parentId":"wrk_99b45ce56b994ef6b63cae0f80b8c840","modified":1708028653267,"created":1707350296487,"url":"https://api.nhle.com/stats/rest/en/skater/summary","name":"test2","description":"","method":"GET","body":{},"parameters":[{"name":"isAggregate","value":"true"},{"name":"isGame","value":"false"},{"name":"start","value":"0"},{"name":"limit","value":"70"},{"name":"factCayenneExp","value":"goals>=10"},{"name":"sort","value":"[{\"property\": \"points\", \"direction\": \"DESC\"}, {\"property\": \"gamesPlayed\", \"direction\": \"ASC\"}, {\"property\": \"playerId\", \"direction\": \"ASC\"}]"},{"name":"cayenneExp","value":"gameTypeId=2 and isRookie='0' and seasonId >= 20232024 and seasonId <= 20232024 and nationalityCode='USA'"}],"headers":[{"name":"User-Agent","value":"insomnia/8.6.1"}],"authentication":{},"metaSortKey":-1699645690465,"isPrivate":false,"pathParameters":[],"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"env_03a7e3eb088245d7af86c18a5d983a2d","parentId":"wrk_99b45ce56b994ef6b63cae0f80b8c840","modified":1698242404315,"created":1698242404315,"name":"Base Environment","data":{},"dataPropertyOrder":null,"color":null,"isPrivate":false,"metaSortKey":1698242404315,"_type":"environment"},{"_id":"jar_cd68277a39254c7eb5209b24f8beef3f","parentId":"wrk_99b45ce56b994ef6b63cae0f80b8c840","modified":1698242404316,"created":1698242404316,"name":"Default Jar","cookies":[],"_type":"cookie_jar"}]} -------------------------------------------------------------------------------- /nhlpy/api/edge.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any 2 | 3 | from nhlpy.http_client import HttpClient, Endpoint 4 | 5 | 6 | class Edge: 7 | def __init__(self, http_client: HttpClient) -> None: 8 | self.client = http_client 9 | 10 | # ======================== 11 | # SKATER ENDPOINTS 12 | # ======================== 13 | 14 | def skater_detail(self, player_id: str, season: str = None, game_type: int = 2) -> Dict[str, Any]: 15 | """Get NHL EDGE skater detail statistics for a specific player. 16 | 17 | Retrieves detailed EDGE statistics including shot speed, skating speed, distance traveled, 18 | and zone time data for a skater. 19 | 20 | Args: 21 | player_id (str): The unique identifier for the NHL player 22 | season (str, optional): Season in YYYYYYYY format (e.g., 20232024). Defaults to current season. 23 | game_type (int, optional): Type of games (defaults to 2): 24 | 2: Regular season 25 | 3: Playoffs 26 | 27 | Returns: 28 | dict: Dictionary containing detailed EDGE statistics for the skater 29 | 30 | Example: 31 | client.edge.skater_detail(player_id=8478402) 32 | client.edge.skater_detail(player_id=8478402, season=20232024, game_type=2) 33 | """ 34 | if season is None: 35 | resource = f"edge/skater-detail/{player_id}/now" 36 | else: 37 | resource = f"edge/skater-detail/{player_id}/{season}/{game_type}" 38 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 39 | 40 | def skater_shot_speed_detail(self, player_id: str, season: str = None, game_type: int = 2) -> Dict[str, Any]: 41 | """Get NHL EDGE shot speed details for a specific skater. 42 | 43 | Args: 44 | player_id (str): The unique identifier for the NHL player 45 | season (str, optional): Season in YYYYYYYY format (e.g., 20232024) 46 | game_type (int, optional): Type of games (2: Regular season, 3: Playoffs). Defaults to 2. 47 | 48 | Returns: 49 | dict: Shot speed statistics including maximum and average speeds 50 | """ 51 | if season is None: 52 | resource = f"edge/skater-shot-speed-detail/{player_id}/now" 53 | else: 54 | resource = f"edge/skater-shot-speed-detail/{player_id}/{season}/{game_type}" 55 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 56 | 57 | def skater_skating_speed_detail(self, player_id: str, season: str = None, game_type: int = 2) -> Dict[str, Any]: 58 | """Get NHL EDGE skating speed details for a specific skater. 59 | 60 | Args: 61 | player_id (str): The unique identifier for the NHL player 62 | season (str, optional): Season in YYYYYYYY format (e.g., 20232024) 63 | game_type (int, optional): Type of games (2: Regular season, 3: Playoffs). Defaults to 2. 64 | 65 | Returns: 66 | dict: Skating speed statistics including burst speed and average speed 67 | """ 68 | if season is None: 69 | resource = f"edge/skater-skating-speed-detail/{player_id}/now" 70 | else: 71 | resource = f"edge/skater-skating-speed-detail/{player_id}/{season}/{game_type}" 72 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 73 | 74 | def skater_shot_location_detail(self, player_id: str, season: str = None, game_type: int = 2) -> Dict[str, Any]: 75 | """Get NHL EDGE shot location details for a specific skater. 76 | 77 | Args: 78 | player_id (str): The unique identifier for the NHL player 79 | season (str, optional): Season in YYYYYYYY format (e.g., 20232024) 80 | game_type (int, optional): Type of games (2: Regular season, 3: Playoffs). Defaults to 2. 81 | 82 | Returns: 83 | dict: Shot location data including shooting patterns and heat maps 84 | """ 85 | if season is None: 86 | resource = f"edge/skater-shot-location-detail/{player_id}/now" 87 | else: 88 | resource = f"edge/skater-shot-location-detail/{player_id}/{season}/{game_type}" 89 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 90 | 91 | def skater_skating_distance_detail(self, player_id: str, season: str = None, game_type: int = 2) -> Dict[str, Any]: 92 | """Get NHL EDGE skating distance details for a specific skater. 93 | 94 | Args: 95 | player_id (str): The unique identifier for the NHL player 96 | season (str, optional): Season in YYYYYYYY format (e.g., 20232024) 97 | game_type (int, optional): Type of games (2: Regular season, 3: Playoffs). Defaults to 2. 98 | 99 | Returns: 100 | dict: Distance traveled statistics per game and per shift 101 | """ 102 | if season is None: 103 | resource = f"edge/skater-skating-distance-detail/{player_id}/now" 104 | else: 105 | resource = f"edge/skater-skating-distance-detail/{player_id}/{season}/{game_type}" 106 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 107 | 108 | def skater_comparison(self, player_id: str, season: str = None, game_type: int = 2) -> Dict[str, Any]: 109 | """Get NHL EDGE comparison statistics for a specific skater. 110 | 111 | Args: 112 | player_id (str): The unique identifier for the NHL player 113 | season (str, optional): Season in YYYYYYYY format (e.g., 20232024) 114 | game_type (int, optional): Type of games (2: Regular season, 3: Playoffs). Defaults to 2. 115 | 116 | Returns: 117 | dict: Comparison data relative to league averages 118 | """ 119 | if season is None: 120 | resource = f"edge/skater-comparison/{player_id}/now" 121 | else: 122 | resource = f"edge/skater-comparison/{player_id}/{season}/{game_type}" 123 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 124 | 125 | def skater_zone_time(self, player_id: str, season: str = None, game_type: int = 2) -> Dict[str, Any]: 126 | """Get NHL EDGE zone time details for a specific skater. 127 | 128 | Args: 129 | player_id (str): The unique identifier for the NHL player 130 | season (str, optional): Season in YYYYYYYY format (e.g., 20232024) 131 | game_type (int, optional): Type of games (2: Regular season, 3: Playoffs). Defaults to 2. 132 | 133 | Returns: 134 | dict: Time spent in offensive, defensive, and neutral zones 135 | """ 136 | if season is None: 137 | resource = f"edge/skater-zone-time/{player_id}/now" 138 | else: 139 | resource = f"edge/skater-zone-time/{player_id}/{season}/{game_type}" 140 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 141 | 142 | def skater_landing(self, season: str = None, game_type: int = 2) -> Dict[str, Any]: 143 | """Get NHL EDGE skater landing page data. 144 | 145 | Retrieves league-wide skater EDGE statistics overview. 146 | 147 | Args: 148 | season (str, optional): Season in YYYYYYYY format (e.g., 20232024) 149 | game_type (int, optional): Type of games (2: Regular season, 3: Playoffs). Defaults to 2. 150 | 151 | Returns: 152 | dict: Overview of league-wide skater EDGE statistics 153 | """ 154 | if season is None: 155 | resource = "edge/skater-landing/now" 156 | else: 157 | resource = f"edge/skater-landing/{season}/{game_type}" 158 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 159 | 160 | def cat_skater_detail(self, player_id: str, season: str = None, game_type: int = 2) -> Dict[str, Any]: 161 | """Get NHL CAT (Catch All Tracking) EDGE skater details. 162 | 163 | Args: 164 | player_id (str): The unique identifier for the NHL player 165 | season (str, optional): Season in YYYYYYYY format (e.g., 20232024) 166 | game_type (int, optional): Type of games (2: Regular season, 3: Playoffs). Defaults to 2. 167 | 168 | Returns: 169 | dict: CAT EDGE statistics for the skater 170 | """ 171 | if season is None: 172 | resource = f"cat/edge/skater-detail/{player_id}/now" 173 | else: 174 | resource = f"cat/edge/skater-detail/{player_id}/{season}/{game_type}" 175 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 176 | 177 | # ======================== 178 | # GOALIE ENDPOINTS 179 | # ======================== 180 | 181 | def goalie_detail(self, player_id: str, season: str = None, game_type: int = 2) -> Dict[str, Any]: 182 | """Get NHL EDGE goalie detail statistics for a specific player. 183 | 184 | Retrieves detailed EDGE statistics including save percentages, shot location data, 185 | and 5v5 performance metrics for a goalie. 186 | 187 | Args: 188 | player_id (str): The unique identifier for the NHL player 189 | season (str, optional): Season in YYYYYYYY format (e.g., 20232024). Defaults to current season. 190 | game_type (int, optional): Type of games (defaults to 2): 191 | 2: Regular season 192 | 3: Playoffs 193 | 194 | Returns: 195 | dict: Dictionary containing detailed EDGE statistics for the goalie 196 | 197 | Example: 198 | client.edge.goalie_detail(player_id=8476945) 199 | client.edge.goalie_detail(player_id=8476945, season=20232024, game_type=2) 200 | """ 201 | if season is None: 202 | resource = f"edge/goalie-detail/{player_id}/now" 203 | else: 204 | resource = f"edge/goalie-detail/{player_id}/{season}/{game_type}" 205 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 206 | 207 | def goalie_shot_location_detail(self, player_id: str, season: str = None, game_type: int = 2) -> Dict[str, Any]: 208 | """Get NHL EDGE shot location details for a specific goalie. 209 | 210 | Args: 211 | player_id (str): The unique identifier for the NHL player 212 | season (str, optional): Season in YYYYYYYY format (e.g., 20232024) 213 | game_type (int, optional): Type of games (2: Regular season, 3: Playoffs). Defaults to 2. 214 | 215 | Returns: 216 | dict: Shot location data faced by the goalie including save percentages by zone 217 | """ 218 | if season is None: 219 | resource = f"edge/goalie-shot-location-detail/{player_id}/now" 220 | else: 221 | resource = f"edge/goalie-shot-location-detail/{player_id}/{season}/{game_type}" 222 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 223 | 224 | def goalie_5v5_detail(self, player_id: str, season: str = None, game_type: int = 2) -> Dict[str, Any]: 225 | """Get NHL EDGE 5v5 performance details for a specific goalie. 226 | 227 | Args: 228 | player_id (str): The unique identifier for the NHL player 229 | season (str, optional): Season in YYYYYYYY format (e.g., 20232024) 230 | game_type (int, optional): Type of games (2: Regular season, 3: Playoffs). Defaults to 2. 231 | 232 | Returns: 233 | dict: 5-on-5 performance statistics and save percentages 234 | """ 235 | if season is None: 236 | resource = f"edge/goalie-5v5-detail/{player_id}/now" 237 | else: 238 | resource = f"edge/goalie-5v5-detail/{player_id}/{season}/{game_type}" 239 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 240 | 241 | def goalie_comparison(self, player_id: str, season: str = None, game_type: int = 2) -> Dict[str, Any]: 242 | """Get NHL EDGE comparison statistics for a specific goalie. 243 | 244 | Args: 245 | player_id (str): The unique identifier for the NHL player 246 | season (str, optional): Season in YYYYYYYY format (e.g., 20232024) 247 | game_type (int, optional): Type of games (2: Regular season, 3: Playoffs). Defaults to 2. 248 | 249 | Returns: 250 | dict: Comparison data relative to league averages 251 | """ 252 | if season is None: 253 | resource = f"edge/goalie-comparison/{player_id}/now" 254 | else: 255 | resource = f"edge/goalie-comparison/{player_id}/{season}/{game_type}" 256 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 257 | 258 | def goalie_save_percentage_detail(self, player_id: str, season: str = None, game_type: int = 2) -> Dict[str, Any]: 259 | """Get NHL EDGE save percentage details for a specific goalie. 260 | 261 | Args: 262 | player_id (str): The unique identifier for the NHL player 263 | season (str, optional): Season in YYYYYYYY format (e.g., 20232024) 264 | game_type (int, optional): Type of games (2: Regular season, 3: Playoffs). Defaults to 2. 265 | 266 | Returns: 267 | dict: Detailed save percentage breakdowns by situation and location 268 | """ 269 | if season is None: 270 | resource = f"edge/goalie-save-percentage-detail/{player_id}/now" 271 | else: 272 | resource = f"edge/goalie-save-percentage-detail/{player_id}/{season}/{game_type}" 273 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 274 | 275 | def goalie_landing(self, season: str = None, game_type: int = 2) -> Dict[str, Any]: 276 | """Get NHL EDGE goalie landing page data. 277 | 278 | Retrieves league-wide goalie EDGE statistics overview. 279 | 280 | Args: 281 | season (str, optional): Season in YYYYYYYY format (e.g., 20232024) 282 | game_type (int, optional): Type of games (2: Regular season, 3: Playoffs). Defaults to 2. 283 | 284 | Returns: 285 | dict: Overview of league-wide goalie EDGE statistics 286 | """ 287 | if season is None: 288 | resource = "edge/goalie-landing/now" 289 | else: 290 | resource = f"edge/goalie-landing/{season}/{game_type}" 291 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 292 | 293 | def cat_goalie_detail(self, player_id: str, season: str = None, game_type: int = 2) -> Dict[str, Any]: 294 | """Get NHL CAT (Catch All Tracking) EDGE goalie details. 295 | 296 | Args: 297 | player_id (str): The unique identifier for the NHL player 298 | season (str, optional): Season in YYYYYYYY format (e.g., 20232024) 299 | game_type (int, optional): Type of games (2: Regular season, 3: Playoffs). Defaults to 2. 300 | 301 | Returns: 302 | dict: CAT EDGE statistics for the goalie 303 | """ 304 | if season is None: 305 | resource = f"cat/edge/goalie-detail/{player_id}/now" 306 | else: 307 | resource = f"cat/edge/goalie-detail/{player_id}/{season}/{game_type}" 308 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 309 | 310 | # ======================== 311 | # TEAM ENDPOINTS 312 | # ======================== 313 | 314 | def team_detail(self, team_id: str, season: str = None, game_type: int = 2) -> Dict[str, Any]: 315 | """Get NHL EDGE team detail statistics for a specific team. 316 | 317 | Retrieves detailed EDGE statistics including team skating metrics, shot data, 318 | and zone time information. 319 | 320 | Args: 321 | team_id (str): The unique identifier for the NHL team 322 | season (str, optional): Season in YYYYYYYY format (e.g., 20232024). Defaults to current season. 323 | game_type (int, optional): Type of games (defaults to 2): 324 | 2: Regular season 325 | 3: Playoffs 326 | 327 | Returns: 328 | dict: Dictionary containing detailed EDGE statistics for the team 329 | 330 | Example: 331 | client.edge.team_detail(team_id=10) 332 | client.edge.team_detail(team_id=10, season=20232024, game_type=2) 333 | """ 334 | if season is None: 335 | resource = f"edge/team-detail/{team_id}/now" 336 | else: 337 | resource = f"edge/team-detail/{team_id}/{season}/{game_type}" 338 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 339 | 340 | def team_skating_distance_detail(self, team_id: str, season: str = None, game_type: int = 2) -> Dict[str, Any]: 341 | """Get NHL EDGE skating distance details for a specific team. 342 | 343 | Args: 344 | team_id (str): The unique identifier for the NHL team 345 | season (str, optional): Season in YYYYYYYY format (e.g., 20232024) 346 | game_type (int, optional): Type of games (2: Regular season, 3: Playoffs). Defaults to 2. 347 | 348 | Returns: 349 | dict: Team skating distance statistics per game and per player 350 | """ 351 | if season is None: 352 | resource = f"edge/team-skating-distance-detail/{team_id}/now" 353 | else: 354 | resource = f"edge/team-skating-distance-detail/{team_id}/{season}/{game_type}" 355 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 356 | 357 | def team_zone_time_details(self, team_id: str, season: str = None, game_type: int = 2) -> Dict[str, Any]: 358 | """Get NHL EDGE zone time details for a specific team. 359 | 360 | Args: 361 | team_id (str): The unique identifier for the NHL team 362 | season (str, optional): Season in YYYYYYYY format (e.g., 20232024) 363 | game_type (int, optional): Type of games (2: Regular season, 3: Playoffs). Defaults to 2. 364 | 365 | Returns: 366 | dict: Team time spent in offensive, defensive, and neutral zones 367 | """ 368 | if season is None: 369 | resource = f"edge/team-zone-time-details/{team_id}/now" 370 | else: 371 | resource = f"edge/team-zone-time-details/{team_id}/{season}/{game_type}" 372 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 373 | 374 | def team_shot_location_detail(self, team_id: str, season: str = None, game_type: int = 2) -> Dict[str, Any]: 375 | """Get NHL EDGE shot location details for a specific team. 376 | 377 | Args: 378 | team_id (str): The unique identifier for the NHL team 379 | season (str, optional): Season in YYYYYYYY format (e.g., 20232024) 380 | game_type (int, optional): Type of games (2: Regular season, 3: Playoffs). Defaults to 2. 381 | 382 | Returns: 383 | dict: Team shot location data including shooting patterns and heat maps 384 | """ 385 | if season is None: 386 | resource = f"edge/team-shot-location-detail/{team_id}/now" 387 | else: 388 | resource = f"edge/team-shot-location-detail/{team_id}/{season}/{game_type}" 389 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 390 | 391 | def team_landing(self, season: str = None, game_type: int = 2) -> Dict[str, Any]: 392 | """Get NHL EDGE team landing page data. 393 | 394 | Retrieves league-wide team EDGE statistics overview. 395 | 396 | Args: 397 | season (str, optional): Season in YYYYYYYY format (e.g., 20232024) 398 | game_type (int, optional): Type of games (2: Regular season, 3: Playoffs). Defaults to 2. 399 | 400 | Returns: 401 | dict: Overview of league-wide team EDGE statistics 402 | """ 403 | if season is None: 404 | resource = "edge/team-landing/now" 405 | else: 406 | resource = f"edge/team-landing/{season}/{game_type}" 407 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 408 | 409 | def team_shot_speed_detail(self, team_id: str, season: str = None, game_type: int = 2) -> Dict[str, Any]: 410 | """Get NHL EDGE shot speed details for a specific team. 411 | 412 | Args: 413 | team_id (str): The unique identifier for the NHL team 414 | season (str, optional): Season in YYYYYYYY format (e.g., 20232024) 415 | game_type (int, optional): Type of games (2: Regular season, 3: Playoffs). Defaults to 2. 416 | 417 | Returns: 418 | dict: Team shot speed statistics including maximum and average speeds 419 | """ 420 | if season is None: 421 | resource = f"edge/team-shot-speed-detail/{team_id}/now" 422 | else: 423 | resource = f"edge/team-shot-speed-detail/{team_id}/{season}/{game_type}" 424 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 425 | 426 | def team_skating_speed_detail(self, team_id: str, season: str = None, game_type: int = 2) -> Dict[str, Any]: 427 | """Get NHL EDGE skating speed details for a specific team. 428 | 429 | Args: 430 | team_id (str): The unique identifier for the NHL team 431 | season (str, optional): Season in YYYYYYYY format (e.g., 20232024) 432 | game_type (int, optional): Type of games (2: Regular season, 3: Playoffs). Defaults to 2. 433 | 434 | Returns: 435 | dict: Team skating speed statistics including burst speed and average speed 436 | """ 437 | if season is None: 438 | resource = f"edge/team-skating-speed-detail/{team_id}/now" 439 | else: 440 | resource = f"edge/team-skating-speed-detail/{team_id}/{season}/{game_type}" 441 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=resource).json() 442 | -------------------------------------------------------------------------------- /nhlpy/api/stats.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import List 3 | 4 | from nhlpy.api.query.builder import QueryContext 5 | from nhlpy.api.query.filters import _goalie_stats_sorts 6 | from nhlpy.api.query.sorting.sorting_options import SortingOptions 7 | from nhlpy.http_client import HttpClient, Endpoint 8 | 9 | 10 | class Stats: 11 | def __init__(self, http_client: HttpClient): 12 | self.client = http_client 13 | 14 | def gametypes_per_season_directory_by_team(self, team_abbr: str) -> dict: 15 | """Gets all game types played by a team throughout their history. 16 | 17 | A dictionary containing game types for each season the team has existed in the league. 18 | 19 | Args: 20 | team_abbr (str): The 3 letter abbreviation of the team (e.g., BUF, TOR) 21 | 22 | Returns: 23 | dict: A mapping of seasons to game types played by the team 24 | 25 | Example: 26 | https://api-web.nhle.com/v1/club-stats-season/TOR 27 | 28 | [ 29 | {'season': 20242025, 'gameTypes': [2]}, 30 | {'season': 20232024, 'gameTypes': [2, 3]}, 31 | {'season': 20222023, 'gameTypes': [2, 3]}, 32 | {'season': 20212022, 'gameTypes': [2, 3]}, 33 | ... 34 | ] 35 | 36 | """ 37 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=f"club-stats-season/{team_abbr}").json() 38 | 39 | def player_career_stats(self, player_id: str) -> dict: 40 | """Gets a player's career statistics and biographical information. 41 | 42 | Retrieves comprehensive player data including career stats and personal details from the NHL API. 43 | API endpoint example: https://api-web.nhle.com/v1/player/8481528/landing 44 | 45 | Args: 46 | player_id (str): The unique identifier for the NHL player 47 | 48 | Returns: 49 | dict: A dictionary containing the player's career statistics and personal information 50 | 51 | Example: 52 | Full Example: https://github.com/coreyjs/nhl-api-py/wiki/Player-Career-Stats-%E2%80%90-Example-Payload 53 | 54 | {'playerId': 8478402, 55 | 'isActive': True, 56 | 'currentTeamId': 22, 57 | 'currentTeamAbbrev': 'EDM', 58 | 'fullTeamName': {'default': 'Edmonton Oilers', 'fr': "Oilers d'Edmonton"}, 59 | 'teamCommonName': {'default': 'Oilers'}, 60 | 'teamPlaceNameWithPreposition': {'default': 'Edmonton', 'fr': "d'Edmonton"}, 61 | 'firstName': {'default': 'Connor'}, 62 | 'lastName': {'default': 'McDavid'}, 63 | 'badges': [{'logoUrl': {'default': 'https://assets.nhle.com/badges/4n_face-off.svg', 64 | 'fr': 'https://assets.nhle.com/badges/4n_face-off_fr.svg'}, 65 | 'title': {'default': '4 Nations Face-Off', 66 | 'fr': 'Confrontation Des 4 Nations'}}], 67 | 'teamLogo': 'https://assets.nhle.com/logos/nhl/svg/EDM_light.svg', 68 | 'sweaterNumber': 97, 69 | 'position': 'C', 70 | """ 71 | return self.client.get(endpoint=Endpoint.API_WEB_V1, resource=f"player/{player_id}/landing").json() 72 | 73 | def player_game_log(self, player_id: str, season_id: str, game_type: int) -> List[dict]: 74 | """Gets a player's game log for a specific season and game type. 75 | 76 | Retrieves detailed game-by-game statistics for a player during a specified season and game type. 77 | 78 | Args: 79 | game_type (int): The type of games to retrieve: 80 | 1: Preseason 81 | 2: Regular season 82 | 3: Playoffs 83 | season_id (str): The season identifier in YYYYYYYY format (e.g., "20222023", "20232024") 84 | player_id (str): The unique identifier for the NHL player 85 | 86 | Returns: 87 | dict: A dictionary containing the player's game-by-game statistics for the specified parameters 88 | 89 | Example: 90 | Full example here https://github.com/coreyjs/nhl-api-py/wiki/Stats.Player-Game-Log-%E2%80%90-Example-Response 91 | [ 92 | {'gameId': 2024020641, 93 | 'teamAbbrev': 'EDM', 94 | 'homeRoadFlag': 'R', 95 | 'gameDate': '2025-01-07', 96 | 'goals': 1, 97 | 'assists': 0, 98 | 'commonName': {'default': 'Oilers'}, 99 | 'opponentCommonName': {'default': 'Bruins'}, 100 | 'points': 1, 101 | 'plusMinus': 0, 102 | 'powerPlayGoals': 1, 103 | 'powerPlayPoints': 1, 104 | 'gameWinningGoals': 0, 105 | 'otGoals': 0, 106 | 'shots': 5, 107 | 'shifts': 18, 108 | 'shorthandedGoals': 0, 109 | 'shorthandedPoints': 0, 110 | 'opponentAbbrev': 'BOS', 111 | 'pim': 0, 112 | 'toi': '18:04'}, 113 | ... 114 | ] 115 | """ 116 | data = self.client.get( 117 | endpoint=Endpoint.API_WEB_V1, resource=f"player/{player_id}/game-log/{season_id}/{game_type}" 118 | ).json() 119 | return data.get("gameLog", []) 120 | 121 | def team_summary( 122 | self, 123 | start_season: str, 124 | end_season: str, 125 | game_type_id: int = 2, 126 | is_game: bool = False, 127 | is_aggregate: bool = False, 128 | sort_expr: List[dict] = None, 129 | start: int = 0, 130 | limit: int = 50, 131 | fact_cayenne_exp: str = "gamesPlayed>1", 132 | default_cayenne_exp: str = None, 133 | ) -> List[dict]: 134 | """Retrieves team summary statistics across one or more seasons. 135 | 136 | Gets aggregated team statistics for a specified range of seasons with optional filtering and sorting. 137 | 138 | Args: 139 | start_season (str): Beginning of season range in YYYYYYYY format (e.g., "20202021"). 140 | For single season queries, set equal to end_season. 141 | end_season (str): End of season range in YYYYYYYY format (e.g., "20212022") 142 | game_type_id (int, optional): Type of games to include: 143 | 2: Regular season (default) 144 | 3: Playoffs 145 | 1: Preseason 146 | is_game (bool, optional): Defaults False. (dev notes: not sure what this is, its part of the api call) 147 | is_aggregate (bool, optional): Defaults False. Whether to aggregate the statistics 148 | sort_expr (List[dict], optional): List of sorting criteria. Defaults to: 149 | [ 150 | {"property": "points", "direction": "DESC"}, 151 | {"property": "wins", "direction": "DESC"}, 152 | {"property": "teamId", "direction": "ASC"} 153 | ] 154 | start (int, optional): Starting index for pagination. Defaults to 0 155 | limit (int, optional): Maximum number of results to return. Defaults to 50 156 | fact_cayenne_exp (str, optional): Apache Cayenne filter expression. 157 | Defaults to 'gamesPlayed>=1' 158 | default_cayenne_exp (str, optional): Additional Apache Cayenne filter. 159 | Example: "gameTypeId=2 and seasonId<=20232024 and seasonId>=20232024" 160 | If provided, overrides the automatically generated expression. 161 | 162 | Returns: 163 | List[dict]: List of dictionaries containing team summary statistics 164 | 165 | Examples: 166 | Full Response Example: https://github.com/coreyjs/nhl-api-py/wiki/Stats.Team-Summary-%E2%80%90-Example-Response 167 | c.stats.team_summary(start_season="20202021", end_season="20212022", game_type_id=2) 168 | c.stats.team_summary(start_season="20202021", end_season="20212022") 169 | 170 | [{'faceoffWinPct': 0.48235, 171 | 'gamesPlayed': 82, 172 | 'goalsAgainst': 242, 173 | 'goalsAgainstPerGame': 2.95121, 174 | 'goalsFor': 337, 175 | 'goalsForPerGame': 4.10975, 176 | 'losses': 18, 177 | 'otLosses': 6, 178 | 'penaltyKillNetPct': 0.841698, 179 | 'penaltyKillPct': 0.795367, 180 | 'pointPct': 0.7439, 181 | 'points': 122, 182 | 'powerPlayNetPct': 0.21374, 183 | 'powerPlayPct': 0.244274, 184 | 'regulationAndOtWins': 55, 185 | 'seasonId': 20212022, 186 | 'shotsAgainstPerGame': 30.67073, 187 | 'shotsForPerGame': 37.34146, 188 | 'teamFullName': 'Florida Panthers', 189 | 'teamId': 13, 190 | 'ties': None, 191 | 'wins': 58, 192 | 'winsInRegulation': 42, 193 | 'winsInShootout': 3}, 194 | ... ] 195 | """ 196 | q_params = { 197 | "isAggregate": is_aggregate, 198 | "isGame": is_game, 199 | "start": start, 200 | "limit": limit, 201 | "factCayenneExp": fact_cayenne_exp, 202 | } 203 | 204 | if not sort_expr: 205 | sort_expr = [ 206 | {"property": "points", "direction": "DESC"}, 207 | {"property": "wins", "direction": "DESC"}, 208 | {"property": "teamId", "direction": "ASC"}, 209 | ] 210 | q_params["sort"] = json.dumps(sort_expr) 211 | 212 | if not default_cayenne_exp: 213 | default_cayenne_exp = f"gameTypeId={game_type_id} and seasonId<={end_season} and seasonId>={start_season}" 214 | q_params["cayenneExp"] = default_cayenne_exp 215 | 216 | return self.client.get(endpoint=Endpoint.API_STATS, resource="en/team/summary", query_params=q_params).json()[ 217 | "data" 218 | ] 219 | 220 | def skater_stats_summary( 221 | self, 222 | start_season: str, 223 | end_season: str, 224 | franchise_id: str = None, 225 | game_type_id: int = 2, 226 | aggregate: bool = False, 227 | sort_expr: List[dict] = None, 228 | start: int = 0, 229 | limit: int = 25, 230 | fact_cayenne_exp: str = "gamesPlayed>=1", 231 | default_cayenne_exp: str = None, 232 | ) -> List[dict]: 233 | """Gets simplified skater statistics summary for specified seasons and franchises. 234 | 235 | Retrieves aggregated or season-by-season skating statistics with optional filtering and sorting. 236 | 237 | 238 | Args: 239 | start_season (str): Beginning of season range in YYYYYYYY format (e.g., "20202021") 240 | end_season (str): End of season range in YYYYYYYY format 241 | franchise_id (str, optional): Franchise identifier specific to /stats APIs. 242 | Note: Different from team_id used in other endpoints 243 | game_type_id (int, optional): Type of games to include: 244 | 2: Regular season (Default) 245 | 3: Playoffs 246 | 1: Preseason 247 | aggregate (bool, optional): When True, combines multiple seasons' data per player. 248 | When False, returns separate entries per season. Defaults to False. 249 | sort_expr (List[dict], optional): List of sorting criteria. Defaults to: 250 | [ 251 | {"property": "points", "direction": "DESC"}, 252 | {"property": "gamesPlayed", "direction": "ASC"}, 253 | {"property": "playerId", "direction": "ASC"} 254 | ] 255 | start (int, optional): Starting index for pagination 256 | limit (int, optional): Maximum number of results to return. Defaults to 25. 257 | fact_cayenne_exp (str, optional): Base filter criteria. Defaults to 'gamesPlayed>=1' 258 | Can be modified for custom filtering 259 | default_cayenne_exp (str, optional): Additional filter expression 260 | 261 | Returns: 262 | List[dict]: List of dictionaries containing skater statistics 263 | 264 | Examples: 265 | Full Response Example: https://github.com/coreyjs/nhl-api-py/wiki/Stats.Skater-Stats-Summary-Simple 266 | c.stats.skater_stats_summary_simple(start_season="20232024", end_season="20232024") 267 | c.stats.skater_stats_summary_simple(franchise_id=10, start_season="20232024", end_season="20232024") 268 | 269 | [{'assists': 71, 270 | 'evGoals': 38, 271 | 'evPoints': 75, 272 | 'faceoffWinPct': 0.1, 273 | 'gameWinningGoals': 5, 274 | 'gamesPlayed': 82, 275 | 'goals': 49, 276 | 'lastName': 'Panarin', 277 | 'otGoals': 1, 278 | 'penaltyMinutes': 24, 279 | 'playerId': 8478550, 280 | 'plusMinus': 18, 281 | 'points': 120, 282 | 'pointsPerGame': 1.46341, 283 | 'positionCode': 'L', 284 | 'ppGoals': 11, 285 | 'ppPoints': 44, 286 | 'seasonId': 20232024, 287 | 'shGoals': 0, 288 | 'shPoints': 1, 289 | 'shootingPct': 0.16171, 290 | 'shootsCatches': 'R', 291 | 'shots': 303, 292 | 'skaterFullName': 'Artemi Panarin', 293 | 'teamAbbrevs': 'NYR', 294 | 'timeOnIcePerGame': 1207.1341}, 295 | ... ] 296 | """ 297 | q_params = { 298 | "isAggregate": aggregate, 299 | "isGame": False, 300 | "start": start, 301 | "limit": limit, 302 | "factCayenneExp": fact_cayenne_exp, 303 | } 304 | 305 | if not sort_expr: 306 | sort_expr = [ 307 | {"property": "points", "direction": "DESC"}, 308 | {"property": "gamesPlayed", "direction": "ASC"}, 309 | {"property": "playerId", "direction": "ASC"}, 310 | ] 311 | q_params["sort"] = json.dumps(sort_expr) 312 | 313 | if not default_cayenne_exp: 314 | default_cayenne_exp = f"gameTypeId={game_type_id} and seasonId<={end_season} and seasonId>={start_season}" 315 | if franchise_id: 316 | default_cayenne_exp = f"franchiseId={franchise_id} and {default_cayenne_exp}" 317 | q_params["cayenneExp"] = default_cayenne_exp 318 | 319 | return self.client.get(endpoint=Endpoint.API_STATS, resource="en/skater/summary", query_params=q_params).json()[ 320 | "data" 321 | ] 322 | 323 | def skater_stats_with_query_context( 324 | self, 325 | query_context: QueryContext, 326 | report_type: str, 327 | sort_expr: List[dict] = None, 328 | aggregate: bool = False, 329 | start: int = 0, 330 | limit: int = 25, 331 | ) -> dict: 332 | """Retrieves skater statistics using a query context and specified report type. 333 | 334 | Gets detailed skater statistics with customizable filtering, sorting, and aggregation options. 335 | 336 | Args: 337 | query_context (QueryContext): Context object containing query parameters 338 | report_type (str): Type of statistical report to retrieve: 339 | 'summary', 'bios', 'faceoffpercentages', 'faceoffwins', 340 | 'goalsForAgainst', 'realtime', 'penalties', 'penaltykill', 341 | 'penaltyShots', 'powerplay', 'puckPossessions', 342 | 'summaryshooting', 'percentages', 'scoringRates', 343 | 'scoringpergame', 'shootout', 'shottype', 'timeonice' 344 | sort_expr (List[dict], optional): List of sorting criteria. Defaults to None. 345 | Example format: 346 | [ 347 | {"property": "points", "direction": "DESC"}, 348 | {"property": "gamesPlayed", "direction": "ASC"}, 349 | {"property": "playerId", "direction": "ASC"} 350 | ] 351 | aggregate (bool, optional): When True, combines multiple seasons' data per player. 352 | When False, returns separate entries per season. Defaults to False. 353 | start (int, optional): Starting index for pagination. Defaults to 0. 354 | limit (int, optional): Maximum number of results to return. Defaults to 25. 355 | 356 | Returns: 357 | dict: Dictionary containing skater statistics based on the specified report type 358 | 359 | Example: 360 | Full example here: https://github.com/coreyjs/nhl-api-py/wiki/Stats.Skater-Stats-with-Query-Context 361 | 362 | filters = [ 363 | GameTypeQuery(game_type="2"), 364 | DraftQuery(year="2020", draft_round="2"), 365 | SeasonQuery(season_start="20202021", season_end="20232024"), 366 | PositionQuery(position=PositionTypes.ALL_FORWARDS) 367 | ] 368 | 369 | query_builder = QueryBuilder() 370 | query_context: QueryContext = query_builder.build(filters=filters) 371 | 372 | data = client.stats.skater_stats_with_query_context( 373 | report_type='summary', 374 | query_context=query_context, 375 | aggregate=True 376 | ) 377 | 378 | Response: 379 | {'data': [{'assists': 42, 380 | 'evGoals': 35, 381 | 'evPoints': 70, 382 | 'faceoffWinPct': 0.33333, 383 | 'gameWinningGoals': 6, 384 | 'gamesPlayed': 161, 385 | 'goals': 40, 386 | 'lastName': 'Peterka', 387 | 'otGoals': 0, 388 | 'penaltyMinutes': 54, 389 | 'playerId': 8482175, 390 | 'plusMinus': -5, 391 | 'points': 82, 392 | 'pointsPerGame': 0.50931, 393 | 'positionCode': 'R', 394 | 'ppGoals': 5, 395 | 'ppPoints': 12, 396 | 'shGoals': 0, 397 | 'shPoints': 0, 398 | 'shootingPct': 0.11299, 399 | 'shootsCatches': 'L', 400 | 'shots': 354, 401 | 'skaterFullName': 'JJ Peterka', 402 | 'timeOnIcePerGame': 904.5714}, 403 | ...] 404 | """ 405 | q_params = { 406 | "isAggregate": aggregate, 407 | "isGame": False, 408 | "start": start, 409 | "limit": limit, 410 | "factCayenneExp": query_context.fact_query, 411 | } 412 | 413 | if not sort_expr: 414 | sort_expr = SortingOptions.get_default_sorting_for_report(report_type) 415 | 416 | q_params["sort"] = json.dumps(sort_expr) 417 | q_params["cayenneExp"] = query_context.query_str 418 | return self.client.get( 419 | endpoint=Endpoint.API_STATS, resource=f"en/skater/{report_type}", query_params=q_params 420 | ).json() 421 | 422 | def goalie_stats_summary( 423 | self, 424 | start_season: str, 425 | end_season: str = None, 426 | stats_type: str = "summary", 427 | game_type_id: int = 2, 428 | franchise_id: str = None, 429 | aggregate: bool = False, 430 | sort_expr: List[dict] = None, 431 | start: int = 0, 432 | limit: int = 25, 433 | fact_cayenne_exp: str = None, 434 | default_cayenne_exp: str = None, 435 | ) -> List[dict]: 436 | """Retrieves goalie statistics with various filtering and aggregation options. 437 | 438 | A simple endpoint that returns different types of goalie statistics based on the specified stats_type parameter. 439 | 440 | Args: 441 | start_season (str): Beginning of season range in YYYYYYYY format (e.g., "20202021") 442 | end_season (str, optional): End of season range in YYYYYYYY format. 443 | Defaults to start_season if not provided. 444 | stats_type (str): Type of statistics to retrieve: 445 | 'summary', 'advanced', 'bios', 'daysrest', 'penaltyShots', 446 | 'savesByStrength', 'shootout', 'startedVsRelieved' 447 | game_type_id (int, optional): Type of games to include: 448 | 2: Regular season 449 | 3: Playoffs 450 | 1: Preseason (tentative) 451 | franchise_id (str, optional): Franchise identifier to filter results 452 | aggregate (bool, optional): When True, combines multiple seasons' data per goalie. 453 | When False, returns separate entries per season. Defaults to False. 454 | sort_expr (List[dict], optional): List of sorting criteria. Uses EDGE stats site defaults. 455 | Can be customized using any properties from the response payload. 456 | start (int, optional): Starting index for pagination 457 | limit (int, optional): Defaults to 25. Maximum number of results to return 458 | fact_cayenne_exp (str, optional): Base filter criteria 459 | default_cayenne_exp (str, optional): Additional filter expression 460 | 461 | Returns: 462 | dict: Dictionary containing goalie statistics based on the specified parameters 463 | 464 | Example: 465 | client.stats.goalie_stats_summary_simple(start_season="20242025", stats_type="summary") 466 | 467 | [{'assists': 0, 468 | 'gamesPlayed': 33, 469 | 'gamesStarted': 33, 470 | 'goalieFullName': 'Connor Hellebuyck', 471 | 'goals': 0, 472 | 'goalsAgainst': 69, 473 | 'goalsAgainstAverage': 2.08485, 474 | 'lastName': 'Hellebuyck', 475 | 'losses': 6, 476 | 'otLosses': 2, 477 | 'penaltyMinutes': 0, 478 | 'playerId': 8476945, 479 | 'points': 0, 480 | 'savePct': 0.92612, 481 | 'saves': 865, 482 | 'seasonId': 20242025, 483 | 'shootsCatches': 'L', 484 | 'shotsAgainst': 934, 485 | 'shutouts': 5, 486 | 'teamAbbrevs': 'WPG', 487 | 'ties': None, 488 | 'timeOnIce': 119145, 489 | 'wins': 25}, 490 | """ 491 | q_params = { 492 | "isAggregate": aggregate, 493 | "isGame": False, 494 | "start": start, 495 | "limit": limit, 496 | "factCayenneExp": fact_cayenne_exp, 497 | } 498 | 499 | if end_season is None: 500 | end_season = start_season 501 | 502 | if not sort_expr: 503 | sort_expr = _goalie_stats_sorts(report=stats_type) 504 | 505 | q_params["sort"] = json.dumps(sort_expr) 506 | 507 | if not default_cayenne_exp: 508 | default_cayenne_exp = f"gameTypeId={game_type_id} and seasonId<={end_season} and seasonId>={start_season}" 509 | 510 | if franchise_id: 511 | default_cayenne_exp = f"franchiseId={franchise_id} and {default_cayenne_exp}" 512 | 513 | q_params["cayenneExp"] = default_cayenne_exp 514 | 515 | response = self.client.get( 516 | endpoint=Endpoint.API_STATS, resource=f"en/goalie/{stats_type}", query_params=q_params 517 | ).json() 518 | return response.get("data", []) 519 | --------------------------------------------------------------------------------