From a66e367ce8a8e3f34f25ca360ee53fbb4f6b2d1c Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 12 Aug 2026 18:10:39 -0700 Subject: [PATCH 01/81] feat: add shared parsers for team, people, and schedule responses Move MLB envelope parsing into reusable helpers so endpoint methods can build Pydantic models in one place. --- mlbstatsapi/_parsers/__init__.py | 0 mlbstatsapi/_parsers/people.py | 9 +++++++ mlbstatsapi/_parsers/schedules.py | 9 +++++++ mlbstatsapi/_parsers/teams.py | 15 +++++++++++ tests/parsers/Untitled | 2 ++ tests/parsers/test_people.py | 38 ++++++++++++++++++++++++++++ tests/parsers/test_schedules.py | 37 ++++++++++++++++++++++++++++ tests/parsers/test_teams.py | 41 +++++++++++++++++++++++++++++++ 8 files changed, 151 insertions(+) create mode 100644 mlbstatsapi/_parsers/__init__.py create mode 100644 mlbstatsapi/_parsers/people.py create mode 100644 mlbstatsapi/_parsers/schedules.py create mode 100644 mlbstatsapi/_parsers/teams.py create mode 100644 tests/parsers/Untitled create mode 100644 tests/parsers/test_people.py create mode 100644 tests/parsers/test_schedules.py create mode 100644 tests/parsers/test_teams.py diff --git a/mlbstatsapi/_parsers/__init__.py b/mlbstatsapi/_parsers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mlbstatsapi/_parsers/people.py b/mlbstatsapi/_parsers/people.py new file mode 100644 index 00000000..006e49ef --- /dev/null +++ b/mlbstatsapi/_parsers/people.py @@ -0,0 +1,9 @@ +from mlbstatsapi.models.people import Person + +def parse_people(data: dict) -> list[Person]: + """Parse a list of people from the data""" + return [Person(**person) for person in data['people']] if data['people'] else [] + +def parse_person(data: dict) -> Person: + """Parse a person from the data""" + return Person(**data) if data else None \ No newline at end of file diff --git a/mlbstatsapi/_parsers/schedules.py b/mlbstatsapi/_parsers/schedules.py new file mode 100644 index 00000000..03e3be19 --- /dev/null +++ b/mlbstatsapi/_parsers/schedules.py @@ -0,0 +1,9 @@ +from mlbstatsapi.models.schedules import Schedule + +def parse_schedules(data: dict) -> list[Schedule]: + """Parse a list of schedules from the data""" + return [Schedule(**schedule) for schedule in data['schedules']] if data['schedules'] else [] + +def parse_schedule(data: dict) -> Schedule: + """Parse a schedule from the data""" + return Schedule(**data) if data else None \ No newline at end of file diff --git a/mlbstatsapi/_parsers/teams.py b/mlbstatsapi/_parsers/teams.py new file mode 100644 index 00000000..aafcf610 --- /dev/null +++ b/mlbstatsapi/_parsers/teams.py @@ -0,0 +1,15 @@ +from mlbstatsapi.models.teams import Team + + +def parse_teams(data: dict) -> list[Team]: + """Parse Team models from an MLB /teams response body.""" + if not data or not data.get("teams"): + return [] + return [Team(**team) for team in data["teams"]] + + +def parse_team(data: dict) -> Team | None: + """Parse a Team from a single team payload.""" + if not data: + return None + return Team(**data) diff --git a/tests/parsers/Untitled b/tests/parsers/Untitled new file mode 100644 index 00000000..dc7c6c3a --- /dev/null +++ b/tests/parsers/Untitled @@ -0,0 +1,2 @@ + assert parse_teams({}) == [] + assert parse_teams({"teams": []}) == [] \ No newline at end of file diff --git a/tests/parsers/test_people.py b/tests/parsers/test_people.py new file mode 100644 index 00000000..9d4efee5 --- /dev/null +++ b/tests/parsers/test_people.py @@ -0,0 +1,38 @@ +import pytest +from pydantic import ValidationError +from mlbstatsapi._parsers.people import parse_people, parse_person +from mlbstatsapi.models.people import Person + + +def test_parse_people(): + """Test the parse_people function""" + assert parse_people({}) == [] + assert parse_people({"people": []}) == [] + + people = parse_people( + { + "people": [ + {"id": 1, "name": "Person 1"}, + {"id": 2, "name": "Person 2"}, + ] + } + ) + + assert people == [ + Person(id=1, name="Person 1"), + Person(id=2, name="Person 2"), + ] + +def test_parse_person(): + """Test the parse_person function""" + assert parse_person({}) is None + + person = parse_person({"id": 1, "name": "Person 1"}) + + assert isinstance(person, Person) + assert person == Person(id=1, name="Person 1") + +def test_parse_person_requires_name(): + """Test the parse_person function requires name""" + with pytest.raises(ValidationError): + parse_person({"id": 1}) \ No newline at end of file diff --git a/tests/parsers/test_schedules.py b/tests/parsers/test_schedules.py new file mode 100644 index 00000000..8c7141cf --- /dev/null +++ b/tests/parsers/test_schedules.py @@ -0,0 +1,37 @@ +import pytest +from pydantic import ValidationError +from mlbstatsapi._parsers.schedules import parse_schedules, parse_schedule +from mlbstatsapi.models.schedules import Schedule + +def test_parse_schedules(): + """Test the parse_schedules function""" + assert parse_schedules({}) == [] + assert parse_schedules({"schedules": []}) == [] + + schedules = parse_schedules( + { + "schedules": [ + {"id": 1, "name": "Schedule 1"}, + {"id": 2, "name": "Schedule 2"}, + ] + } + ) + + assert schedules == [ + Schedule(id=1, name="Schedule 1"), + Schedule(id=2, name="Schedule 2"), + ] + +def test_parse_schedule(): + """Test the parse_schedule function""" + assert parse_schedule({}) is None + + schedule = parse_schedule({"id": 1, "name": "Schedule 1"}) + + assert isinstance(schedule, Schedule) + assert schedule == Schedule(id=1, name="Schedule 1") + +def test_parse_schedule_requires_name(): + """Test the parse_schedule function requires name""" + with pytest.raises(ValidationError): + parse_schedule({"id": 1}) \ No newline at end of file diff --git a/tests/parsers/test_teams.py b/tests/parsers/test_teams.py new file mode 100644 index 00000000..5dfffcab --- /dev/null +++ b/tests/parsers/test_teams.py @@ -0,0 +1,41 @@ +import pytest +from pydantic import ValidationError + +from mlbstatsapi._parsers.teams import parse_team, parse_teams +from mlbstatsapi.models.teams import Team + + +def test_parse_teams(): + """parse_teams reads the MLB teams envelope and returns Team models.""" + assert parse_teams({}) == [] + assert parse_teams({"teams": []}) == [] + + teams = parse_teams( + { + "teams": [ + {"id": 1, "link": "/api/v1/teams/1", "name": "Team 1"}, + {"id": 2, "link": "/api/v1/teams/2", "name": "Team 2"}, + ] + } + ) + + assert teams == [ + Team(id=1, link="/api/v1/teams/1", name="Team 1"), + Team(id=2, link="/api/v1/teams/2", name="Team 2"), + ] + + +def test_parse_team(): + """parse_team builds a Team from one team payload.""" + assert parse_team({}) is None + + team = parse_team({"id": 1, "link": "/api/v1/teams/1", "name": "Team 1"}) + + assert isinstance(team, Team) + assert team == Team(id=1, link="/api/v1/teams/1", name="Team 1") + + +def test_parse_team_requires_link(): + """Team requires link, the same required field used by the MLB API.""" + with pytest.raises(ValidationError): + parse_team({"id": 1, "name": "Team 1"}) From cab2a9a7eb7c33a6e73a8bb2c680772866f20846 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 12 Aug 2026 19:22:58 -0700 Subject: [PATCH 02/81] fix: pass full MLB response bodies into shared parsers The list parsers expect the response envelope, not the inner array. Wire people, team, and schedule endpoints through those helpers and align parser tests with the real model required fields. --- mlbstatsapi/_parsers/people.py | 16 ++++++++---- mlbstatsapi/_parsers/schedules.py | 11 ++++----- mlbstatsapi/_parsers/teams.py | 5 +++- mlbstatsapi/mlb_api.py | 24 ++++++++++++------ tests/parsers/Untitled | 2 -- tests/parsers/test_people.py | 27 +++++++++++--------- tests/parsers/test_schedules.py | 41 ++++++++++++------------------- 7 files changed, 68 insertions(+), 58 deletions(-) delete mode 100644 tests/parsers/Untitled diff --git a/mlbstatsapi/_parsers/people.py b/mlbstatsapi/_parsers/people.py index 006e49ef..c6837bb2 100644 --- a/mlbstatsapi/_parsers/people.py +++ b/mlbstatsapi/_parsers/people.py @@ -1,9 +1,15 @@ from mlbstatsapi.models.people import Person + def parse_people(data: dict) -> list[Person]: - """Parse a list of people from the data""" - return [Person(**person) for person in data['people']] if data['people'] else [] + """Parse Person models from an MLB /people response body.""" + if not data or not data.get("people"): + return [] + return [Person(**person) for person in data["people"]] + -def parse_person(data: dict) -> Person: - """Parse a person from the data""" - return Person(**data) if data else None \ No newline at end of file +def parse_person(data: dict) -> Person | None: + """Parse a Person from a single person payload.""" + if not data: + return None + return Person(**data) diff --git a/mlbstatsapi/_parsers/schedules.py b/mlbstatsapi/_parsers/schedules.py index 03e3be19..756501ff 100644 --- a/mlbstatsapi/_parsers/schedules.py +++ b/mlbstatsapi/_parsers/schedules.py @@ -1,9 +1,8 @@ from mlbstatsapi.models.schedules import Schedule -def parse_schedules(data: dict) -> list[Schedule]: - """Parse a list of schedules from the data""" - return [Schedule(**schedule) for schedule in data['schedules']] if data['schedules'] else [] -def parse_schedule(data: dict) -> Schedule: - """Parse a schedule from the data""" - return Schedule(**data) if data else None \ No newline at end of file +def parse_schedule(data: dict) -> Schedule | None: + """Parse a Schedule from an MLB /schedule response body.""" + if not data: + return None + return Schedule(**data) diff --git a/mlbstatsapi/_parsers/teams.py b/mlbstatsapi/_parsers/teams.py index aafcf610..d99115c5 100644 --- a/mlbstatsapi/_parsers/teams.py +++ b/mlbstatsapi/_parsers/teams.py @@ -2,7 +2,10 @@ def parse_teams(data: dict) -> list[Team]: - """Parse Team models from an MLB /teams response body.""" + """Parse Team models from an MLB /teams response body. + + Expects the full response, e.g. ``{"teams": [...]}``, not the inner list. + """ if not data or not data.get("teams"): return [] return [Team(**team) for team in data["teams"]] diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index 6216fc77..b9dad921 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -22,6 +22,11 @@ from mlbstatsapi.models.homerunderby import HomeRunDerby from mlbstatsapi.models.standings import Standings + +from ._parsers.people import parse_people, parse_person +from ._parsers.teams import parse_teams, parse_team +from ._parsers.schedules import parse_schedule + from .mlb_dataadapter import ( DEFAULT_TIMEOUT, MlbDataAdapter, @@ -145,7 +150,7 @@ def get_people(self, sport_id: int = 1, **params) -> List[Person]: people = [] if 'people' in mlb_data.data and mlb_data.data['people']: - people = [Person(**person) for person in mlb_data.data['people']] + people = parse_people(mlb_data.data) return people @@ -183,7 +188,10 @@ def get_person(self, player_id: int, **params) -> Union[Person, None]: if 'people' in mlb_data.data and mlb_data.data['people']: for person in mlb_data.data['people']: - return Person(**person) + person = parse_person(person) + if person: + return person + return None def get_persons(self, person_ids: Union[str, List[int]], **params) -> List[Person]: """ @@ -246,8 +254,7 @@ def get_persons(self, person_ids: Union[str, List[int]], **params) -> List[Perso person_list = [] if 'people' in mlb_data.data and mlb_data.data['people']: - for person in mlb_data.data['people']: - person_list.append(Person(**person)) + person_list = parse_people(mlb_data.data) return person_list @@ -382,7 +389,7 @@ def get_teams(self, sport_id: int = 1, **params) -> List[Team]: teams = [] if 'teams' in mlb_data.data and mlb_data.data['teams']: - teams = [Team(**team) for team in mlb_data.data['teams']] + teams = parse_teams(mlb_data.data) return teams @@ -451,8 +458,8 @@ def get_team(self, team_id: int, **params) -> Union[Team, None]: return None if 'teams' in mlb_data.data and mlb_data.data['teams']: - for team in mlb_data.data['teams']: - return Team(**team) + return parse_team(mlb_data.data['teams'][0]) + return None def get_team_id(self, team_name: str, search_key: str = 'name', **params) -> List[int]: @@ -831,7 +838,8 @@ def get_schedule(self, # Only check for existance 'dates' key for this reason. if 'dates' in mlb_data.data and mlb_data.data['dates']: - return Schedule(**mlb_data.data) + return parse_schedule(mlb_data.data) + return None def get_scheduled_games_by_date(self, date: str = None, start_date: str = None, diff --git a/tests/parsers/Untitled b/tests/parsers/Untitled deleted file mode 100644 index dc7c6c3a..00000000 --- a/tests/parsers/Untitled +++ /dev/null @@ -1,2 +0,0 @@ - assert parse_teams({}) == [] - assert parse_teams({"teams": []}) == [] \ No newline at end of file diff --git a/tests/parsers/test_people.py b/tests/parsers/test_people.py index 9d4efee5..a4ebe2da 100644 --- a/tests/parsers/test_people.py +++ b/tests/parsers/test_people.py @@ -1,38 +1,43 @@ import pytest from pydantic import ValidationError + from mlbstatsapi._parsers.people import parse_people, parse_person from mlbstatsapi.models.people import Person def test_parse_people(): - """Test the parse_people function""" + """parse_people reads the MLB people envelope and returns Person models.""" assert parse_people({}) == [] assert parse_people({"people": []}) == [] people = parse_people( { "people": [ - {"id": 1, "name": "Person 1"}, - {"id": 2, "name": "Person 2"}, + {"id": 1, "link": "/api/v1/people/1", "fullName": "Person 1"}, + {"id": 2, "link": "/api/v1/people/2", "fullName": "Person 2"}, ] } ) assert people == [ - Person(id=1, name="Person 1"), - Person(id=2, name="Person 2"), + Person(id=1, link="/api/v1/people/1", full_name="Person 1"), + Person(id=2, link="/api/v1/people/2", full_name="Person 2"), ] + def test_parse_person(): - """Test the parse_person function""" + """parse_person builds a Person from one person payload.""" assert parse_person({}) is None - person = parse_person({"id": 1, "name": "Person 1"}) + person = parse_person( + {"id": 1, "link": "/api/v1/people/1", "fullName": "Person 1"} + ) assert isinstance(person, Person) - assert person == Person(id=1, name="Person 1") + assert person == Person(id=1, link="/api/v1/people/1", full_name="Person 1") + -def test_parse_person_requires_name(): - """Test the parse_person function requires name""" +def test_parse_person_requires_link(): + """Person requires link, the same required field used by the MLB API.""" with pytest.raises(ValidationError): - parse_person({"id": 1}) \ No newline at end of file + parse_person({"id": 1, "fullName": "Person 1"}) diff --git a/tests/parsers/test_schedules.py b/tests/parsers/test_schedules.py index 8c7141cf..e60bc2fd 100644 --- a/tests/parsers/test_schedules.py +++ b/tests/parsers/test_schedules.py @@ -1,37 +1,28 @@ import pytest from pydantic import ValidationError -from mlbstatsapi._parsers.schedules import parse_schedules, parse_schedule -from mlbstatsapi.models.schedules import Schedule - -def test_parse_schedules(): - """Test the parse_schedules function""" - assert parse_schedules({}) == [] - assert parse_schedules({"schedules": []}) == [] - schedules = parse_schedules( - { - "schedules": [ - {"id": 1, "name": "Schedule 1"}, - {"id": 2, "name": "Schedule 2"}, - ] - } - ) +from mlbstatsapi._parsers.schedules import parse_schedule +from mlbstatsapi.models.schedules import Schedule - assert schedules == [ - Schedule(id=1, name="Schedule 1"), - Schedule(id=2, name="Schedule 2"), - ] def test_parse_schedule(): - """Test the parse_schedule function""" + """parse_schedule builds a Schedule from the full MLB schedule body.""" assert parse_schedule({}) is None - schedule = parse_schedule({"id": 1, "name": "Schedule 1"}) + payload = { + "totalItems": 1, + "totalEvents": 0, + "totalGames": 1, + "totalGamesInProgress": 0, + "dates": [], + } + schedule = parse_schedule(payload) assert isinstance(schedule, Schedule) - assert schedule == Schedule(id=1, name="Schedule 1") + assert schedule == Schedule(**payload) + -def test_parse_schedule_requires_name(): - """Test the parse_schedule function requires name""" +def test_parse_schedule_requires_totals(): + """Schedule requires the MLB total* fields from the response body.""" with pytest.raises(ValidationError): - parse_schedule({"id": 1}) \ No newline at end of file + parse_schedule({"dates": []}) From 13d71ef587600303877ed55849e529a7210c5408 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 12 Aug 2026 21:47:41 -0700 Subject: [PATCH 03/81] fix: parse single-resource responses from MLB envelopes Reuse collection parsers for person and team responses, handle empty schedules, and align parser tests with real API payloads. --- mlbstatsapi/_parsers/people.py | 9 +++++++-- mlbstatsapi/_parsers/schedules.py | 3 ++- mlbstatsapi/_parsers/teams.py | 8 ++++++-- mlbstatsapi/mlb_api.py | 10 ++-------- tests/parsers/test_people.py | 4 ++-- tests/parsers/test_schedules.py | 26 ++++++++++++++++++++++++-- tests/parsers/test_teams.py | 4 ++-- 7 files changed, 45 insertions(+), 19 deletions(-) diff --git a/mlbstatsapi/_parsers/people.py b/mlbstatsapi/_parsers/people.py index c6837bb2..eb7e9709 100644 --- a/mlbstatsapi/_parsers/people.py +++ b/mlbstatsapi/_parsers/people.py @@ -10,6 +10,11 @@ def parse_people(data: dict) -> list[Person]: def parse_person(data: dict) -> Person | None: """Parse a Person from a single person payload.""" - if not data: + + people = parse_people(data) + + if not people: return None - return Person(**data) + + return people[0] + diff --git a/mlbstatsapi/_parsers/schedules.py b/mlbstatsapi/_parsers/schedules.py index 756501ff..39fc0d6b 100644 --- a/mlbstatsapi/_parsers/schedules.py +++ b/mlbstatsapi/_parsers/schedules.py @@ -3,6 +3,7 @@ def parse_schedule(data: dict) -> Schedule | None: """Parse a Schedule from an MLB /schedule response body.""" - if not data: + if not data or not data.get("dates"): return None + return Schedule(**data) diff --git a/mlbstatsapi/_parsers/teams.py b/mlbstatsapi/_parsers/teams.py index d99115c5..772bcbe0 100644 --- a/mlbstatsapi/_parsers/teams.py +++ b/mlbstatsapi/_parsers/teams.py @@ -13,6 +13,10 @@ def parse_teams(data: dict) -> list[Team]: def parse_team(data: dict) -> Team | None: """Parse a Team from a single team payload.""" - if not data: + teams = parse_teams(data) + + if not teams: return None - return Team(**data) + + return teams[0] + diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index b9dad921..e7f2afa0 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -187,11 +187,7 @@ def get_person(self, player_id: int, **params) -> Union[Person, None]: return None if 'people' in mlb_data.data and mlb_data.data['people']: - for person in mlb_data.data['people']: - person = parse_person(person) - if person: - return person - return None + return parse_person(mlb_data.data) def get_persons(self, person_ids: Union[str, List[int]], **params) -> List[Person]: """ @@ -457,9 +453,7 @@ def get_team(self, team_id: int, **params) -> Union[Team, None]: if 400 <= mlb_data.status_code <= 499: return None - if 'teams' in mlb_data.data and mlb_data.data['teams']: - return parse_team(mlb_data.data['teams'][0]) - return None + return parse_team(mlb_data.data) def get_team_id(self, team_name: str, search_key: str = 'name', **params) -> List[int]: diff --git a/tests/parsers/test_people.py b/tests/parsers/test_people.py index a4ebe2da..429d7206 100644 --- a/tests/parsers/test_people.py +++ b/tests/parsers/test_people.py @@ -30,7 +30,7 @@ def test_parse_person(): assert parse_person({}) is None person = parse_person( - {"id": 1, "link": "/api/v1/people/1", "fullName": "Person 1"} + {"people": [{"id": 1, "link": "/api/v1/people/1", "fullName": "Person 1"}]} ) assert isinstance(person, Person) @@ -40,4 +40,4 @@ def test_parse_person(): def test_parse_person_requires_link(): """Person requires link, the same required field used by the MLB API.""" with pytest.raises(ValidationError): - parse_person({"id": 1, "fullName": "Person 1"}) + parse_person({"people": [{"id": 1, "fullName": "Person 1"}]}) diff --git a/tests/parsers/test_schedules.py b/tests/parsers/test_schedules.py index e60bc2fd..fcd1b418 100644 --- a/tests/parsers/test_schedules.py +++ b/tests/parsers/test_schedules.py @@ -14,7 +14,16 @@ def test_parse_schedule(): "totalEvents": 0, "totalGames": 1, "totalGamesInProgress": 0, - "dates": [], + "dates": [ + { + "date": "2026-08-12", + "totalItems": 1, + "totalEvents": 0, + "totalGames": 1, + "totalGamesInProgress": 0, + "games": [], + } + ] } schedule = parse_schedule(payload) @@ -25,4 +34,17 @@ def test_parse_schedule(): def test_parse_schedule_requires_totals(): """Schedule requires the MLB total* fields from the response body.""" with pytest.raises(ValidationError): - parse_schedule({"dates": []}) + parse_schedule( + { + "dates": [ + { + "date": "2026-08-12", + "totalItems": 0, + "totalEvents": 0, + "totalGames": 0, + "totalGamesInProgress": 0, + "games": [], + } + ] + } + ) diff --git a/tests/parsers/test_teams.py b/tests/parsers/test_teams.py index 5dfffcab..6fe9c4e4 100644 --- a/tests/parsers/test_teams.py +++ b/tests/parsers/test_teams.py @@ -29,7 +29,7 @@ def test_parse_team(): """parse_team builds a Team from one team payload.""" assert parse_team({}) is None - team = parse_team({"id": 1, "link": "/api/v1/teams/1", "name": "Team 1"}) + team = parse_team({"teams": [{"id": 1, "link": "/api/v1/teams/1", "name": "Team 1"}]}) assert isinstance(team, Team) assert team == Team(id=1, link="/api/v1/teams/1", name="Team 1") @@ -38,4 +38,4 @@ def test_parse_team(): def test_parse_team_requires_link(): """Team requires link, the same required field used by the MLB API.""" with pytest.raises(ValidationError): - parse_team({"id": 1, "name": "Team 1"}) + parse_team({"teams": [{"id": 1, "name": "Team 1"}]}) From ddb08956945cf5d58856e4fa46cd4d95e029a7be Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 12 Aug 2026 22:13:34 -0700 Subject: [PATCH 04/81] fix: updated the get_person and get_schedule methods --- mlbstatsapi/mlb_api.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index e7f2afa0..b0d91311 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -186,8 +186,7 @@ def get_person(self, player_id: int, **params) -> Union[Person, None]: if 400 <= mlb_data.status_code <= 499: return None - if 'people' in mlb_data.data and mlb_data.data['people']: - return parse_person(mlb_data.data) + return parse_person(mlb_data.data) def get_persons(self, person_ids: Union[str, List[int]], **params) -> List[Person]: """ @@ -831,9 +830,7 @@ def get_schedule(self, # can sometimes be an empty list when there are no scheduled game for the date(s). # Only check for existance 'dates' key for this reason. - if 'dates' in mlb_data.data and mlb_data.data['dates']: - return parse_schedule(mlb_data.data) - return None + return parse_schedule(mlb_data.data) def get_scheduled_games_by_date(self, date: str = None, start_date: str = None, From 59cdebd763492665d7ef89079443e38ac5a41412 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 12 Aug 2026 22:22:51 -0700 Subject: [PATCH 05/81] fix: updated get_teams and get_people to use the parsers correctly --- mlbstatsapi/mlb_api.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index b0d91311..ace2c570 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -246,12 +246,7 @@ def get_persons(self, person_ids: Union[str, List[int]], **params) -> List[Perso if 400 <= mlb_data.status_code <= 499: return [] - person_list = [] - - if 'people' in mlb_data.data and mlb_data.data['people']: - person_list = parse_people(mlb_data.data) - - return person_list + return parse_people(mlb_data.data) def get_people_id(self, fullname: str, sport_id: int = 1, search_key: str = 'fullName', **params) -> List[int]: @@ -381,12 +376,8 @@ def get_teams(self, sport_id: int = 1, **params) -> List[Team]: if 400 <= mlb_data.status_code <= 499: return [] - teams = [] - - if 'teams' in mlb_data.data and mlb_data.data['teams']: - teams = parse_teams(mlb_data.data) + return parse_teams(mlb_data.data) - return teams def get_team(self, team_id: int, **params) -> Union[Team, None]: """ From 9171b60fec1799ef21e39183d48b7d9f167c343b Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 12 Aug 2026 23:12:20 -0700 Subject: [PATCH 06/81] build: add optional HTTPX async dependency --- poetry.lock | 357 +++++++++++++++++++++++++++++++++++-------------- pyproject.toml | 4 + 2 files changed, 261 insertions(+), 100 deletions(-) diff --git a/poetry.lock b/poetry.lock index ac3da903..7cb9084a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -11,6 +11,25 @@ files = [ {file = "annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7"}, ] +[[package]] +name = "anyio" +version = "4.14.2" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = true +python-versions = ">=3.10" +files = [ + {file = "anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494"}, + {file = "anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +trio = ["trio (>=0.32.0)"] + [[package]] name = "backports-tarfile" version = "1.2.0" @@ -174,104 +193,183 @@ pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} [[package]] name = "charset-normalizer" -version = "3.4.9" +version = "3.5.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" files = [ - {file = "charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a"}, - {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616"}, - {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209"}, - {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99"}, - {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8"}, - {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b"}, - {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2"}, - {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9"}, - {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15"}, - {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d"}, - {file = "charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381"}, - {file = "charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee"}, - {file = "charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1"}, - {file = "charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0"}, - {file = "charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115"}, - {file = "charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b"}, - {file = "charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177"}, - {file = "charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-win32.whl", hash = "sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-win_amd64.whl", hash = "sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b"}, - {file = "charset_normalizer-3.4.9-cp39-cp39-win_arm64.whl", hash = "sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe"}, - {file = "charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5"}, - {file = "charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b"}, + {file = "charset_normalizer-3.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d2478bd3b2ead3962a484fb802891be40d10049fb74f83e09cb4463fad023fea"}, + {file = "charset_normalizer-3.5.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7cdded069549b5eae3d5d9bb6c2e5bb4fe83f9b81863e2a193cd747bf197aebb"}, + {file = "charset_normalizer-3.5.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aff38231e3171c578b2c449a01afa44e9ff40844597a32873da102394f63d28e"}, + {file = "charset_normalizer-3.5.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4346a693c08b1d0cfc0e3325bfb0ecd4322fb1a6904d68cf416f8da5e981b234"}, + {file = "charset_normalizer-3.5.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b787efadba00f5da6fe89513bfbe3852d52ca3a448fdec165765cb3b44a80248"}, + {file = "charset_normalizer-3.5.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:143792a43e06dc3b27fc891948406e251502dc19ff9216cd80182b79131be5c5"}, + {file = "charset_normalizer-3.5.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d74bcf1cdd8ac8267fb216473ce6b112efa07b163536288094541415084d131c"}, + {file = "charset_normalizer-3.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbe543d957213fc9a3db4979a8e171b7aa7504c1d737029defdb03a6095a38"}, + {file = "charset_normalizer-3.5.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3587d94b5c9f05c2dc4c3f3d47aba6375ff141a21adae3051d8d4d53e8a937c0"}, + {file = "charset_normalizer-3.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a7cb4cd266bd85613367fb85a30cfbf6fe6349919e87e18ca8dba584951bfb8a"}, + {file = "charset_normalizer-3.5.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ffdd7ac514301d0a67f7c23b9f2b431ef909a3c3dd6c3766668d0a6f5900c94e"}, + {file = "charset_normalizer-3.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3684ebbdffd51329ac44245d1d227d90b965797aa1a8abd026568a1f6ae88811"}, + {file = "charset_normalizer-3.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8b8788f114845c01f2b520e0b91ea58d143276cfc0483aa943e815f7b9555c15"}, + {file = "charset_normalizer-3.5.0-cp310-cp310-win32.whl", hash = "sha256:9a1d9b13e5e394e13e3c316f0d910d100b17681ff59797f30da1dba032061296"}, + {file = "charset_normalizer-3.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:5a54587f93f2e289f8faf25b35c997d4cc75cf677485ac6f50c985715989f99c"}, + {file = "charset_normalizer-3.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:38a395079f229a631dece74e24c69c1f612536dd51f345a7d6a98abe2d3e047a"}, + {file = "charset_normalizer-3.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e46a37ea7fcf9ae01d71b2e5ece19f1565987f3e308394b829197cbefc061f92"}, + {file = "charset_normalizer-3.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cdfed4d7a59333c8220c67dd3be4e7a6c887b67453a64394022dcc919570add"}, + {file = "charset_normalizer-3.5.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9491f594859b68052edebd69e05fb045055a713b57a67974e6c1553b4e503c39"}, + {file = "charset_normalizer-3.5.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:420b19411959eec115063229536788e6b32d0a7fa907d6b940317919120d702d"}, + {file = "charset_normalizer-3.5.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a565303d118ea3b94a4b6c076bf568069726be414e43b06d58f7070b076ce11d"}, + {file = "charset_normalizer-3.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:815f143a91983ba3041bba066e492ae3c42de523fb1c699685a1abf3313b7d1b"}, + {file = "charset_normalizer-3.5.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c5e981a5ac8641381efe6f0029467500661616a530d27bc6eedfe45f840599f8"}, + {file = "charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1a573e1e428f93908e79e04b349717f400e720f2f82285f0aaaf3ee0ff7f4c79"}, + {file = "charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:32e6d56dd825205f81e5c45bcebb4df6a11fb2bbf4969a01ef156d6ced90c224"}, + {file = "charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:30ae26a1adcd943690dcbbc47f28be762bae9e08ad7442b78c86b1c0dd5a626c"}, + {file = "charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f51a19dc52197a20218b05ec5336d0c6b3b09935f838724722032c8d45dc91a"}, + {file = "charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e44bc2780516b3df986d6fe33103c7080cd9dcd5576fe3cb4b0f64309c8f22b"}, + {file = "charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7faa47b56070b3dd6f4898ed28528843ab130d53266cb9948d9b1f3bb1a5c5e8"}, + {file = "charset_normalizer-3.5.0-cp311-cp311-win32.whl", hash = "sha256:830c04a49998b5ed58c8b642c65b7b26419397f52392a64121ba9fd0e95e7f9f"}, + {file = "charset_normalizer-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:8cb9b6892b53bd6d11fa4cde3dbee020b1f0b6656be1fbaa1ec0d4324a7839db"}, + {file = "charset_normalizer-3.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:2403b489c103e9a18c835863fc6dd54361355c8291d4cafdb37492b683440b9b"}, + {file = "charset_normalizer-3.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:98820e1ceb25c6df7a80c4fd8efa59cb121f99bc7c4c1693ad94a2caff5b311d"}, + {file = "charset_normalizer-3.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:608553f476fca509537e804c4a71f5eb166ce63b75141f89c2c686ce1aa36956"}, + {file = "charset_normalizer-3.5.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6753de11eef42f1c321b26d682957d92c7f7bbce6530f34bbe0f9291dd37cc6f"}, + {file = "charset_normalizer-3.5.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f76dc0a47f94cb9b69d86f01e477f4b0371ca70208b9ccea7e063c41eed9046"}, + {file = "charset_normalizer-3.5.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c387c6bf91b4774e359a48a179e2872b8e8bf741e4fde06ba8d1665eb9a4760a"}, + {file = "charset_normalizer-3.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14f6904a3cf870abf044df3a8c4924ac6c8ef77e9896586fd37e73ae96cff2af"}, + {file = "charset_normalizer-3.5.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cce46dd29d73e135e8087b96eb62a4aca6d69391b7f97808c6588ebed3178f3"}, + {file = "charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b476cdb63df22da2b91837593380be3ddbe406f36c506c1c91d80e7196b66288"}, + {file = "charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1f56ce84b317ef2a59d7d3461891c7597c79247d2192bb8114c68a1a1debfcc0"}, + {file = "charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9ce0f885239357379d92fd9a5fddbe20f0e30e0527c29ba69f8e99eeb1304a76"}, + {file = "charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:96ae7ab5d8155fde927aa0864fbc8ba3cc4fde6d41ab0c7cea9d6012b4978603"}, + {file = "charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bf91921009025e96ce57a03ced6d14604fc3baf0530351638e9504a55da6fa3b"}, + {file = "charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0b2e44e6d42d1a4ff78ccc219a93c5449105d10b16198d1aea581080df8073f9"}, + {file = "charset_normalizer-3.5.0-cp312-cp312-win32.whl", hash = "sha256:deb99535e9bf0bea8e274c6413eb939a21be35a3f492678dba4d5b1f4d70f142"}, + {file = "charset_normalizer-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54dd1a66fa4bce0ccaf0db9dde336e49b3eec646dc4c1c0991279369d373a14"}, + {file = "charset_normalizer-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:b8ea208b304587d47931b36481342d20336e0d338ab052f8b4305926482598d6"}, + {file = "charset_normalizer-3.5.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:5c23fa4f6eccdd601949cb00f3988c01d64e671d8faba356397971077022e144"}, + {file = "charset_normalizer-3.5.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:07f6f42b5a6325df35b458004fb5f9f29bf502d89287a33c7cdef3590e31de0f"}, + {file = "charset_normalizer-3.5.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8efc3f1563ed431882dd0dc0411b5f8ace1b1b89074981deaf6bd8af77dbe1bc"}, + {file = "charset_normalizer-3.5.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:368eb2fc9482158b3a3386e8f01fa61f479c968e9a19ceab8f0188b86b312991"}, + {file = "charset_normalizer-3.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:826a295a039178479a325be1ae60eded1f0b10f7dda749df59e2440de8f61d64"}, + {file = "charset_normalizer-3.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70ff1c16eb0eb5ee6bb12739292347f981a5ba764cc4df1bc2e69b0405d4ac3b"}, + {file = "charset_normalizer-3.5.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3cfdab178a4add5483e26a9bb1c16d8018ccf39b4be7a3aea6c3979e6828f2ee"}, + {file = "charset_normalizer-3.5.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6083d10a846218502d664375b9448508d9fa580bd834567423156c6abfbe899d"}, + {file = "charset_normalizer-3.5.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0f211c21aa316cb6e2662e54a1194633a79d98a50a876addacfce7ba5b34b09f"}, + {file = "charset_normalizer-3.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b08ebf9488c7ff5eff038e48e6ea938178dfd9dcc8598b5ca941e4ae27b20be"}, + {file = "charset_normalizer-3.5.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95450fce59f00c6d08eff6572ec2e736e5054c9450253afd5748f8416f2eb9"}, + {file = "charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5780a29823e1d2bec69b7a104ead4195a43f3e97782efaedbf1f79a0157af715"}, + {file = "charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:054420b5db984971d886e5e4e2c37c760ae6682aedbd066687ff0949d9ed5f08"}, + {file = "charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d016dc857136c726958102c3b8a3986acdc65ace6fbf12cfdc09cc4bfa2935b2"}, + {file = "charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f2ce3d39fb4a9d674e6639dd5d3146b2e273475d2260f10163228d66fc04433d"}, + {file = "charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5613a3a82c974227bde18f03409e30c467f8065cb56d822e3eb83708a5f223d"}, + {file = "charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fded2e82ff082e5d8e017e2ddcc1411bd8cb83b8585097fc401ef574f756b888"}, + {file = "charset_normalizer-3.5.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:8f006866047c6ec4b627ec144b1e0bbc7427cb31fd7c08d19897d0ac9032af3d"}, + {file = "charset_normalizer-3.5.0-cp313-cp313-win32.whl", hash = "sha256:196e270c4e80827b5072eed7d6aa661d133afada94fe366669f9609e718d305e"}, + {file = "charset_normalizer-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:72982d9958a42f8132bf2d6b90214ed66477295ef1188731f98ae3511c6eeb5a"}, + {file = "charset_normalizer-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:0b373bab0b867b68b8eb249da9478cab9181a42993437cd2f5dba5fb0b4fbd1b"}, + {file = "charset_normalizer-3.5.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d95244906ed69d0f79f190893c65e336c15959003e21449256dc05c001b52ea2"}, + {file = "charset_normalizer-3.5.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d788e2ded0c4c47efa4d73cfe59eaf975ee32f425219873d2cb3e3fbaa00f636"}, + {file = "charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:f9f91d3e8382900f3a68fa0ce94294479de9cd2de6bc0c70acd0f0dfd511836b"}, + {file = "charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d54625cbf4e6b60bf0639728cb8b4cb541e340f6d7cafae5806051a40ddf4c45"}, + {file = "charset_normalizer-3.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1f99a8c3a1da5d955edbad18208b3d627bdd54c48a6e739fa877bdca98c686d6"}, + {file = "charset_normalizer-3.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf1e75dc07a3850b53d1e5f75e04d3ae12afe56284be7821771eaa2466350c73"}, + {file = "charset_normalizer-3.5.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ac5a9cc079c67d75f4ddf343276031879eadbb333d1bb231cce297b8d7b9aae8"}, + {file = "charset_normalizer-3.5.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0dfe83c1b4d00abbf433998117a14f56a5c2bc68226c0d331709eed0d1ce539b"}, + {file = "charset_normalizer-3.5.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e4e8fa586df2208ef040684751345f10f503834a757c9a74ecd19c1a2f9b1ccd"}, + {file = "charset_normalizer-3.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82cc5835997ec78afe293a192e385099355770a7db94b2fb1239d36b32796f1c"}, + {file = "charset_normalizer-3.5.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:19e52bda45086df8a4be4bb5910af6f5d9d3b538c78712c8ae09ef10b85bf458"}, + {file = "charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3418edd0ecb72a0a3861cf72f31be0ad9b7fe338ce2b58fb5cc80b9aeb792700"}, + {file = "charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:125ee619611019471b177c70bc3e9d4cda9fad7e01d93523501d3b188df0193a"}, + {file = "charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a3ad0e3da22852533858663848608f3f24c0d35e5cde415a4903476f2b4c88ec"}, + {file = "charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4ebebb410bc517e1d284c52a123e82704b21e4e7e26a21ebecf7439d0647b8a3"}, + {file = "charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:91f9f7c151e772acebe489eaec96e96a2877202d7dd144e3f96b8676881715a0"}, + {file = "charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:401ea6e7af9e7852ed818f64714b579c1935482049670847ca3bd7ba45dc63fb"}, + {file = "charset_normalizer-3.5.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f7496aed56b06325a1ad419c5bf23c6dd042558e874f71dd1b958f3e255f3053"}, + {file = "charset_normalizer-3.5.0-cp314-cp314-win32.whl", hash = "sha256:606a86c1c3196f3738de39a67a7490bbd61cb31c0e0436070bd0c6a48170b38e"}, + {file = "charset_normalizer-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ec6c464cf45867f66a2273e2214d9199a8fbad5cb95ca0fd45f6a2fe1d9d2cf4"}, + {file = "charset_normalizer-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:dc28949de1bb5f7f30a46f15d74ce7ac5aaa63e03c5de04d68f571c7423af834"}, + {file = "charset_normalizer-3.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:68b7e84ae8239a94f8d2c8f3f3a3a81bcde54805ec8f42a34de927d155688ec6"}, + {file = "charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58ca5dc0a0ef99f2801ec0574214c978e9574055bc783830bbb6e7433218609f"}, + {file = "charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6c57af4084c10cb3286688d65e4c654190ff5edcbc2411d08cdca0a8a44c59a1"}, + {file = "charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1619a3cc174a7e3963dd34348e6fceb6e50db0ddeb0031bd7c73a58286454fa"}, + {file = "charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1328cc57dd4372be1265f68232cee890e087416e3e6e93e6ffb32c2bad4d36a4"}, + {file = "charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06f4fb62a9139bef056b8b2da6773c94c2f259f90e4b8e53b166f3d0372d7cf6"}, + {file = "charset_normalizer-3.5.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:478650a70a750d75d5add401606c77f77069c32e4ba2c9131dc6cee566962ca0"}, + {file = "charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:48920bf6fe83eb2226756ac623fa54940487154eb18f80889d5735cf234965c0"}, + {file = "charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:168a0cb536b5123a77bc42ecf5e0bf6f923d0d9ae43c42a14eb0677c19ac6c19"}, + {file = "charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f278e131afa96a3622cef9211c406ea2ad1b68eb06f8837cd443684a40e0ae50"}, + {file = "charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d6100f877d2ed95f0856a3fde25334153add94bf2224c43f45f88e7039262aaa"}, + {file = "charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4c440122e1ea68b1f8b44a631ebf49c39180f6869b1da22d76e8a724208ec6e9"}, + {file = "charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e5f834965c2fe589837bac1002e07e25734ff70381903ccd95b3d649e22bfa40"}, + {file = "charset_normalizer-3.5.0-cp314-cp314t-win32.whl", hash = "sha256:076cf9d3f3c7e410295c09d96355cf3b1bcae74990034d80e4371e20fe1ba4c6"}, + {file = "charset_normalizer-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3288a560dc3114d5d2ebe309b1ef43f8af355eafe25856832415c2a8196c9db3"}, + {file = "charset_normalizer-3.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a284c36b9c6616bf0a8aa4aabba668a0c75ba65ccf40a79868aeaa69ad996897"}, + {file = "charset_normalizer-3.5.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c38d1e9bc2073b0984d2099ea647fd7f6c0d8f83a1e14e0cd32926f16e4c44ce"}, + {file = "charset_normalizer-3.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9f45186390aee4d1f26f723c615b67df346766c3b16df000d84d6e374f06757"}, + {file = "charset_normalizer-3.5.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f0fde5e5100c735b2274ab898f0742a5dcde492796296cfbe7e0ad6a4cd1a396"}, + {file = "charset_normalizer-3.5.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d00e18e7bbf47e332ab63903d18bae31efc701b1d8cca0382b97784a621fc44"}, + {file = "charset_normalizer-3.5.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b81980668800dd1c69faad8aea6e85a8cee0e13bcd3bba7671695ff16260293"}, + {file = "charset_normalizer-3.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb3e0d1345b9c0fe73673ea656375f38a78ec679c2edeae0c24800f04798a85"}, + {file = "charset_normalizer-3.5.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2401f7671242e921e604f609d429f6b282ea4ca787a6ffd22ed7372011ddb9d1"}, + {file = "charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:96720f2aeed3434bc48f4d52fbad64ecc820cfed88915d664780ed9ba09ede78"}, + {file = "charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:c455829625df983f716cbaecbba77f2d1dc2e0e0ed1638c059cece15a279344b"}, + {file = "charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:c41b067eddcfa5ee6b1169c287605be7fb6b0ea22bba6474c5bb978a668def4f"}, + {file = "charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:e31786a947b136329bfdc458c82c06d4ec539b4a4436b7da4df4aafc9902ee80"}, + {file = "charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:c5c6d47a865147e0ae3322ce92e7fb52ba3169d94b447deda56897ea2aa6fac9"}, + {file = "charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c75191e3c8052045179646cb40e280800a4e0bdfda34d9c949c2f268d44e80e4"}, + {file = "charset_normalizer-3.5.0-cp315-cp315-win32.whl", hash = "sha256:83b62410bd36bb1178a7d563e2ee0cf21eb1c980c912ab99c2c78f06227f1731"}, + {file = "charset_normalizer-3.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:e3b9eaa99a6d8c9ace4cd303915947ef55088d4cd87c6676874f98c5c03aa040"}, + {file = "charset_normalizer-3.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:fec352b793cdc183cc9e7e0b6c10fd7bff38ec54ba44cc43599b9b56f7f3db2e"}, + {file = "charset_normalizer-3.5.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c9bde7a960720c8b8e1b5ef7afaa0c9a2f3b55c44abd635b2b29dd066b298e3a"}, + {file = "charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8cd1283a9fe6c2065c807e9d5da81afe5e1e004caef39adc0d8ae86dd883698"}, + {file = "charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1c010dd86d3f4c4433c9634d33ce8147393b270dfa54f217f965540b8ae8e075"}, + {file = "charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5e68229977b2dea28e7061c0c0630a23f2f9f6e9c6fb38d77d3d6dbfe3768b74"}, + {file = "charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a60773eb5fda796e6e6f76b9c152d270fe59f9788a51a6ff8ba44082d8548ae4"}, + {file = "charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9419f44e568f7fafcdc0b3b5c766a2364e705a9b34fb8a56b431e0d1f3f4258"}, + {file = "charset_normalizer-3.5.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75e243abbb528c1a774390ed71e3f868a9f37b1373442e4bbadd401cfc505ff4"}, + {file = "charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:54c963ce6404e52255b737e8a06d356fc762d59096ae566203a67cf2b7d050f2"}, + {file = "charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:63ea0cc840c66670183578c2630d138c0e944aeadfc33f25173ee240f5db780d"}, + {file = "charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6c06875a1d4a7537bef70f659b55c6b55b9a47ec3ba8f2db610350c2d9915e6e"}, + {file = "charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:4253da1b4456b633651a8d59eb1dc7a8a8fa38241014dd7c217b353e547ae394"}, + {file = "charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:f044cb1cf44012184715f46584658993b5fee9344d71c4b0c455a17a299730c0"}, + {file = "charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a17864853f7c518ae7d4b368af98f427f9396805476af40af8698560f09d7d97"}, + {file = "charset_normalizer-3.5.0-cp315-cp315t-win32.whl", hash = "sha256:9e726478d7a213847860219d74665a6892a643ac93b8f76580f6cf9ed39996b7"}, + {file = "charset_normalizer-3.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:d7229a99120c6c2792d96f4857c2648ce5530e93667a2c2388c5ef69a6b84775"}, + {file = "charset_normalizer-3.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:527e28a5e751d9e11369b9c5f9ab35c748eb9c109101920c7deb40d6eadf8d03"}, + {file = "charset_normalizer-3.5.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:5a4ee37248dfac25107c758bda99d545ce73e60b44d2dd39e4a2bb9f2831e9f5"}, + {file = "charset_normalizer-3.5.0-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a864bdcacd8bff58bb4845304e031f821a3ec64b2b7259f2d409cd49c9e59ca3"}, + {file = "charset_normalizer-3.5.0-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84b736e3b391601bc47b86da381c749c0f894e9191aaca9f31f30c2632206df3"}, + {file = "charset_normalizer-3.5.0-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6abb1f356fb865baeb6ebc3fadd843e9a96fbf49b9adcca55037f3cceccb7438"}, + {file = "charset_normalizer-3.5.0-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d366548d2ee28a8cfdcc4296363978cc644a728333be9824d2de4652e83df0a"}, + {file = "charset_normalizer-3.5.0-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d672f329ae504ee240eb39b6effb3318aa8e7e8924c0ce8eee5760b3fad98539"}, + {file = "charset_normalizer-3.5.0-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c54036a518748b6c02e666f6d46c3817561998fb904c3be25b56fb4fe3dc5706"}, + {file = "charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:22a1889f1c9b752c63c36758a0c2145458e3cadb20fced7a0790002e9dd12b26"}, + {file = "charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b7eb3eab5c646d3de7dcb14a7c9caebace5249c5767da39e1761cb1576e521a3"}, + {file = "charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2080aa129a28267984cdc902898993d788c995c384e285d0d19199f56760d52e"}, + {file = "charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:fd68c825548a611158230e2f9222e210ceb2e3391995c0aa5865cbdf3ab4bd49"}, + {file = "charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:d8a9316f4da85e937242642b537c6d55d7e9287dd38e5634732f8233932aff45"}, + {file = "charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d90254c8f609338c53ec180fcd4c4f9c16502e238e3fc88ca7fd4c2f38d445b8"}, + {file = "charset_normalizer-3.5.0-cp37-abi3-win32.whl", hash = "sha256:8b3e9e29b8b07cc461b9ce7768db7693a93979d0dadf22046f6f3555ded2f516"}, + {file = "charset_normalizer-3.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:0c8953d9d1617794cfc40d81179571c9ba3805dd029623a15c93f1fb70e60a74"}, + {file = "charset_normalizer-3.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:562d24ca7797c1af8852994950c2e623a907b201fc4b0ed29e92af173d3828ca"}, + {file = "charset_normalizer-3.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7ffc43fe52618fcd7abc6ee0b46aea527db10da73305fcc6aaf9710ac7a33ec7"}, + {file = "charset_normalizer-3.5.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:301bfc4877c4f4f62b344235ecc58d06c901683801636eef819f88769c315ba2"}, + {file = "charset_normalizer-3.5.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17a0fd0e23961c2c017372e37aabc7ca8fceb9e10ad898977dfb40ad3927baae"}, + {file = "charset_normalizer-3.5.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ac68ebfa549cc623e0e9add2937526340c629ccf667b4da85b7ef5f99e70bbd9"}, + {file = "charset_normalizer-3.5.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6da562a20a49673fe365b05750e98d03bb2c5f8b8d03562b014c1abb3df739f1"}, + {file = "charset_normalizer-3.5.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d22a083497d2f7d06a57172c5b60ee66cedcf304fde5226d4dfdc94f6180f5b1"}, + {file = "charset_normalizer-3.5.0-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c825661dfcf843119ab57cdcac0df7a48e168764c66917bc74f9a42ecb096da9"}, + {file = "charset_normalizer-3.5.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2df26d4134948616be0ece05d0b24d621d3990f37147b5883c52052b613ef1f5"}, + {file = "charset_normalizer-3.5.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:69d647cf158eb6bc9c99503292abed1f2079a2de5859f06a403f8aee6417475d"}, + {file = "charset_normalizer-3.5.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d08952c0f14eb56d9dad72a2e17773b5f709c55b28635822d18c4adf38680833"}, + {file = "charset_normalizer-3.5.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:85f9e0e2724bbddf05de65e5fb03b73eb23e985b7df4259c1d19feb302eb8dc2"}, + {file = "charset_normalizer-3.5.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:dc7f6aca0bdac5e6520c8b6769bda69315fe7cb57f69885f115bc8ca02d1d022"}, + {file = "charset_normalizer-3.5.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:74892fe9f33d204860e782e0a2030bb39f9f0af1e7a24f7d5a5b632df311f655"}, + {file = "charset_normalizer-3.5.0-cp39-cp39-win32.whl", hash = "sha256:17db18db9a1374d5b9d9a3252f980b4243b0b4efd1df03fac78bb587f6ce98cd"}, + {file = "charset_normalizer-3.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:9e0213f3f8a2674a6778be299aea1d6dc6dda015aab86f683bca6d78f81f27bb"}, + {file = "charset_normalizer-3.5.0-cp39-cp39-win_arm64.whl", hash = "sha256:d867cefea33acad8e33a3eb408cca7889a9cf999bd5433d962089d5a13b6e75f"}, + {file = "charset_normalizer-3.5.0-py3-none-any.whl", hash = "sha256:993dfcbe75a85a3784abb5084f2c41b915767c90546fcc92803cffa28611baea"}, + {file = "charset_normalizer-3.5.0.tar.gz", hash = "sha256:49bd5feb59b0bf3cbf6ebcf4352e371c95b9da9bacd4449f8b64d0ad2c10a26e"}, ] [[package]] @@ -375,6 +473,62 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = true +python-versions = ">=3.8" +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = true +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = true +python-versions = ">=3.8" +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "id" version = "1.6.1" @@ -1090,17 +1244,17 @@ files = [ [[package]] name = "typing-inspection" -version = "0.4.2" +version = "0.4.4" description = "Runtime typing introspection tools" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" files = [ - {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, - {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, + {file = "typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147"}, + {file = "typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47"}, ] [package.dependencies] -typing-extensions = ">=4.12.0" +typing-extensions = ">=4.15.0" [[package]] name = "urllib3" @@ -1138,7 +1292,10 @@ enabler = ["pytest-enabler (>=3.4)"] test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] type = ["pytest-mypy (>=1.0.1)"] +[extras] +async = ["httpx"] + [metadata] lock-version = "2.0" python-versions = ">=3.10" -content-hash = "a010df85afbd7110b3c9a8eef0bee07e69eaf5828d35fe29e1c7d71b161d4885" +content-hash = "c5e22c5fd1323c2657a2cb0f076bd72bc15ac450a6ccacfbcf5fdfaaa316362b" diff --git a/pyproject.toml b/pyproject.toml index c4f2feab..42cb075a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,10 @@ classifiers = [ python = ">=3.10" requests = ">=2" pydantic = "^2.0" +httpx = { version = ">=0.28.1,<1.0", optional = true } + +[tool.poetry.extras] +async = ["httpx"] [tool.poetry.group.dev.dependencies] pytest = "^8.0" From b33c5b56d7c57e80177d02590c5982a90cda65ed Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Thu, 13 Aug 2026 17:15:54 -0700 Subject: [PATCH 07/81] feat: add asynchronous MLB data adapter Add an httpx-based async transport while sharing HTTP error and compatibility handling with the synchronous adapter. --- mlbstatsapi/_http.py | 120 +++++++++++++++++ mlbstatsapi/async_mlb_dataadapter.py | 186 +++++++++++++++++++++++++++ mlbstatsapi/mlb_dataadapter.py | 141 ++------------------ tests/test_async_mlb_dataadapter.py | 0 4 files changed, 319 insertions(+), 128 deletions(-) create mode 100644 mlbstatsapi/_http.py create mode 100644 mlbstatsapi/async_mlb_dataadapter.py create mode 100644 tests/test_async_mlb_dataadapter.py diff --git a/mlbstatsapi/_http.py b/mlbstatsapi/_http.py new file mode 100644 index 00000000..de9a4427 --- /dev/null +++ b/mlbstatsapi/_http.py @@ -0,0 +1,120 @@ +import inspect +import warnings +from typing import Protocol + +from .exceptions import MlbHttpError +from .warnings import MlbHttpCompatibilityWarning + + +HTTP_ERROR_BODY_EXCERPT_LIMIT = 500 + + +class _ResponseLike(Protocol): + content: bytes + text: str + + def json(self) -> object: + ... + + +def _is_mlbstatsapi_module(module_name: str) -> bool: + """Return True when module_name belongs to this package.""" + return module_name == "mlbstatsapi" or module_name.startswith("mlbstatsapi.") + + +def _compatibility_warning_stacklevel() -> int: + """Return a warnings.warn stacklevel for the first non-package caller.""" + frame = inspect.currentframe() + stacklevel = 1 + + try: + frame = frame.f_back + + while frame is not None: + module_name = frame.f_globals.get("__name__", "") + + if not _is_mlbstatsapi_module(module_name): + return stacklevel + + stacklevel += 1 + frame = frame.f_back + finally: + del frame + + return 1 + + +def _warn_http_compatibility( + *, + status_code: int, + url: str, +) -> None: + warnings.warn( + ( + f"HTTP {status_code} for {url} was suppressed because " + "strict_http=False explicitly selected compatibility mode, so the " + "historical empty result was returned. Strict HTTP behavior is the " + "default in version 1.0. Remove strict_http=False or pass " + "strict_http=True to raise MlbHttpError." + ), + MlbHttpCompatibilityWarning, + stacklevel=_compatibility_warning_stacklevel(), + ) + + +def _extract_error_response_data( + response: _ResponseLike, +) -> dict | list | None: + """Best-effort JSON extraction from an error response.""" + try: + if not response.content: + return None + + data = response.json() + except Exception: + return None + + if isinstance(data, (dict, list)): + return data + + return None + + +def _extract_error_body_excerpt( + response: _ResponseLike, +) -> str | None: + """Best-effort bounded text excerpt from an error response.""" + try: + if not response.content: + return None + + text = response.text + except Exception: + return None + + if not text: + return None + + return text[:HTTP_ERROR_BODY_EXCERPT_LIMIT] + + +def _build_http_error( + response: _ResponseLike, + *, + status_code: int, + reason: str, + url: str | None, + method: str, +) -> MlbHttpError: + """Build MlbHttpError from transport-neutral response context.""" + response_data = _extract_error_response_data(response) + body_excerpt = _extract_error_body_excerpt(response) + + return MlbHttpError( + status_code=status_code, + reason=reason, + url=url, + method=method, + response_data=response_data, + body_excerpt=body_excerpt, + ) \ No newline at end of file diff --git a/mlbstatsapi/async_mlb_dataadapter.py b/mlbstatsapi/async_mlb_dataadapter.py new file mode 100644 index 00000000..2ada675e --- /dev/null +++ b/mlbstatsapi/async_mlb_dataadapter.py @@ -0,0 +1,186 @@ +import logging + +import httpx + +from typing import Dict + +from .exceptions import ( + MlbDecodeError, + MlbHttpError, + MlbTimeoutError, + MlbTransportError, +) +from .mlb_dataadapter import ( + DEFAULT_TIMEOUT, + MlbResult, + TimeoutType, +) + + + +class AsyncMlbDataAdapter: + """Async data adapter for MLB API.""" + + + def __init__( + self, + hostname: str = "statsapi.mlb.com", + ver: str = "v1", + logger: logging.Logger | None = None, + timeout: TimeoutType = DEFAULT_TIMEOUT, + client: httpx.AsyncClient | None = None, + *, + strict_http: bool = True, + ): + self.url = f"https://{hostname}/api/{ver}/" + self._logger = logger or logging.getLogger(__name__) + self._timeout = timeout + self._strict_http = strict_http + self._owns_client = client is None + + if client is None: + self._client = httpx.AsyncClient() + else: + self._client = client + + self._closed = False + + async def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> MlbResult: + """Get data from the MLB API.""" + """ + return a MlbResult from endpoint + + Parameters + ---------- + endpoint : str + rest api endpoint + ep_params : dict + params + data : dict + data to send with requests (we aren't using this) + + Returns + ------- + MlbResult + """ + + full_url = self.url + endpoint + logline_pre = f'url={full_url}' + logline_post = " ,".join( + ( + logline_pre, + 'success={}, status_code={}, message={}, url={}' + ) + ) + + try: + self._logger.debug(logline_post) + + response = await self._client.get( + url=full_url, + params=ep_params, + timeout=self._translate_timeout(self._timeout), + ) + + except httpx.TimeoutException as exc: + self._logger.error(msg=str(exc)) + raise MlbTimeoutError("Request failed") from exc + + except httpx.RequestError as exc: + self._logger.error(msg=str(exc)) + raise MlbTransportError("Request failed") from exc + + status_code = response.status_code + + if 400 <= status_code <= 499: + self._logger.error(msg=logline_post.format( + 'Invalid Request', + status_code, + response.reason_phrase, + str(response.url), + )) + # Strict mode raises for final non-404 4xx after retries are exhausted. + # 404 stays an empty MlbResult so endpoints keep None / [] / {} behavior. + if self._strict_http and status_code != 404: + raise _build_http_error( + response, + method="GET", + fallback_url=full_url, + ) + if status_code != 404: + _warn_http_compatibility( + status_code=status_code, + url=str(response.url) if response.url else full_url, + ) + return MlbResult( + status_code=status_code, + message=response.reason_phrase, + data={}, + ) + + if 500 <= status_code <= 599: + self._logger.error(msg=logline_post.format( + 'Internal error occurred', + status_code, + response.reason_phrase, + str(response.url), + )) + raise _build_http_error( + response, + status_code=response.status_code, + reason=response.reason_phrase, + url=str(response.url) if response.url else full_url, + method="GET", + ) + + if not 200 <= status_code <= 299: + raise _build_http_error( + response, + status_code=response.status_code, + reason=response.reason_phrase, + url=str(response.url) if response.url else full_url, + method="GET", + ) + + self._logger.debug(msg=logline_post.format( + 'success', + status_code, + response.reason_phrase, + str(response.url), + )) + + if not response.content: + response_data = {} + else: + try: + response_data = response.json() + except ValueError as exc: + self._logger.error(msg=(str(exc))) + raise MlbDecodeError( + "Bad JSON in response" + ) from exc + + return MlbResult( + status_code, + message=response.reason_phrase, + data=response_data, + ) + + @staticmethod + def _translate_timeout(timeout: TimeoutType) -> httpx.Timeout: + if isinstance(timeout, tuple): + connect_timeout, read_timeout = timeout + + return httpx.Timeout( + connect=connect_timeout, + read=read_timeout, + write=read_timeout, + pool=connect_timeout, + ) + + return httpx.Timeout(timeout) + + async def aclose(self) -> None: + if self._owns_client and not self._closed: + await self._client.aclose() + self._closed = True \ No newline at end of file diff --git a/mlbstatsapi/mlb_dataadapter.py b/mlbstatsapi/mlb_dataadapter.py index 8082896c..d4b89e9e 100644 --- a/mlbstatsapi/mlb_dataadapter.py +++ b/mlbstatsapi/mlb_dataadapter.py @@ -16,6 +16,10 @@ from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry +from ._http import ( + _build_http_error, + _warn_http_compatibility, +) # Connect timeout, then read timeout. Callers may override with a scalar or tuple. DEFAULT_TIMEOUT = (3.05, 30.0) @@ -26,131 +30,6 @@ PACKAGE_DISTRIBUTION_NAME = "python-mlb-statsapi" UNKNOWN_PACKAGE_VERSION = "unknown" -# Bounded excerpt for error response bodies attached to MlbHttpError. -HTTP_ERROR_BODY_EXCERPT_LIMIT = 500 - - -def _is_mlbstatsapi_module(module_name: str) -> bool: - """Return True when *module_name* belongs to this package.""" - return module_name == "mlbstatsapi" or module_name.startswith("mlbstatsapi.") - - -def _compatibility_warning_stacklevel() -> int: - """Return a warnings.warn stacklevel for the first non-package caller. - - A fixed stack level cannot serve both direct MlbDataAdapter.get() calls and - public Mlb endpoint methods that wrap the adapter. Walk frames from the - caller of this helper outward and stop at the first module outside the - mlbstatsapi package namespace. - """ - frame = inspect.currentframe() - stacklevel = 1 - try: - frame = frame.f_back - while frame is not None: - module_name = frame.f_globals.get("__name__", "") - if not _is_mlbstatsapi_module(module_name): - return stacklevel - stacklevel += 1 - frame = frame.f_back - finally: - del frame - return 1 - - -def _warn_http_compatibility( - *, - status_code: int, - url: str, -) -> None: - """Warn that compatibility mode suppressed an error strict mode would raise. - - Only the status code and URL are reported; response bodies, headers, and - credentials must never reach a warning message. - """ - warnings.warn( - ( - f"HTTP {status_code} for {url} was suppressed because " - "strict_http=False explicitly selected compatibility mode, so the " - "historical empty result was returned. Strict HTTP behavior is the " - "default in version 1.0. Remove strict_http=False or pass " - "strict_http=True to raise MlbHttpError." - ), - MlbHttpCompatibilityWarning, - stacklevel=_compatibility_warning_stacklevel(), - ) - - -def _extract_error_response_data( - response: requests.Response, -) -> dict | list | None: - """Best-effort JSON object/list extraction from an error response. - - Returns None for empty bodies, invalid JSON, scalars, or unexpected failures. - Must not raise; context extraction cannot replace the original HTTP error. - """ - try: - if not response.content: - return None - data = response.json() - except Exception: - return None - - if isinstance(data, (dict, list)): - return data - return None - - -def _extract_error_body_excerpt( - response: requests.Response, -) -> str | None: - """Best-effort bounded text excerpt from an error response body. - - Returns None for empty bodies or unexpected text-decoding failures. - Must not raise; context extraction cannot replace the original HTTP error. - """ - try: - if not response.content: - return None - text = response.text - except Exception: - return None - - if not text: - return None - return text[:HTTP_ERROR_BODY_EXCERPT_LIMIT] - - -def _build_http_error( - response: requests.Response, - *, - method: str, - fallback_url: str, -) -> MlbHttpError: - """Build an MlbHttpError with best-effort response context. - - Extraction failures must not prevent raising MlbHttpError with status, - reason, URL, and method. - """ - try: - response_data = _extract_error_response_data(response) - except Exception: - response_data = None - - try: - body_excerpt = _extract_error_body_excerpt(response) - except Exception: - body_excerpt = None - - return MlbHttpError( - status_code=response.status_code, - reason=response.reason, - url=response.url or fallback_url, - method=method, - response_data=response_data, - body_excerpt=body_excerpt, - ) - def create_retry_policy() -> Retry: """Create a new instance of the default MLB HTTP retry policy.""" @@ -340,8 +219,10 @@ def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> MlbRe if self._strict_http and status_code != 404: raise _build_http_error( response, + status_code=response.status_code, + reason=response.reason, + url=response.url or full_url, method="GET", - fallback_url=full_url, ) if status_code != 404: _warn_http_compatibility( @@ -363,15 +244,19 @@ def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> MlbRe )) raise _build_http_error( response, + status_code=response.status_code, + reason=response.reason, + url=response.url or full_url, method="GET", - fallback_url=full_url, ) if not 200 <= status_code <= 299: raise _build_http_error( response, + status_code=response.status_code, + reason=response.reason, + url=response.url or full_url, method="GET", - fallback_url=full_url, ) self._logger.debug(msg=logline_post.format( diff --git a/tests/test_async_mlb_dataadapter.py b/tests/test_async_mlb_dataadapter.py new file mode 100644 index 00000000..e69de29b From e8f78b8fd355006f95824b3d4ebe32702f24a266 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Thu, 13 Aug 2026 17:47:58 -0700 Subject: [PATCH 08/81] fix: use shared HTTP helpers in data adapters Remove obsolete adapter imports and update the async adapter to build HTTP errors through the shared transport-neutral helpers. --- mlbstatsapi/async_mlb_dataadapter.py | 9 +++++++-- mlbstatsapi/mlb_dataadapter.py | 4 ---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/mlbstatsapi/async_mlb_dataadapter.py b/mlbstatsapi/async_mlb_dataadapter.py index 2ada675e..890e4f4c 100644 --- a/mlbstatsapi/async_mlb_dataadapter.py +++ b/mlbstatsapi/async_mlb_dataadapter.py @@ -6,7 +6,6 @@ from .exceptions import ( MlbDecodeError, - MlbHttpError, MlbTimeoutError, MlbTransportError, ) @@ -16,6 +15,10 @@ TimeoutType, ) +from ._http import ( + _build_http_error, + _warn_http_compatibility, +) class AsyncMlbDataAdapter: @@ -104,8 +107,10 @@ async def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> if self._strict_http and status_code != 404: raise _build_http_error( response, + status_code=response.status_code, + reason=response.reason_phrase, + url=str(response.url) if response.url else full_url, method="GET", - fallback_url=full_url, ) if status_code != 404: _warn_http_compatibility( diff --git a/mlbstatsapi/mlb_dataadapter.py b/mlbstatsapi/mlb_dataadapter.py index d4b89e9e..ae10ba64 100644 --- a/mlbstatsapi/mlb_dataadapter.py +++ b/mlbstatsapi/mlb_dataadapter.py @@ -3,14 +3,10 @@ from .exceptions import ( MlbDecodeError, - MlbHttpError, MlbTimeoutError, MlbTransportError, ) -from .warnings import MlbHttpCompatibilityWarning -import inspect import logging -import warnings import requests from requests.adapters import HTTPAdapter From a93b70e1ceb659ba59f5d0e325ba38f68b8f27b8 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Thu, 13 Aug 2026 19:01:52 -0700 Subject: [PATCH 09/81] fix: preserve HTTP errors when context extraction fails Keep HTTP error construction resilient to unexpected response parsing failures and update exception tests for the shared HTTP helpers. --- mlbstatsapi/_http.py | 11 +++++++++-- tests/test_mlb_exceptions.py | 22 +++++++++++++--------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/mlbstatsapi/_http.py b/mlbstatsapi/_http.py index de9a4427..c8f3feba 100644 --- a/mlbstatsapi/_http.py +++ b/mlbstatsapi/_http.py @@ -107,8 +107,15 @@ def _build_http_error( method: str, ) -> MlbHttpError: """Build MlbHttpError from transport-neutral response context.""" - response_data = _extract_error_response_data(response) - body_excerpt = _extract_error_body_excerpt(response) + try: + response_data = _extract_error_response_data(response) + except Exception: + response_data = None + + try: + body_excerpt = _extract_error_body_excerpt(response) + except Exception: + body_excerpt = None return MlbHttpError( status_code=status_code, diff --git a/tests/test_mlb_exceptions.py b/tests/test_mlb_exceptions.py index effbff31..1ba92ac6 100644 --- a/tests/test_mlb_exceptions.py +++ b/tests/test_mlb_exceptions.py @@ -14,9 +14,9 @@ MlbTransportError, TheMlbStatsApiException, ) -from mlbstatsapi.mlb_dataadapter import ( - HTTP_ERROR_BODY_EXCERPT_LIMIT, +from mlbstatsapi._http import ( _build_http_error, + HTTP_ERROR_BODY_EXCERPT_LIMIT, ) @@ -404,11 +404,15 @@ def test_url_fallback_when_response_url_missing(): response.json.return_value = {"message": "boom"} response.text = '{"message": "boom"}' - exc = _build_http_error( - response, - method="GET", - fallback_url=f"{BASE_URL}sports", - ) + session = MagicMock() + session.get.return_value = response + + adapter = MlbDataAdapter(session=session) + + with pytest.raises(MlbHttpError) as exc_info: + adapter.get(endpoint="sports") + + exc = exc_info.value assert exc.url == f"{BASE_URL}sports" assert exc.method == "GET" @@ -426,11 +430,11 @@ def test_best_effort_extraction_failure_still_raises_mlb_http_error(requests_moc with ( patch( - "mlbstatsapi.mlb_dataadapter._extract_error_response_data", + "mlbstatsapi._http._extract_error_response_data", side_effect=RuntimeError("unexpected json failure"), ), patch( - "mlbstatsapi.mlb_dataadapter._extract_error_body_excerpt", + "mlbstatsapi._http._extract_error_body_excerpt", side_effect=RuntimeError("unexpected text failure"), ), pytest.raises(MlbHttpError) as exc_info, From 73054d168fdd10eed469e182ac03d547a548e053 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sun, 16 Aug 2026 16:34:10 -0700 Subject: [PATCH 10/81] feat: add bounded retry-with-backoff to AsyncMlbDataAdapter Adds a hand-rolled retry loop for AsyncMlbDataAdapter.get(), since httpx has no transport-level equivalent to urllib3's Retry mounted on the sync adapter's session. Reuses create_retry_policy() for total/backoff_factor/ status_forcelist/respect_retry_after_header so async stays consistent with the sync adapter's retry config, honors Retry-After, and only retries when the adapter owns its client. Closes #301. Co-Authored-By: Claude Sonnet 5 --- mlbstatsapi/async_mlb_dataadapter.py | 87 +++++-- tests/test_async_mlb_dataadapter.py | 340 +++++++++++++++++++++++++++ 2 files changed, 411 insertions(+), 16 deletions(-) diff --git a/mlbstatsapi/async_mlb_dataadapter.py b/mlbstatsapi/async_mlb_dataadapter.py index 890e4f4c..9ed88de7 100644 --- a/mlbstatsapi/async_mlb_dataadapter.py +++ b/mlbstatsapi/async_mlb_dataadapter.py @@ -1,3 +1,4 @@ +import asyncio import logging import httpx @@ -13,6 +14,7 @@ DEFAULT_TIMEOUT, MlbResult, TimeoutType, + create_retry_policy, ) from ._http import ( @@ -40,6 +42,7 @@ def __init__( self._timeout = timeout self._strict_http = strict_http self._owns_client = client is None + self._retry_policy = create_retry_policy() if client is None: self._client = httpx.AsyncClient() @@ -76,22 +79,8 @@ async def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> ) ) - try: - self._logger.debug(logline_post) - - response = await self._client.get( - url=full_url, - params=ep_params, - timeout=self._translate_timeout(self._timeout), - ) - - except httpx.TimeoutException as exc: - self._logger.error(msg=str(exc)) - raise MlbTimeoutError("Request failed") from exc - - except httpx.RequestError as exc: - self._logger.error(msg=str(exc)) - raise MlbTransportError("Request failed") from exc + self._logger.debug(logline_post) + response = await self._request_with_retries(full_url, ep_params) status_code = response.status_code @@ -171,6 +160,72 @@ async def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> data=response_data, ) + async def _request_with_retries( + self, + full_url: str, + ep_params: Dict, + ) -> httpx.Response: + """Issue the GET call, retrying with bounded backoff when this + adapter owns its httpx.AsyncClient. + + An injected client is called exactly once; its retry behavior stays + under caller control, matching the sync adapter's session-ownership + rule. + """ + policy = self._retry_policy + max_attempts = policy.total + 1 if self._owns_client else 1 + + attempt = 0 + while True: + attempt += 1 + try: + response = await self._client.get( + url=full_url, + params=ep_params, + timeout=self._translate_timeout(self._timeout), + ) + except httpx.TimeoutException as exc: + if attempt >= max_attempts: + self._logger.error(msg=str(exc)) + raise MlbTimeoutError("Request failed") from exc + await self._sleep_before_retry(attempt=attempt, response=None) + continue + except httpx.RequestError as exc: + if attempt >= max_attempts: + self._logger.error(msg=str(exc)) + raise MlbTransportError("Request failed") from exc + await self._sleep_before_retry(attempt=attempt, response=None) + continue + + if response.status_code not in policy.status_forcelist or attempt >= max_attempts: + return response + + await self._sleep_before_retry(attempt=attempt, response=response) + + async def _sleep_before_retry( + self, + *, + attempt: int, + response: httpx.Response | None, + ) -> None: + policy = self._retry_policy + + if policy.respect_retry_after_header and response is not None: + retry_after = policy.get_retry_after(response) + if retry_after: + await asyncio.sleep(retry_after) + return + + # Mirrors urllib3's Retry.get_backoff_time(): no delay before the + # first retry, exponential thereafter, capped at backoff_max. + delay = 0.0 if attempt <= 1 else min( + policy.backoff_factor * (2 ** (attempt - 1)), + policy.backoff_max, + ) + + if delay > 0: + await asyncio.sleep(delay) + @staticmethod def _translate_timeout(timeout: TimeoutType) -> httpx.Timeout: if isinstance(timeout, tuple): diff --git a/tests/test_async_mlb_dataadapter.py b/tests/test_async_mlb_dataadapter.py index e69de29b..b1b27919 100644 --- a/tests/test_async_mlb_dataadapter.py +++ b/tests/test_async_mlb_dataadapter.py @@ -0,0 +1,340 @@ +"""Offline tests for AsyncMlbDataAdapter's bounded retry-with-backoff behavior. + +Mirrors the retry contract asserted for the sync adapter in +tests/test_mlb_retries.py, adapted to httpx.MockTransport instead of a real +threaded HTTP server, since the async retry loop here is hand-rolled Python +rather than logic buried inside urllib3/requests internals. + +These tests must not contact the live MLB API. +""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +from mlbstatsapi import ( + MlbDecodeError, + MlbHttpCompatibilityWarning, + MlbHttpError, + MlbTimeoutError, + MlbTransportError, +) +from mlbstatsapi.async_mlb_dataadapter import AsyncMlbDataAdapter + +from http_contract_support import ( + RETRYABLE_STATUS_CODES, + SERVER_ERRORS, + assert_library_retry_policy, +) + + +BASE_URL = "https://statsapi.mlb.com/api/v1/" + +SLEEP_TARGET = "mlbstatsapi.async_mlb_dataadapter.asyncio.sleep" + + +def run_async(coro): + return asyncio.run(coro) + + +class _ScriptedHandler: + """Serve a scripted sequence of httpx Responses/exceptions. + + The last entry repeats for any call beyond the script's length, so a + single-item script models a persistent failure. + """ + + def __init__(self, *script: httpx.Response | Exception): + self._script = list(script) + self.call_count = 0 + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.call_count += 1 + index = min(self.call_count - 1, len(self._script) - 1) + item = self._script[index] + if isinstance(item, Exception): + raise item + return item + + +def _response(status_code: int, *, headers: dict | None = None, text: str | None = None) -> httpx.Response: + return httpx.Response(status_code, headers=headers or {}, text=text) + + +def _owned_adapter(handler, **kwargs) -> AsyncMlbDataAdapter: + """Build an adapter that owns its client, so retries are active.""" + adapter = AsyncMlbDataAdapter(**kwargs) + adapter._client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + return adapter + + +def _injected_adapter(handler, **kwargs) -> AsyncMlbDataAdapter: + """Build an adapter with a caller-supplied client, so retries are bypassed.""" + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + return AsyncMlbDataAdapter(client=client, **kwargs) + + +def test_retry_policy_matches_library_default(): + adapter = AsyncMlbDataAdapter() + assert_library_retry_policy(adapter._retry_policy) + + +def test_injected_client_persistent_server_error_is_not_retried(): + handler = _ScriptedHandler(_response(500)) + + async def scenario(): + adapter = _injected_adapter(handler) + with pytest.raises(MlbHttpError) as exc_info: + await adapter.get(endpoint="sports") + return exc_info.value.status_code + + status_code = run_async(scenario()) + assert status_code == 500 + assert handler.call_count == 1 + + +def test_injected_client_does_not_consume_a_second_scripted_response(): + handler = _ScriptedHandler(_response(500), _response(200)) + + async def scenario(): + adapter = _injected_adapter(handler) + with pytest.raises(MlbHttpError): + await adapter.get(endpoint="sports") + + run_async(scenario()) + assert handler.call_count == 1 + + +@pytest.mark.parametrize("status_code", RETRYABLE_STATUS_CODES) +def test_owned_client_retries_retryable_status_then_succeeds(status_code): + handler = _ScriptedHandler(_response(status_code), _response(200)) + + async def scenario(): + adapter = _owned_adapter(handler) + with patch(SLEEP_TARGET, new_callable=AsyncMock): + return await adapter.get(endpoint="sports") + + result = run_async(scenario()) + assert result.status_code == 200 + assert handler.call_count == 2 + + +@pytest.mark.parametrize("status_code", SERVER_ERRORS) +def test_owned_client_exhausts_retries_on_persistent_server_error(status_code): + handler = _ScriptedHandler(_response(status_code)) + + async def scenario(): + adapter = _owned_adapter(handler) + with patch(SLEEP_TARGET, new_callable=AsyncMock): + with pytest.raises(MlbHttpError) as exc_info: + await adapter.get(endpoint="sports") + return exc_info.value.status_code + + returned_status = run_async(scenario()) + assert returned_status == status_code + assert handler.call_count == 4 + + +def test_owned_client_final_429_raises_under_strict_http(): + handler = _ScriptedHandler(_response(429)) + + async def scenario(): + adapter = _owned_adapter(handler, strict_http=True) + with patch(SLEEP_TARGET, new_callable=AsyncMock): + with pytest.raises(MlbHttpError) as exc_info: + await adapter.get(endpoint="sports") + return exc_info.value.status_code + + status_code = run_async(scenario()) + assert status_code == 429 + assert handler.call_count == 4 + + +def test_owned_client_final_429_returns_empty_result_under_compatibility_mode(): + handler = _ScriptedHandler(_response(429)) + + async def scenario(): + adapter = _owned_adapter(handler, strict_http=False) + with patch(SLEEP_TARGET, new_callable=AsyncMock): + with pytest.warns(MlbHttpCompatibilityWarning) as warning_info: + result = await adapter.get(endpoint="sports") + return result, warning_info + + result, warning_info = run_async(scenario()) + assert result.status_code == 429 + assert result.data == {} + assert len(warning_info) == 1 + assert handler.call_count == 4 + + +def test_400_is_not_retried(): + handler = _ScriptedHandler(_response(400), _response(200)) + + async def scenario(): + adapter = _owned_adapter(handler) + with pytest.raises(MlbHttpError): + await adapter.get(endpoint="sports") + + run_async(scenario()) + assert handler.call_count == 1 + + +def test_404_is_not_retried(): + handler = _ScriptedHandler(_response(404), _response(200)) + + async def scenario(): + adapter = _owned_adapter(handler) + return await adapter.get(endpoint="sports") + + result = run_async(scenario()) + assert result.status_code == 404 + assert result.data == {} + assert handler.call_count == 1 + + +def test_timeout_retried_then_succeeds(): + handler = _ScriptedHandler(httpx.ReadTimeout("timed out"), _response(200)) + + async def scenario(): + adapter = _owned_adapter(handler) + with patch(SLEEP_TARGET, new_callable=AsyncMock): + return await adapter.get(endpoint="sports") + + result = run_async(scenario()) + assert result.status_code == 200 + assert handler.call_count == 2 + + +def test_timeout_exhausts_retries_and_raises_mlb_timeout_error(): + handler = _ScriptedHandler(httpx.ReadTimeout("timed out")) + + async def scenario(): + adapter = _owned_adapter(handler) + with patch(SLEEP_TARGET, new_callable=AsyncMock): + with pytest.raises(MlbTimeoutError): + await adapter.get(endpoint="sports") + + run_async(scenario()) + assert handler.call_count == 4 + + +def test_transport_error_retried_then_succeeds(): + handler = _ScriptedHandler(httpx.ConnectError("connection refused"), _response(200)) + + async def scenario(): + adapter = _owned_adapter(handler) + with patch(SLEEP_TARGET, new_callable=AsyncMock): + return await adapter.get(endpoint="sports") + + result = run_async(scenario()) + assert result.status_code == 200 + assert handler.call_count == 2 + + +def test_transport_error_exhausts_retries_and_raises_mlb_transport_error(): + handler = _ScriptedHandler(httpx.ConnectError("connection refused")) + + async def scenario(): + adapter = _owned_adapter(handler) + with patch(SLEEP_TARGET, new_callable=AsyncMock): + with pytest.raises(MlbTransportError): + await adapter.get(endpoint="sports") + + run_async(scenario()) + assert handler.call_count == 4 + + +def test_retry_after_header_drives_sleep_duration(): + handler = _ScriptedHandler( + _response(429, headers={"Retry-After": "7"}), + _response(200), + ) + + async def scenario(): + adapter = _owned_adapter(handler) + with patch(SLEEP_TARGET, new_callable=AsyncMock) as sleep_mock: + await adapter.get(endpoint="sports") + return sleep_mock + + sleep_mock = run_async(scenario()) + sleep_mock.assert_awaited_once_with(7) + + +def test_no_delay_before_first_retry(): + handler = _ScriptedHandler(_response(500), _response(200)) + + async def scenario(): + adapter = _owned_adapter(handler) + with patch(SLEEP_TARGET, new_callable=AsyncMock) as sleep_mock: + await adapter.get(endpoint="sports") + return sleep_mock + + sleep_mock = run_async(scenario()) + sleep_mock.assert_not_awaited() + + +def test_backoff_grows_exponentially_between_retries(): + handler = _ScriptedHandler(_response(500)) + + async def scenario(): + adapter = _owned_adapter(handler) + with patch(SLEEP_TARGET, new_callable=AsyncMock) as sleep_mock: + with pytest.raises(MlbHttpError): + await adapter.get(endpoint="sports") + return sleep_mock + + sleep_mock = run_async(scenario()) + assert [call.args[0] for call in sleep_mock.await_args_list] == [1.0, 2.0] + + +def test_cancelled_error_propagates_without_retry_during_network_call(): + call_count = 0 + + async def hanging_handler(request: httpx.Request) -> httpx.Response: + nonlocal call_count + call_count += 1 + await asyncio.sleep(10) + raise AssertionError("handler should have been cancelled before returning") + + async def scenario(): + adapter = _owned_adapter(hanging_handler) + task = asyncio.ensure_future(adapter.get(endpoint="sports")) + await asyncio.sleep(0) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + run_async(scenario()) + assert call_count == 1 + + +def test_cancelled_error_propagates_without_retry_during_backoff_sleep(): + handler = _ScriptedHandler(_response(500), _response(500), _response(200)) + + async def cancelling_sleep(delay): + raise asyncio.CancelledError() + + async def scenario(): + adapter = _owned_adapter(handler) + with patch(SLEEP_TARGET, side_effect=cancelling_sleep): + with pytest.raises(asyncio.CancelledError): + await adapter.get(endpoint="sports") + + run_async(scenario()) + assert handler.call_count == 2 + + +def test_json_decode_failure_is_not_retried(): + handler = _ScriptedHandler(_response(200, text="not json")) + + async def scenario(): + adapter = _owned_adapter(handler) + with pytest.raises(MlbDecodeError): + await adapter.get(endpoint="sports") + + run_async(scenario()) + assert handler.call_count == 1 From 3a340d802a80ee77672125682070b9b470ddbed0 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Mon, 17 Aug 2026 12:41:06 -0700 Subject: [PATCH 11/81] feat: add per-error-kind retry budgets and non-blocking backoff tests Splits AsyncMlbDataAdapter's retry loop into read/connect/timeout/status budgets sourced from create_retry_policy(), matching the sync adapter's independent connect/read/status counters instead of a single uniform total bound. ConnectTimeout now correctly falls through to MlbTimeoutError rather than being bundled with ConnectError's MlbTransportError path. Also adds two regression tests: a plain 200 makes exactly one call with no retry, and the backoff wait actually yields the event loop (verified by temporarily swapping it for a blocking call and confirming the test catches it). Note: the ConnectError and TimeoutException exhaustion branches don't log via self._logger.error before raising, unlike the ReadTimeout and RequestError branches - worth a follow-up for logging consistency. Co-Authored-By: Claude Sonnet 5 --- mlbstatsapi/async_mlb_dataadapter.py | 39 +++++++++++++++++-- tests/test_async_mlb_dataadapter.py | 56 +++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/mlbstatsapi/async_mlb_dataadapter.py b/mlbstatsapi/async_mlb_dataadapter.py index 9ed88de7..ecb17bf8 100644 --- a/mlbstatsapi/async_mlb_dataadapter.py +++ b/mlbstatsapi/async_mlb_dataadapter.py @@ -173,7 +173,6 @@ async def _request_with_retries( rule. """ policy = self._retry_policy - max_attempts = policy.total + 1 if self._owns_client else 1 attempt = 0 while True: @@ -184,19 +183,53 @@ async def _request_with_retries( params=ep_params, timeout=self._translate_timeout(self._timeout), ) - except httpx.TimeoutException as exc: + + except httpx.ReadTimeout as exc: + max_attempts = policy.read + 1 if self._owns_client else 1 + if attempt >= max_attempts: self._logger.error(msg=str(exc)) raise MlbTimeoutError("Request failed") from exc + await self._sleep_before_retry(attempt=attempt, response=None) continue + + except httpx.ConnectError as exc: + max_attempts = policy.connect + 1 if self._owns_client else 1 + + if attempt >= max_attempts: + raise MlbTransportError("Request failed") from exc + + await self._sleep_before_retry( + attempt=attempt, + response=None, + ) + continue + + except httpx.TimeoutException as exc: + max_attempts = policy.total + 1 if self._owns_client else 1 + + if attempt >= max_attempts: + raise MlbTimeoutError("Request failed") from exc + + await self._sleep_before_retry( + attempt=attempt, + response=None, + ) + continue + except httpx.RequestError as exc: + max_attempts = policy.total + 1 if self._owns_client else 1 + if attempt >= max_attempts: self._logger.error(msg=str(exc)) raise MlbTransportError("Request failed") from exc + await self._sleep_before_retry(attempt=attempt, response=None) continue + max_attempts = policy.status + 1 if self._owns_client else 1 + if response.status_code not in policy.status_forcelist or attempt >= max_attempts: return response @@ -243,4 +276,4 @@ def _translate_timeout(timeout: TimeoutType) -> httpx.Timeout: async def aclose(self) -> None: if self._owns_client and not self._closed: await self._client.aclose() - self._closed = True \ No newline at end of file + self._closed = True diff --git a/tests/test_async_mlb_dataadapter.py b/tests/test_async_mlb_dataadapter.py index b1b27919..9d0e5a3e 100644 --- a/tests/test_async_mlb_dataadapter.py +++ b/tests/test_async_mlb_dataadapter.py @@ -11,6 +11,7 @@ from __future__ import annotations import asyncio +import contextlib from unittest.mock import AsyncMock, patch import httpx @@ -83,6 +84,21 @@ def test_retry_policy_matches_library_default(): assert_library_retry_policy(adapter._retry_policy) +def test_200_succeeds_with_no_retry(): + handler = _ScriptedHandler(_response(200)) + + async def scenario(): + adapter = _owned_adapter(handler) + with patch(SLEEP_TARGET, new_callable=AsyncMock) as sleep_mock: + result = await adapter.get(endpoint="sports") + return result, sleep_mock + + result, sleep_mock = run_async(scenario()) + assert result.status_code == 200 + assert handler.call_count == 1 + sleep_mock.assert_not_awaited() + + def test_injected_client_persistent_server_error_is_not_retried(): handler = _ScriptedHandler(_response(500)) @@ -219,7 +235,7 @@ async def scenario(): await adapter.get(endpoint="sports") run_async(scenario()) - assert handler.call_count == 4 + assert handler.call_count == 3 def test_transport_error_retried_then_succeeds(): @@ -291,6 +307,44 @@ async def scenario(): assert [call.args[0] for call in sleep_mock.await_args_list] == [1.0, 2.0] +def test_retry_sleep_is_async_and_non_blocking(): + """A real (unmocked) backoff wait must yield the event loop. + + If _sleep_before_retry ever used a blocking call (e.g. time.sleep) + instead of `await asyncio.sleep(...)`, the whole event loop would + freeze for the wait's duration and the concurrently running marker + task below would make zero progress during it. + """ + handler = _ScriptedHandler(_response(500), _response(500), _response(200)) + + async def scenario(): + adapter = _owned_adapter(handler) + # Small but real backoff so the test stays fast without mocking sleep. + adapter._retry_policy.backoff_factor = 0.05 + + marker_ticks = 0 + + async def marker(): + nonlocal marker_ticks + for _ in range(50): + await asyncio.sleep(0.005) + marker_ticks += 1 + + marker_task = asyncio.ensure_future(marker()) + try: + result = await adapter.get(endpoint="sports") + finally: + marker_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await marker_task + + return result, marker_ticks + + result, marker_ticks = run_async(scenario()) + assert result.status_code == 200 + assert marker_ticks > 0 + + def test_cancelled_error_propagates_without_retry_during_network_call(): call_count = 0 From 09a87c1701ea7ec0a3655ff52c48740f1b25f5cd Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Mon, 17 Aug 2026 12:47:49 -0700 Subject: [PATCH 12/81] test: cover JSON data, aclose lifecycle, timeout translation, and concurrency Adds regression coverage for gaps found during review: actual JSON payload parsing on 2xx, an explicit empty 204 response, structured MlbHttpError context (reason/url/method/response_data/body_excerpt), library-owned client close plus aclose() idempotence, injected clients staying open, scalar and tuple timeout translation to httpx.Timeout, multiple concurrent requests on one adapter, and cancelling one in-flight request leaving a sibling request on the same adapter unaffected. Co-Authored-By: Claude Sonnet 5 --- tests/test_async_mlb_dataadapter.py | 146 ++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/tests/test_async_mlb_dataadapter.py b/tests/test_async_mlb_dataadapter.py index 9d0e5a3e..e32da8fd 100644 --- a/tests/test_async_mlb_dataadapter.py +++ b/tests/test_async_mlb_dataadapter.py @@ -99,6 +99,152 @@ async def scenario(): sleep_mock.assert_not_awaited() +def test_200_response_returns_actual_json_data(): + payload = {"sports": [{"id": 1, "name": "Major League Baseball"}]} + handler = _ScriptedHandler(httpx.Response(200, json=payload)) + + async def scenario(): + adapter = _owned_adapter(handler) + return await adapter.get(endpoint="sports") + + result = run_async(scenario()) + assert result.status_code == 200 + assert result.data == payload + + +def test_explicit_empty_successful_response_returns_empty_data(): + handler = _ScriptedHandler(_response(204, text="")) + + async def scenario(): + adapter = _owned_adapter(handler) + return await adapter.get(endpoint="sports") + + result = run_async(scenario()) + assert result.status_code == 204 + assert result.data == {} + + +def test_mlb_http_error_has_structured_context(): + payload = {"messageNumber": 1, "message": "Internal error occurred"} + handler = _ScriptedHandler(httpx.Response(500, json=payload)) + + async def scenario(): + adapter = _owned_adapter(handler) + with patch(SLEEP_TARGET, new_callable=AsyncMock): + with pytest.raises(MlbHttpError) as exc_info: + await adapter.get(endpoint="sports") + return exc_info.value + + error = run_async(scenario()) + assert error.status_code == 500 + assert error.reason == "Internal Server Error" + assert error.method == "GET" + assert error.url == f"{BASE_URL}sports" + assert error.response_data == payload + assert error.body_excerpt is not None + assert "Internal error occurred" in error.body_excerpt + + +def test_library_owned_client_closes(): + async def scenario(): + adapter = AsyncMlbDataAdapter() + was_open = not adapter._client.is_closed + await adapter.aclose() + return was_open, adapter._client.is_closed + + was_open, is_closed = run_async(scenario()) + assert was_open is True + assert is_closed is True + + +def test_aclose_is_idempotent(): + async def scenario(): + adapter = AsyncMlbDataAdapter() + await adapter.aclose() + with patch.object(adapter._client, "aclose", new_callable=AsyncMock) as aclose_mock: + await adapter.aclose() + return aclose_mock + + aclose_mock = run_async(scenario()) + aclose_mock.assert_not_awaited() + + +def test_injected_client_is_not_closed(): + async def scenario(): + client = httpx.AsyncClient() + adapter = AsyncMlbDataAdapter(client=client) + await adapter.aclose() + was_closed = client.is_closed + await client.aclose() + return was_closed + + was_closed = run_async(scenario()) + assert was_closed is False + + +def test_scalar_timeout_translation(): + result = AsyncMlbDataAdapter._translate_timeout(5) + assert result.connect == 5 + assert result.read == 5 + assert result.write == 5 + assert result.pool == 5 + + +def test_tuple_timeout_translation(): + result = AsyncMlbDataAdapter._translate_timeout((3.05, 30.0)) + assert result.connect == 3.05 + assert result.pool == 3.05 + assert result.read == 30.0 + assert result.write == 30.0 + + +def test_multiple_concurrent_requests_on_one_adapter(): + responses = { + "sports": httpx.Response(200, json={"id": "sports"}), + "teams": httpx.Response(200, json={"id": "teams"}), + } + + def handler(request: httpx.Request) -> httpx.Response: + endpoint = request.url.path.rsplit("/", 1)[-1] + return responses[endpoint] + + async def scenario(): + adapter = _owned_adapter(handler) + return await asyncio.gather( + adapter.get(endpoint="sports"), + adapter.get(endpoint="teams"), + ) + + sports_result, teams_result = run_async(scenario()) + assert sports_result.data == {"id": "sports"} + assert teams_result.data == {"id": "teams"} + + +def test_cancelling_one_request_does_not_cancel_another(): + async def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("hang"): + await asyncio.sleep(10) + raise AssertionError("handler should have been cancelled before returning") + return _response(200) + + async def scenario(): + adapter = _owned_adapter(handler) + + hanging_task = asyncio.ensure_future(adapter.get(endpoint="hang")) + await asyncio.sleep(0) + + other_task = asyncio.ensure_future(adapter.get(endpoint="sports")) + + hanging_task.cancel() + with pytest.raises(asyncio.CancelledError): + await hanging_task + + return await other_task + + result = run_async(scenario()) + assert result.status_code == 200 + + def test_injected_client_persistent_server_error_is_not_retried(): handler = _ScriptedHandler(_response(500)) From e138c24e4f55335b65c4d915a142aa249ab662f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 23:41:46 +0000 Subject: [PATCH 13/81] feat: send package User-Agent from library-owned async clients A library-created httpx.AsyncClient now identifies itself as python-mlb-statsapi/, reusing _build_user_agent() from the sync adapter so the version lookup and the "unknown" source-only fallback stay defined in one place. Passing the header to the AsyncClient constructor replaces only User-Agent, leaving httpx's other defaults intact. A caller-injected client is used exactly as given: its headers are never read, replaced, or reconfigured. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KFiLe3NhRL75YPrFCmQVZG --- mlbstatsapi/async_mlb_dataadapter.py | 9 +++- tests/test_async_mlb_dataadapter.py | 71 ++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/mlbstatsapi/async_mlb_dataadapter.py b/mlbstatsapi/async_mlb_dataadapter.py index ecb17bf8..8cf6f371 100644 --- a/mlbstatsapi/async_mlb_dataadapter.py +++ b/mlbstatsapi/async_mlb_dataadapter.py @@ -14,6 +14,7 @@ DEFAULT_TIMEOUT, MlbResult, TimeoutType, + _build_user_agent, create_retry_policy, ) @@ -45,8 +46,14 @@ def __init__( self._retry_policy = create_retry_policy() if client is None: - self._client = httpx.AsyncClient() + # Only a library-owned client gets the package User-Agent. Passing + # it to the constructor replaces just that header, so httpx's other + # default headers (Accept, Accept-Encoding, Connection) survive. + self._client = httpx.AsyncClient( + headers={"User-Agent": _build_user_agent()}, + ) else: + # An injected client stays exactly as the caller configured it. self._client = client self._closed = False diff --git a/tests/test_async_mlb_dataadapter.py b/tests/test_async_mlb_dataadapter.py index e32da8fd..79da6622 100644 --- a/tests/test_async_mlb_dataadapter.py +++ b/tests/test_async_mlb_dataadapter.py @@ -12,6 +12,7 @@ import asyncio import contextlib +from importlib.metadata import PackageNotFoundError from unittest.mock import AsyncMock, patch import httpx @@ -25,6 +26,7 @@ MlbTransportError, ) from mlbstatsapi.async_mlb_dataadapter import AsyncMlbDataAdapter +from mlbstatsapi.mlb_dataadapter import PACKAGE_DISTRIBUTION_NAME from http_contract_support import ( RETRYABLE_STATUS_CODES, @@ -37,6 +39,10 @@ SLEEP_TARGET = "mlbstatsapi.async_mlb_dataadapter.asyncio.sleep" +# Matches tests/test_mlb_session.py, so both adapters assert the same contract. +MOCKED_PACKAGE_VERSION = "9.8.7" +MOCKED_USER_AGENT = f"python-mlb-statsapi/{MOCKED_PACKAGE_VERSION}" + def run_async(coro): return asyncio.run(coro) @@ -538,3 +544,68 @@ async def scenario(): run_async(scenario()) assert handler.call_count == 1 + + +# --- Versioned User-Agent --- + + +def test_library_owned_client_has_versioned_user_agent(): + """A library-created AsyncClient sends the package and version User-Agent.""" + with patch( + "mlbstatsapi.mlb_dataadapter.package_version", + return_value=MOCKED_PACKAGE_VERSION, + ) as lookup: + adapter = AsyncMlbDataAdapter() + try: + assert adapter._client.headers["User-Agent"] == MOCKED_USER_AGENT + finally: + run_async(adapter.aclose()) + + lookup.assert_called_with(PACKAGE_DISTRIBUTION_NAME) + + +def test_library_owned_client_user_agent_uses_installed_version(): + """Without patching, the User-Agent still names this package.""" + adapter = AsyncMlbDataAdapter() + try: + assert adapter._client.headers["User-Agent"].startswith( + f"{PACKAGE_DISTRIBUTION_NAME}/", + ) + finally: + run_async(adapter.aclose()) + + +def test_library_owned_client_user_agent_falls_back_when_metadata_missing(): + """Missing distribution metadata yields the "unknown" fallback, not an error.""" + with patch( + "mlbstatsapi.mlb_dataadapter.package_version", + side_effect=PackageNotFoundError(PACKAGE_DISTRIBUTION_NAME), + ): + adapter = AsyncMlbDataAdapter() + try: + assert adapter._client.headers["User-Agent"] == "python-mlb-statsapi/unknown" + finally: + run_async(adapter.aclose()) + + +def test_injected_client_headers_are_unchanged(): + """Headers on a caller-supplied client survive adapter construction.""" + async def scenario(): + client = httpx.AsyncClient( + headers={ + "User-Agent": "my-baseball-project/1.0", + "X-Application": "scoreboard", + }, + ) + headers_before = dict(client.headers) + try: + adapter = AsyncMlbDataAdapter(client=client) + + assert adapter._client is client + assert dict(client.headers) == headers_before + assert client.headers["User-Agent"] == "my-baseball-project/1.0" + assert client.headers["X-Application"] == "scoreboard" + finally: + await client.aclose() + + run_async(scenario()) From 0d713f130387c15c168d480091478d06ea351282 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 00:55:50 +0000 Subject: [PATCH 14/81] fix: finish async adapter retry and test cleanup httpx.ConnectTimeout subclasses httpx.TimeoutException, so it fell through to the generic timeout handler and spent the total retry budget. It now has its own branch, ahead of TimeoutException, that spends the connect budget while still raising MlbTimeoutError, matching the sync retry contract: ReadTimeout -> read budget -> MlbTimeoutError ConnectTimeout -> connect budget -> MlbTimeoutError ConnectError -> connect budget -> MlbTransportError other TimeoutException -> total budget -> MlbTimeoutError other RequestError -> total budget -> MlbTransportError retryable HTTP status -> status budget A failing connect error is now logged like the other exhausted retry paths. The _owned_adapter test helper created the adapter's library-owned AsyncClient and then replaced it, leaving the original open. It now swaps only the transport while the adapter builds its own client through the production path, so exactly one client exists, ownership and header behavior are unchanged, and run_async() closes it inside the event loop that used it. New focused coverage: - connect timeout exhausts retries and raises MlbTimeoutError - connect timeout spends the connect budget, not the total budget - a final non-2xx outside 4xx/5xx raises MlbHttpError - an injected client's timeout configuration is not mutated - the package User-Agent leaves httpx's other default headers intact - a JSON decode failure keeps the underlying error as its cause Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FaoU7oRx5LGn9ZKMufGbzd --- mlbstatsapi/async_mlb_dataadapter.py | 24 ++++ tests/test_async_mlb_dataadapter.py | 167 +++++++++++++++++++++++++-- 2 files changed, 183 insertions(+), 8 deletions(-) diff --git a/mlbstatsapi/async_mlb_dataadapter.py b/mlbstatsapi/async_mlb_dataadapter.py index 8cf6f371..c4e14a2c 100644 --- a/mlbstatsapi/async_mlb_dataadapter.py +++ b/mlbstatsapi/async_mlb_dataadapter.py @@ -178,6 +178,16 @@ async def _request_with_retries( An injected client is called exactly once; its retry behavior stays under caller control, matching the sync adapter's session-ownership rule. + + Failures spend the retry budget the sync policy would spend, and + surface the public exception the sync adapter raises: + + ReadTimeout -> read budget -> MlbTimeoutError + ConnectTimeout -> connect budget -> MlbTimeoutError + ConnectError -> connect budget -> MlbTransportError + other TimeoutException -> total budget -> MlbTimeoutError + other RequestError -> total budget -> MlbTransportError + retryable HTTP status -> status budget """ policy = self._retry_policy @@ -201,10 +211,24 @@ async def _request_with_retries( await self._sleep_before_retry(attempt=attempt, response=None) continue + except httpx.ConnectTimeout as exc: + # Caught before httpx.TimeoutException: a connect timeout is a + # timeout for the caller, but it spends the connect budget so + # the retry accounting matches the sync policy. + max_attempts = policy.connect + 1 if self._owns_client else 1 + + if attempt >= max_attempts: + self._logger.error(msg=str(exc)) + raise MlbTimeoutError("Request failed") from exc + + await self._sleep_before_retry(attempt=attempt, response=None) + continue + except httpx.ConnectError as exc: max_attempts = policy.connect + 1 if self._owns_client else 1 if attempt >= max_attempts: + self._logger.error(msg=str(exc)) raise MlbTransportError("Request failed") from exc await self._sleep_before_retry( diff --git a/tests/test_async_mlb_dataadapter.py b/tests/test_async_mlb_dataadapter.py index 79da6622..d2893fe4 100644 --- a/tests/test_async_mlb_dataadapter.py +++ b/tests/test_async_mlb_dataadapter.py @@ -1,6 +1,11 @@ -"""Offline tests for AsyncMlbDataAdapter's bounded retry-with-backoff behavior. +"""Focused offline tests for the AsyncMlbDataAdapter implementation. -Mirrors the retry contract asserted for the sync adapter in +Covers the behavior delivered in issue #301: successful GETs, the HTTP status +contract, exception mapping, lifecycle and ownership, timeout translation, +User-Agent, bounded retry-with-backoff, cancellation, and concurrency. The +exhaustive async transport-contract matrix belongs to #302. + +The retry assertions mirror the contract asserted for the sync adapter in tests/test_mlb_retries.py, adapted to httpx.MockTransport instead of a real threaded HTTP server, since the async retry loop here is hand-rolled Python rather than logic buried inside urllib3/requests internals. @@ -39,13 +44,29 @@ SLEEP_TARGET = "mlbstatsapi.async_mlb_dataadapter.asyncio.sleep" +# Patched only while a test adapter is constructed, so the adapter creates its +# own library-owned client the way production does, over a MockTransport. +CLIENT_TARGET = "mlbstatsapi.async_mlb_dataadapter.httpx.AsyncClient" + # Matches tests/test_mlb_session.py, so both adapters assert the same contract. MOCKED_PACKAGE_VERSION = "9.8.7" MOCKED_USER_AGENT = f"python-mlb-statsapi/{MOCKED_PACKAGE_VERSION}" +# Adapters built by _owned_adapter(); run_async() closes them inside the same +# event loop that used them, so no AsyncClient is left open by a test. +_ADAPTERS_TO_CLOSE: list[AsyncMlbDataAdapter] = [] + + def run_async(coro): - return asyncio.run(coro) + async def runner(): + try: + return await coro + finally: + while _ADAPTERS_TO_CLOSE: + await _ADAPTERS_TO_CLOSE.pop().aclose() + + return asyncio.run(runner()) class _ScriptedHandler: @@ -73,9 +94,26 @@ def _response(status_code: int, *, headers: dict | None = None, text: str | None def _owned_adapter(handler, **kwargs) -> AsyncMlbDataAdapter: - """Build an adapter that owns its client, so retries are active.""" - adapter = AsyncMlbDataAdapter(**kwargs) - adapter._client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + """Build an adapter that owns its client, so retries are active. + + The adapter still builds its own client through the production path — only + the transport is swapped for a MockTransport — so ownership, headers, and + retry behavior are exactly what the library does at runtime, and no client + is constructed and then discarded. Call this from inside a run_async() + scenario; run_async() closes what it creates. + """ + real_async_client = httpx.AsyncClient + + def mock_transport_client(**client_kwargs) -> httpx.AsyncClient: + return real_async_client( + transport=httpx.MockTransport(handler), + **client_kwargs, + ) + + with patch(CLIENT_TARGET, mock_transport_client): + adapter = AsyncMlbDataAdapter(**kwargs) + + _ADAPTERS_TO_CLOSE.append(adapter) return adapter @@ -188,6 +226,29 @@ async def scenario(): assert was_closed is False +def test_injected_client_timeout_configuration_is_not_mutated(): + """The library's timeout is applied per request, not written to the client.""" + handler = _ScriptedHandler(_response(200)) + + async def scenario(): + client = httpx.AsyncClient( + transport=httpx.MockTransport(handler), + timeout=httpx.Timeout(11.0), + ) + try: + adapter = AsyncMlbDataAdapter(client=client, timeout=(1.0, 2.0)) + await adapter.get(endpoint="sports") + return client.timeout + finally: + await client.aclose() + + timeout = run_async(scenario()) + assert timeout.connect == 11.0 + assert timeout.read == 11.0 + assert timeout.write == 11.0 + assert timeout.pool == 11.0 + + def test_scalar_timeout_translation(): result = AsyncMlbDataAdapter._translate_timeout(5) assert result.connect == 5 @@ -364,6 +425,25 @@ async def scenario(): assert handler.call_count == 1 +def test_other_non_2xx_status_raises_http_error(): + """A final non-2xx outside the 4xx/5xx ranges still raises MlbHttpError.""" + handler = _ScriptedHandler( + _response(302, headers={"Location": "https://example.test/moved"}), + ) + + async def scenario(): + # Redirects are not followed, so the 302 reaches the status contract. + adapter = _owned_adapter(handler) + with pytest.raises(MlbHttpError) as exc_info: + await adapter.get(endpoint="sports") + return exc_info.value + + error = run_async(scenario()) + assert error.status_code == 302 + assert error.method == "GET" + assert handler.call_count == 1 + + def test_timeout_retried_then_succeeds(): handler = _ScriptedHandler(httpx.ReadTimeout("timed out"), _response(200)) @@ -390,6 +470,51 @@ async def scenario(): assert handler.call_count == 3 +def test_connect_timeout_exhausts_retries_and_raises_mlb_timeout_error(): + """A connect timeout stays a timeout for the caller. + + httpx.ConnectTimeout subclasses httpx.TimeoutException, so it needs its + own branch to spend the connect budget while still raising + MlbTimeoutError rather than MlbTransportError. + """ + handler = _ScriptedHandler(httpx.ConnectTimeout("connect timed out")) + + async def scenario(): + adapter = _owned_adapter(handler) + with patch(SLEEP_TARGET, new_callable=AsyncMock): + with pytest.raises(MlbTimeoutError) as exc_info: + await adapter.get(endpoint="sports") + return exc_info.value + + error = run_async(scenario()) + assert handler.call_count == 4 + # MlbTimeoutError subclasses MlbTransportError, so only the exact type + # distinguishes a timeout from a plain transport failure. + assert type(error) is MlbTimeoutError + assert isinstance(error.__cause__, httpx.ConnectTimeout) + + +def test_connect_timeout_spends_the_connect_retry_budget(): + """The connect budget bounds a connect timeout, not the total or read one. + + The default policy uses total=3 and connect=3, so attempt counts alone + cannot tell those two budgets apart. Narrowing connect makes the + difference observable: falling through to the generic timeout branch + would still allow four attempts here. + """ + handler = _ScriptedHandler(httpx.ConnectTimeout("connect timed out")) + + async def scenario(): + adapter = _owned_adapter(handler) + adapter._retry_policy.connect = 1 + with patch(SLEEP_TARGET, new_callable=AsyncMock): + with pytest.raises(MlbTimeoutError): + await adapter.get(endpoint="sports") + + run_async(scenario()) + assert handler.call_count == 2 + + def test_transport_error_retried_then_succeeds(): handler = _ScriptedHandler(httpx.ConnectError("connection refused"), _response(200)) @@ -539,10 +664,13 @@ def test_json_decode_failure_is_not_retried(): async def scenario(): adapter = _owned_adapter(handler) - with pytest.raises(MlbDecodeError): + with pytest.raises(MlbDecodeError) as exc_info: await adapter.get(endpoint="sports") + return exc_info.value - run_async(scenario()) + error = run_async(scenario()) + # Matches the sync adapter: the underlying decode failure stays the cause. + assert isinstance(error.__cause__, ValueError) assert handler.call_count == 1 @@ -588,6 +716,29 @@ def test_library_owned_client_user_agent_falls_back_when_metadata_missing(): run_async(adapter.aclose()) +def test_library_owned_client_preserves_httpx_default_headers(): + """Only User-Agent changes; HTTPX's other default headers are untouched. + + Mirrors test_mlb_session.test_library_created_session_preserves_requests_ + default_headers for the async client. + """ + baseline = httpx.AsyncClient() + adapter = AsyncMlbDataAdapter() + try: + for header, value in baseline.headers.items(): + if header.lower() == "user-agent": + continue + assert adapter._client.headers[header] == value + + for header in ("Accept", "Accept-Encoding", "Connection"): + assert adapter._client.headers[header] == baseline.headers[header] + + assert adapter._client.headers["User-Agent"] != baseline.headers["User-Agent"] + finally: + run_async(adapter.aclose()) + run_async(baseline.aclose()) + + def test_injected_client_headers_are_unchanged(): """Headers on a caller-supplied client survive adapter construction.""" async def scenario(): From edcfdc82c3ad4dd6f278b6a68a7ef04cd78e8a50 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 01:37:31 +0000 Subject: [PATCH 15/81] fix: expose async adapter behind optional dependency boundary AsyncMlbDataAdapter is public API per #298, but mlbstatsapi/__init__.py did not export it, and adding a plain import there would have made `import mlbstatsapi` require HTTPX for every sync-only install. Resolve the package-root async symbol lazily (PEP 562 module __getattr__ plus __dir__) and route the HTTPX import through a private boundary helper. A missing optional dependency now surfaces as an ImportError naming `pip install "python-mlb-statsapi[async]"`, chained from the original ModuleNotFoundError, and only when async functionality is requested. - add mlbstatsapi/_async_support.import_httpx() for the one actionable message - import HTTPX through it in async_mlb_dataadapter, so importing that module directly hits the same boundary - lazily export AsyncMlbDataAdapter from the package root and keep it in dir() - add tests/test_async_optional_dependency.py; every "HTTPX is missing" case runs in a child interpreter that blocks the import at sys.meta_path, so the results do not depend on sys.modules state from earlier tests - document the boundary in docs/public-api.md HTTPX remains optional and is not re-exported. Retry, timeout, User-Agent, and all synchronous behavior are unchanged. Refs #301 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RXEufcdjaRsRM89BvaJuq5 --- docs/public-api.md | 30 +++ mlbstatsapi/__init__.py | 24 ++ mlbstatsapi/_async_support.py | 34 +++ mlbstatsapi/async_mlb_dataadapter.py | 10 +- tests/test_async_optional_dependency.py | 313 ++++++++++++++++++++++++ 5 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 mlbstatsapi/_async_support.py create mode 100644 tests/test_async_optional_dependency.py diff --git a/docs/public-api.md b/docs/public-api.md index 12ee2176..36ea9c02 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -133,6 +133,36 @@ surface. A future focused issue may introduce `__all__` after deciding how to treat the accidental submodule names (for example, a documented deprecation period). +## Optional async support + +`AsyncMlbDataAdapter` is a public package-root symbol, like `MlbDataAdapter`, +but its HTTP dependency is optional and installed with the `async` extra: + +```bash +pip install "python-mlb-statsapi[async]" +``` + +With the extra installed: + +```python +from mlbstatsapi import AsyncMlbDataAdapter +``` + +Async symbols are resolved on first access, so the optional dependency is not +imported by `import mlbstatsapi`. A synchronous-only install is unaffected: + +* `import mlbstatsapi` succeeds without the `async` extra +* every supported package-root symbol listed above stays importable +* nothing in the synchronous surface changes + +Requesting async functionality without the extra raises `ImportError` naming +the install command above. That failure happens only when async functionality +is requested — importing the package, or any supported synchronous symbol, +never triggers it. + +The async HTTP library is an implementation detail. It is not re-exported from +the package root, and its types are not part of the public API. + ## Primary client `Mlb` is the primary synchronous client. diff --git a/mlbstatsapi/__init__.py b/mlbstatsapi/__init__.py index bb3c21cf..e5a6cf7e 100644 --- a/mlbstatsapi/__init__.py +++ b/mlbstatsapi/__init__.py @@ -25,3 +25,27 @@ return_splits, get_stat_attributes ) + +# Async symbols are resolved lazily. HTTPX is an optional dependency installed +# with the ``async`` extra, so importing the async adapter eagerly here would +# make ``import mlbstatsapi`` fail for every sync-only install. Resolving on +# first access keeps async functionality discoverable from the package root +# while the missing-dependency error surfaces only when async is actually +# requested. See docs/public-api.md. +_LAZY_ASYNC_EXPORTS = ("AsyncMlbDataAdapter",) + + +def __getattr__(name: str): + if name in _LAZY_ASYNC_EXPORTS: + from .async_mlb_dataadapter import AsyncMlbDataAdapter + + # Cache on the module so later attribute access is an ordinary lookup. + globals()["AsyncMlbDataAdapter"] = AsyncMlbDataAdapter + return AsyncMlbDataAdapter + + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__() -> list[str]: + # Keeps the lazy async names discoverable without importing HTTPX. + return sorted(set(globals()) | set(_LAZY_ASYNC_EXPORTS)) diff --git a/mlbstatsapi/_async_support.py b/mlbstatsapi/_async_support.py new file mode 100644 index 00000000..3cf52731 --- /dev/null +++ b/mlbstatsapi/_async_support.py @@ -0,0 +1,34 @@ +"""Private optional-dependency boundary for async support. + +HTTPX ships only with the ``async`` extra, so a sync-only install must be able +to ``import mlbstatsapi`` and use ``Mlb`` / ``MlbDataAdapter`` without it. Every +async entry point routes its HTTPX import through :func:`import_httpx`, so a +missing optional dependency produces one actionable install message instead of a +bare ``ModuleNotFoundError`` naming a library the user never asked for. + +HTTPX itself stays an implementation detail: nothing here re-exports it. +""" + +from types import ModuleType + +ASYNC_EXTRA_REQUIREMENT = 'python-mlb-statsapi[async]' + +MISSING_HTTPX_MESSAGE = ( + "Async support requires the optional HTTPX dependency, which is not " + "installed. Install it with:\n\n" + f' pip install "{ASYNC_EXTRA_REQUIREMENT}"\n' +) + + +def import_httpx() -> ModuleType: + """Return the ``httpx`` module, or raise an actionable ``ImportError``. + + The original failure is preserved as the exception cause so a broken async + install stays diagnosable. + """ + try: + import httpx + except ImportError as exc: + raise ImportError(MISSING_HTTPX_MESSAGE) from exc + + return httpx diff --git a/mlbstatsapi/async_mlb_dataadapter.py b/mlbstatsapi/async_mlb_dataadapter.py index c4e14a2c..65f29749 100644 --- a/mlbstatsapi/async_mlb_dataadapter.py +++ b/mlbstatsapi/async_mlb_dataadapter.py @@ -1,10 +1,9 @@ import asyncio import logging -import httpx - from typing import Dict +from ._async_support import import_httpx from .exceptions import ( MlbDecodeError, MlbTimeoutError, @@ -23,6 +22,13 @@ _warn_http_compatibility, ) +# HTTPX is optional; it ships with the ``async`` extra. Importing it through +# the shared boundary means a sync-only install that reaches for async +# functionality gets install guidance instead of a bare ModuleNotFoundError +# naming a library it never asked for. Binding the module here keeps every +# ``httpx.`` reference below unchanged. +httpx = import_httpx() + class AsyncMlbDataAdapter: """Async data adapter for MLB API.""" diff --git a/tests/test_async_optional_dependency.py b/tests/test_async_optional_dependency.py new file mode 100644 index 00000000..362fbd87 --- /dev/null +++ b/tests/test_async_optional_dependency.py @@ -0,0 +1,313 @@ +"""Offline tests for the async optional-dependency boundary (issue #301). + +HTTPX ships only with the ``python-mlb-statsapi[async]`` extra, so three things +have to hold at once: + +* ``from mlbstatsapi import AsyncMlbDataAdapter`` works when the extra is + installed +* ``import mlbstatsapi`` and the whole sync surface keep working when it is not +* reaching for async functionality without it produces actionable install + guidance instead of a bare ``ModuleNotFoundError`` + +Optional-import behavior is easy to test misleadingly, because ``httpx`` and +``mlbstatsapi`` are already in ``sys.modules`` by the time this file runs. Every +"HTTPX is missing" case therefore runs in a child interpreter that blocks the +import at ``sys.meta_path`` before ``mlbstatsapi`` is imported at all, which +also means the developer environment never has to uninstall anything. + +These tests must not contact the live MLB API. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +import mlbstatsapi +from mlbstatsapi.async_mlb_dataadapter import AsyncMlbDataAdapter + +from test_public_api import SUPPORTED_PACKAGE_ROOT_SYMBOLS + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + +# The guidance callers must be able to act on. Asserted as a substring so the +# surrounding sentence can be reworded without breaking these tests. +ASYNC_EXTRA_REQUIREMENT = "python-mlb-statsapi[async]" + +# Prepended to a child program to simulate a sync-only install. The finder +# rejects httpx before any path-based finder can satisfy it, so an installed +# HTTPX in this environment is invisible to the child. +BLOCK_HTTPX = """ +import sys + + +class _HttpxBlocker: + # Makes httpx look uninstalled, exactly as ModuleNotFoundError would. + def find_spec(self, fullname, path=None, target=None): + if fullname == "httpx" or fullname.startswith("httpx."): + raise ModuleNotFoundError( + f"No module named {fullname!r}", name=fullname + ) + return None + + +sys.meta_path.insert(0, _HttpxBlocker()) +assert "httpx" not in sys.modules, "child started with httpx already imported" +assert "mlbstatsapi" not in sys.modules, "child started with mlbstatsapi imported" +""" + + +def _run_child(body: str, *, block_httpx: bool) -> str: + """Run ``body`` in a fresh interpreter against this working tree.""" + program = textwrap.dedent(body) + if block_httpx: + program = BLOCK_HTTPX + program + + completed = subprocess.run( + [sys.executable, "-c", program], + cwd=PROJECT_ROOT, + # Import the working tree rather than any installed copy of the package. + env={**os.environ, "PYTHONPATH": str(PROJECT_ROOT)}, + capture_output=True, + text=True, + timeout=120, + ) + + assert completed.returncode == 0, ( + "child interpreter failed\n" + f"--- stdout ---\n{completed.stdout}\n" + f"--- stderr ---\n{completed.stderr}" + ) + return completed.stdout + + +# --------------------------------------------------------------------------- +# With HTTPX installed +# --------------------------------------------------------------------------- + + +def test_async_adapter_is_exported_from_the_package_root() -> None: + from mlbstatsapi import AsyncMlbDataAdapter as exported + + assert exported is AsyncMlbDataAdapter + assert mlbstatsapi.AsyncMlbDataAdapter is AsyncMlbDataAdapter + assert exported.__module__ == "mlbstatsapi.async_mlb_dataadapter" + + +def test_async_adapter_is_discoverable_from_the_package_root() -> None: + assert "AsyncMlbDataAdapter" in dir(mlbstatsapi) + + +def test_package_root_does_not_expose_httpx() -> None: + """HTTPX stays an implementation detail of the async adapter.""" + assert not hasattr(mlbstatsapi, "httpx") + + +def test_unknown_package_root_attribute_still_raises_attribute_error() -> None: + with pytest.raises(AttributeError): + mlbstatsapi.NotARealPublicSymbol # noqa: B018 + + +def test_importing_the_package_does_not_import_httpx() -> None: + """The boundary is lazy: a sync-only caller never pays for HTTPX.""" + _run_child( + """ + import sys + + import mlbstatsapi + from mlbstatsapi import Mlb, MlbDataAdapter + + imported = sorted(name for name in sys.modules if name.startswith("httpx")) + assert not imported, imported + assert "mlbstatsapi.async_mlb_dataadapter" not in sys.modules + """, + block_httpx=False, + ) + + +def test_async_access_imports_httpx_on_demand() -> None: + _run_child( + """ + import sys + + import mlbstatsapi + + assert "httpx" not in sys.modules + adapter_class = mlbstatsapi.AsyncMlbDataAdapter + assert "httpx" in sys.modules + assert adapter_class.__module__ == "mlbstatsapi.async_mlb_dataadapter" + + # Resolved once, then cached as an ordinary module attribute. + assert mlbstatsapi.AsyncMlbDataAdapter is adapter_class + """, + block_httpx=False, + ) + + +# --------------------------------------------------------------------------- +# Without HTTPX installed +# --------------------------------------------------------------------------- + + +def test_sync_only_install_can_import_the_package() -> None: + _run_child( + """ + import sys + + import mlbstatsapi + from mlbstatsapi import Mlb + from mlbstatsapi import MlbDataAdapter + + assert "httpx" not in sys.modules + """, + block_httpx=True, + ) + + +def test_sync_only_install_keeps_every_supported_package_root_symbol() -> None: + """The frozen 1.x package-root manifest must not depend on the async extra.""" + _run_child( + f""" + import mlbstatsapi + + for name in {list(SUPPORTED_PACKAGE_ROOT_SYMBOLS)!r}: + assert getattr(mlbstatsapi, name) is not None, name + """, + block_httpx=True, + ) + + +def test_sync_only_install_can_still_use_the_sync_adapter() -> None: + """The boundary changes no sync behavior, including Session ownership.""" + _run_child( + """ + import sys + + from mlbstatsapi import Mlb, MlbDataAdapter, MlbResult + + adapter = MlbDataAdapter() + try: + assert adapter.url == "https://statsapi.mlb.com/api/v1/" + assert adapter._owns_session is True + assert "python-mlb-statsapi/" in adapter._session.headers["User-Agent"] + finally: + adapter.close() + assert adapter._closed is True + + with Mlb() as mlb: + assert mlb._owns_session is True + + result = MlbResult(404, "Not Found") + assert result.data == {} + + assert "httpx" not in sys.modules + """, + block_httpx=True, + ) + + +def test_missing_httpx_reports_the_async_extra_from_the_package_root() -> None: + stdout = _run_child( + """ + import mlbstatsapi + + try: + from mlbstatsapi import AsyncMlbDataAdapter + except ImportError as exc: + message = str(exc) + cause = exc.__cause__ + else: + raise AssertionError("expected an ImportError without httpx") + + assert "python-mlb-statsapi[async]" in message, message + assert "pip install" in message, message + # The real failure stays diagnosable behind the friendly message. + assert isinstance(cause, ModuleNotFoundError), cause + assert cause.name == "httpx", cause.name + + print(message) + """, + block_httpx=True, + ) + + assert ASYNC_EXTRA_REQUIREMENT in stdout + + +def test_missing_httpx_reports_the_async_extra_from_attribute_access() -> None: + _run_child( + """ + import mlbstatsapi + + try: + mlbstatsapi.AsyncMlbDataAdapter + except ImportError as exc: + message = str(exc) + else: + raise AssertionError("expected an ImportError without httpx") + + assert "python-mlb-statsapi[async]" in message, message + """, + block_httpx=True, + ) + + +def test_missing_httpx_reports_the_async_extra_from_the_async_module() -> None: + """Importing the module directly hits the same boundary, not a raw httpx error.""" + _run_child( + """ + try: + import mlbstatsapi.async_mlb_dataadapter # noqa: F401 + except ImportError as exc: + message = str(exc) + else: + raise AssertionError("expected an ImportError without httpx") + + assert "python-mlb-statsapi[async]" in message, message + assert "pip install" in message, message + """, + block_httpx=True, + ) + + +def test_async_name_stays_discoverable_without_httpx() -> None: + """Discoverability must not require the optional dependency.""" + _run_child( + """ + import sys + + import mlbstatsapi + + assert "AsyncMlbDataAdapter" in dir(mlbstatsapi) + assert "httpx" not in sys.modules + """, + block_httpx=True, + ) + + +def test_failed_async_access_leaves_the_sync_api_usable() -> None: + _run_child( + """ + import mlbstatsapi + + for _ in range(2): + try: + mlbstatsapi.AsyncMlbDataAdapter + except ImportError as exc: + assert "python-mlb-statsapi[async]" in str(exc), str(exc) + else: + raise AssertionError("expected an ImportError without httpx") + + adapter = mlbstatsapi.MlbDataAdapter() + try: + assert adapter.url == "https://statsapi.mlb.com/api/v1/" + finally: + adapter.close() + """, + block_httpx=True, + ) From 4413a62809ce68a63040f0fe47cd91f8821833e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 02:26:37 +0000 Subject: [PATCH 16/81] test: align async adapter public API contract Split the frozen package-root manifest so "public API" and "available without optional dependencies" are separate statements: * SUPPORTED_PACKAGE_ROOT_SYMBOLS is the always-available surface that sync-only environments freeze against * OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS holds the public async surface that needs the async extra * SUPPORTED_PACKAGE_ROOT_API is their union, the whole supported 1.x package-root API Tests now prove all three parts of the contract: the always-available symbols still import without HTTPX, AsyncMlbDataAdapter is public and importable when HTTPX is present, and it stays discoverable and reported against the async manifest in a sync-only install. The docs classification table gains an availability column and an AsyncMlbDataAdapter row, checked against the manifests so the two cannot drift. Also tighten the optional-dependency boundary: only a missing top-level httpx is rewritten into the install message. An installed but broken HTTPX fails on some other module and now reports its own error instead of pointing at an extra that would not fix it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014DLLrrKnwptxpJq3bVmNK5 --- docs/public-api.md | 48 ++++++--- mlbstatsapi/_async_support.py | 13 ++- tests/test_async_optional_dependency.py | 115 ++++++++++++++++++-- tests/test_public_api.py | 137 +++++++++++++++++++++++- 4 files changed, 288 insertions(+), 25 deletions(-) diff --git a/docs/public-api.md b/docs/public-api.md index 36ea9c02..923fe084 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -78,22 +78,37 @@ from mlbstatsapi import ( ) ``` +The symbols above are available in every install. `AsyncMlbDataAdapter` is +equally public, but it resolves only when the optional `async` extra is +installed; see [Optional async support](#optional-async-support). + ### Classification of package-root symbols -| Symbol | Status | -| --- | --- | -| `Mlb` | Public and stable in 1.x | -| `MlbDataAdapter` | Public and stable in 1.x | -| `MlbResult` | Public and stable in 1.x | -| `create_retry_policy` | Public and stable in 1.x | -| `TheMlbStatsApiException` | Public and stable in 1.x | -| `MlbTransportError` | Public and stable in 1.x | -| `MlbTimeoutError` | Public and stable in 1.x | -| `MlbHttpError` | Public and stable in 1.x | -| `MlbDecodeError` | Public and stable in 1.x | -| `MlbHttpCompatibilityWarning` | Public and stable in 1.x | -| `return_splits` | Public legacy helper, stable in 1.x but not preferred for new code | -| `get_stat_attributes` | Public legacy helper, stable in 1.x but not preferred for new code | +Status and availability are separate questions. Every symbol below is public and +covered by the stability policy above; the availability column records whether +resolving it needs an optional dependency. + +| Symbol | Status | Availability | +| --- | --- | --- | +| `Mlb` | Public and stable in 1.x | Always available | +| `MlbDataAdapter` | Public and stable in 1.x | Always available | +| `AsyncMlbDataAdapter` | Public and stable in 1.x | Requires the optional `async` extra | +| `MlbResult` | Public and stable in 1.x | Always available | +| `create_retry_policy` | Public and stable in 1.x | Always available | +| `TheMlbStatsApiException` | Public and stable in 1.x | Always available | +| `MlbTransportError` | Public and stable in 1.x | Always available | +| `MlbTimeoutError` | Public and stable in 1.x | Always available | +| `MlbHttpError` | Public and stable in 1.x | Always available | +| `MlbDecodeError` | Public and stable in 1.x | Always available | +| `MlbHttpCompatibilityWarning` | Public and stable in 1.x | Always available | +| `return_splits` | Public legacy helper, stable in 1.x but not preferred for new code | Always available | +| `get_stat_attributes` | Public legacy helper, stable in 1.x but not preferred for new code | Always available | + +`AsyncMlbDataAdapter` is supported 1.x API on the same terms as the synchronous +symbols: it will not be removed or renamed during the series, and its documented +behavior stays compatible. Only its availability is conditional, because its +HTTP dependency ships with the `async` extra. See +[Optional async support](#optional-async-support). No package-root symbol is marked deprecated in version 1.0. Deprecation requires a documented replacement, a warning strategy, a removal timeline, and a @@ -136,7 +151,8 @@ accidental submodule names (for example, a documented deprecation period). ## Optional async support `AsyncMlbDataAdapter` is a public package-root symbol, like `MlbDataAdapter`, -but its HTTP dependency is optional and installed with the `async` extra: +and appears in the classification table above. Its HTTP dependency is optional +and installed with the `async` extra: ```bash pip install "python-mlb-statsapi[async]" @@ -152,7 +168,7 @@ Async symbols are resolved on first access, so the optional dependency is not imported by `import mlbstatsapi`. A synchronous-only install is unaffected: * `import mlbstatsapi` succeeds without the `async` extra -* every supported package-root symbol listed above stays importable +* every package-root symbol marked "Always available" above stays importable * nothing in the synchronous surface changes Requesting async functionality without the extra raises `ImportError` naming diff --git a/mlbstatsapi/_async_support.py b/mlbstatsapi/_async_support.py index 3cf52731..88b7fb4e 100644 --- a/mlbstatsapi/_async_support.py +++ b/mlbstatsapi/_async_support.py @@ -4,7 +4,8 @@ to ``import mlbstatsapi`` and use ``Mlb`` / ``MlbDataAdapter`` without it. Every async entry point routes its HTTPX import through :func:`import_httpx`, so a missing optional dependency produces one actionable install message instead of a -bare ``ModuleNotFoundError`` naming a library the user never asked for. +bare ``ModuleNotFoundError`` naming a library the user never asked for. Import +failures that are not a missing ``httpx`` are left alone. HTTPX itself stays an implementation detail: nothing here re-exports it. """ @@ -23,12 +24,20 @@ def import_httpx() -> ModuleType: """Return the ``httpx`` module, or raise an actionable ``ImportError``. + Only a genuinely missing top-level ``httpx`` is translated into the install + message. An installed-but-broken HTTPX fails on some other module (a + missing transitive dependency, for example), and telling that user to + install the extra would send them chasing the wrong problem, so those + failures propagate unchanged. + The original failure is preserved as the exception cause so a broken async install stays diagnosable. """ try: import httpx - except ImportError as exc: + except ModuleNotFoundError as exc: + if exc.name != "httpx": + raise raise ImportError(MISSING_HTTPX_MESSAGE) from exc return httpx diff --git a/tests/test_async_optional_dependency.py b/tests/test_async_optional_dependency.py index 362fbd87..6f50bfe1 100644 --- a/tests/test_async_optional_dependency.py +++ b/tests/test_async_optional_dependency.py @@ -9,6 +9,9 @@ * reaching for async functionality without it produces actionable install guidance instead of a bare ``ModuleNotFoundError`` +That guidance is reserved for a genuinely missing HTTPX: an installed but broken +HTTPX must keep reporting its own failure. + Optional-import behavior is easy to test misleadingly, because ``httpx`` and ``mlbstatsapi`` are already in ``sys.modules`` by the time this file runs. Every "HTTPX is missing" case therefore runs in a child interpreter that blocks the @@ -31,7 +34,10 @@ import mlbstatsapi from mlbstatsapi.async_mlb_dataadapter import AsyncMlbDataAdapter -from test_public_api import SUPPORTED_PACKAGE_ROOT_SYMBOLS +from test_public_api import ( + OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS, + SUPPORTED_PACKAGE_ROOT_SYMBOLS, +) PROJECT_ROOT = Path(__file__).resolve().parent.parent @@ -62,12 +68,47 @@ def find_spec(self, fullname, path=None, target=None): assert "mlbstatsapi" not in sys.modules, "child started with mlbstatsapi imported" """ +# Prepended to a child program to simulate an installed but broken HTTPX: the +# httpx import fails, yet httpx itself is present. The user's problem is a +# broken dependency tree, not a missing extra, so the boundary must not rewrite +# it into install guidance. +BREAK_HTTPX_DEPENDENCY = """ +import sys + + +class _BrokenHttpxDependency: + def find_spec(self, fullname, path=None, target=None): + if fullname == "httpx": + raise ModuleNotFoundError( + "No module named 'httpcore'", name="httpcore" + ) + return None + + +sys.meta_path.insert(0, _BrokenHttpxDependency()) +assert "httpx" not in sys.modules, "child started with httpx already imported" +""" + + +def _run_child( + body: str, + *, + block_httpx: bool = False, + break_httpx: bool = False, +) -> str: + """Run ``body`` in a fresh interpreter against this working tree. + + ``block_httpx`` makes HTTPX look uninstalled; ``break_httpx`` makes it look + installed but unimportable. They describe different environments, so a test + picks exactly one. + """ + assert not (block_httpx and break_httpx), "pick one HTTPX environment" -def _run_child(body: str, *, block_httpx: bool) -> str: - """Run ``body`` in a fresh interpreter against this working tree.""" program = textwrap.dedent(body) if block_httpx: program = BLOCK_HTTPX + program + elif break_httpx: + program = BREAK_HTTPX_DEPENDENCY + program completed = subprocess.run( [sys.executable, "-c", program], @@ -171,7 +212,12 @@ def test_sync_only_install_can_import_the_package() -> None: def test_sync_only_install_keeps_every_supported_package_root_symbol() -> None: - """The frozen 1.x package-root manifest must not depend on the async extra.""" + """Every always-available public symbol must resolve without the extra. + + ``SUPPORTED_PACKAGE_ROOT_SYMBOLS`` is the always-available half of the 1.x + package-root API. The async half is covered separately below; both halves + are public API. + """ _run_child( f""" import mlbstatsapi @@ -183,6 +229,24 @@ def test_sync_only_install_keeps_every_supported_package_root_symbol() -> None: ) +def test_sync_only_install_reports_the_extra_for_every_async_symbol() -> None: + """The optional async manifest is exactly what the extra unlocks.""" + _run_child( + f""" + import mlbstatsapi + + for name in {list(OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS)!r}: + try: + getattr(mlbstatsapi, name) + except ImportError as exc: + assert "python-mlb-statsapi[async]" in str(exc), str(exc) + else: + raise AssertionError(f"expected an ImportError for {{name}}") + """, + block_httpx=True, + ) + + def test_sync_only_install_can_still_use_the_sync_adapter() -> None: """The boundary changes no sync behavior, including Session ownership.""" _run_child( @@ -278,12 +342,13 @@ def test_missing_httpx_reports_the_async_extra_from_the_async_module() -> None: def test_async_name_stays_discoverable_without_httpx() -> None: """Discoverability must not require the optional dependency.""" _run_child( - """ + f""" import sys import mlbstatsapi - assert "AsyncMlbDataAdapter" in dir(mlbstatsapi) + for name in {list(OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS)!r}: + assert name in dir(mlbstatsapi), name assert "httpx" not in sys.modules """, block_httpx=True, @@ -311,3 +376,41 @@ def test_failed_async_access_leaves_the_sync_api_usable() -> None: """, block_httpx=True, ) + + +# --------------------------------------------------------------------------- +# With HTTPX installed but broken +# --------------------------------------------------------------------------- + + +def test_broken_httpx_install_is_not_reported_as_a_missing_extra() -> None: + """Installing the extra would not fix a broken HTTPX, so do not suggest it.""" + _run_child( + """ + try: + import mlbstatsapi.async_mlb_dataadapter # noqa: F401 + except ModuleNotFoundError as exc: + assert exc.name == "httpcore", exc.name + assert "python-mlb-statsapi[async]" not in str(exc), str(exc) + else: + raise AssertionError("expected the underlying import failure") + """, + break_httpx=True, + ) + + +def test_broken_httpx_install_surfaces_from_the_package_root_too() -> None: + _run_child( + """ + import mlbstatsapi + + try: + mlbstatsapi.AsyncMlbDataAdapter + except ModuleNotFoundError as exc: + assert exc.name == "httpcore", exc.name + assert "python-mlb-statsapi[async]" not in str(exc), str(exc) + else: + raise AssertionError("expected the underlying import failure") + """, + break_httpx=True, + ) diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 6d2dc85c..2575c3a9 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -4,13 +4,21 @@ exception and warning inheritance, Session ownership guarantees, and the explicit ``Mlb`` public-method manifest documented in ``docs/public-api.md``. +The package-root surface is split across two manifests because "public API" and +"available without optional dependencies" are different questions. Everything in +either manifest is public and stable in 1.x; only the async manifest needs the +optional ``async`` extra to resolve. + They must not contact the live MLB API. """ from __future__ import annotations +import importlib.util import inspect +import re import warnings +from pathlib import Path from typing import Any import pytest @@ -36,11 +44,18 @@ from http_contract_support import assert_library_retry_policy +PROJECT_ROOT = Path(__file__).resolve().parent.parent +PUBLIC_API_DOC = PROJECT_ROOT / "docs" / "public-api.md" + + # --------------------------------------------------------------------------- # Package-root manifests # --------------------------------------------------------------------------- -# Intentionally supported package-root symbols for the 1.x series. +# Supported package-root symbols for the 1.x series that are always available, +# including in a sync-only install without the ``async`` extra. Sync-only +# environments freeze their surface against this manifest, so a symbol that +# needs an optional dependency must not be added here. SUPPORTED_PACKAGE_ROOT_SYMBOLS: tuple[str, ...] = ( "Mlb", "MlbDataAdapter", @@ -56,6 +71,18 @@ "return_splits", ) +# Supported package-root symbols for the 1.x series that require the optional +# ``async`` extra (HTTPX). These are public and stable exactly like the symbols +# above; only their availability is conditional. See docs/public-api.md. +OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS: tuple[str, ...] = ( + "AsyncMlbDataAdapter", +) + +# The complete supported package-root API for the 1.x series. +SUPPORTED_PACKAGE_ROOT_API: tuple[str, ...] = ( + SUPPORTED_PACKAGE_ROOT_SYMBOLS + OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS +) + # Legacy helpers remain supported but are not preferred for new code. LEGACY_PACKAGE_ROOT_HELPERS: tuple[str, ...] = ( "get_stat_attributes", @@ -74,6 +101,26 @@ ) +# HTTPX ships only with the ``async`` extra, so this module must stay runnable +# in a sync-only environment. Cases that assert async availability are skipped +# there; tests/test_async_optional_dependency.py covers the sync-only half of +# the contract in child interpreters that block HTTPX outright. +def _async_extra_installed() -> bool: + """Report whether HTTPX is available, without importing it here.""" + try: + return importlib.util.find_spec("httpx") is not None + except ImportError: + # An environment may also make httpx unavailable by raising from a meta + # path finder instead of reporting no spec. + return False + + +requires_async_extra = pytest.mark.skipif( + not _async_extra_installed(), + reason="requires the optional async extra (HTTPX)", +) + + # Python 3.14 renders typing.Union[a, b] as "a | b" while Python 3.10-3.13 # render "Union[a, b]". The annotation object itself is unchanged, so the legacy # spelling is rewritten here and one manifest stays valid across the whole @@ -187,6 +234,32 @@ def test_supported_package_root_symbols_are_unique() -> None: assert len(SUPPORTED_PACKAGE_ROOT_SYMBOLS) == len(set(SUPPORTED_PACKAGE_ROOT_SYMBOLS)) +def test_optional_async_package_root_symbols_are_unique() -> None: + assert len(OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS) == len( + set(OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS) + ) + + +def test_package_root_manifests_are_disjoint() -> None: + """A symbol is either always available or gated behind the async extra.""" + assert not set(SUPPORTED_PACKAGE_ROOT_SYMBOLS) & set( + OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS + ) + + +def test_supported_package_root_api_is_the_union_of_both_manifests() -> None: + assert set(SUPPORTED_PACKAGE_ROOT_API) == set(SUPPORTED_PACKAGE_ROOT_SYMBOLS) | set( + OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS + ) + assert len(SUPPORTED_PACKAGE_ROOT_API) == len(set(SUPPORTED_PACKAGE_ROOT_API)) + + +def test_async_data_adapter_is_part_of_the_supported_api() -> None: + """The async adapter is supported 1.x API, not merely an optional add-on.""" + assert "AsyncMlbDataAdapter" in OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS + assert "AsyncMlbDataAdapter" in SUPPORTED_PACKAGE_ROOT_API + + def test_supported_package_root_symbols_are_importable_from_package() -> None: for name in SUPPORTED_PACKAGE_ROOT_SYMBOLS: assert hasattr(mlbstatsapi, name), name @@ -201,12 +274,40 @@ def test_supported_symbols_are_importable_by_name(name: str) -> None: assert namespace[name] is getattr(mlbstatsapi, name) +@pytest.mark.parametrize("name", OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS) +def test_optional_async_symbols_are_discoverable_without_the_extra(name: str) -> None: + """Discoverability is unconditional; only resolution needs HTTPX.""" + assert name in dir(mlbstatsapi) + + +@requires_async_extra +@pytest.mark.parametrize("name", OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS) +def test_optional_async_symbols_are_importable_with_the_extra(name: str) -> None: + namespace: dict[str, Any] = {} + exec(f"from mlbstatsapi import {name}", namespace) + assert name in namespace + assert namespace[name] is getattr(mlbstatsapi, name) + + +@requires_async_extra +def test_async_data_adapter_resolves_to_the_async_module() -> None: + adapter_class = mlbstatsapi.AsyncMlbDataAdapter + assert adapter_class.__module__ == "mlbstatsapi.async_mlb_dataadapter" + assert adapter_class.__name__ == "AsyncMlbDataAdapter" + + def test_package_does_not_define_all_in_version_1_0() -> None: """``__all__`` is omitted so star-import behavior is not silently narrowed.""" assert getattr(mlbstatsapi, "__all__", None) is None def test_star_import_includes_supported_symbols() -> None: + """Only the always-available manifest is asserted here. + + Async symbols resolve lazily, so whether a wildcard import sees them depends + on whether something already touched them in this interpreter. Their + documented access path is an explicit import, not ``import *``. + """ namespace: dict[str, Any] = {} exec("from mlbstatsapi import *", namespace) for name in SUPPORTED_PACKAGE_ROOT_SYMBOLS: @@ -230,6 +331,40 @@ def test_legacy_helpers_remain_package_root_importable() -> None: assert name in SUPPORTED_PACKAGE_ROOT_SYMBOLS +# --------------------------------------------------------------------------- +# Documented classification +# --------------------------------------------------------------------------- + + +def _documented_package_root_classifications() -> dict[str, str]: + """Return the symbol/status rows of the classification table in the docs.""" + text = PUBLIC_API_DOC.read_text(encoding="utf-8") + section = text.split("### Classification of package-root symbols", 1)[1] + section = re.split(r"\n#{2,} ", section, maxsplit=1)[0] + + rows: dict[str, str] = {} + for line in section.splitlines(): + match = re.match(r"^\|\s*`([A-Za-z_][A-Za-z0-9_]*)`\s*\|(.+?)\|\s*$", line) + if match: + rows[match.group(1)] = match.group(2).strip() + return rows + + +def test_documentation_classifies_every_supported_package_root_symbol() -> None: + documented = _documented_package_root_classifications() + for name in SUPPORTED_PACKAGE_ROOT_API: + assert name in documented, f"{name} is missing from the classification table" + + +def test_documentation_classifies_async_symbols_as_public_and_optional() -> None: + """Public API status and optional-dependency availability stay separate.""" + documented = _documented_package_root_classifications() + for name in OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS: + status = documented[name] + assert "Public and stable in 1.x" in status, status + assert "`async` extra" in status, status + + # --------------------------------------------------------------------------- # Constructor signatures # --------------------------------------------------------------------------- From b942a90d541a12337e4a233e4fb574f8b028017f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 02:55:49 +0000 Subject: [PATCH 17/81] test: allow suite collection without async extra HTTPX is optional, but both #301 test modules imported it at module scope, so `pytest tests/` errored during collection on a sync-only install instead of running the tests that do not need the extra. test_async_mlb_dataadapter.py exercises the HTTPX-backed adapter from end to end, so it now skips as a module via pytest.importorskip before importing AsyncMlbDataAdapter. Ordering is pytest, then the HTTPX check, then the async imports. test_async_optional_dependency.py deliberately does not skip: most of it asserts how an install without HTTPX behaves, which is exactly what a sync-only environment can prove. Its module-level async adapter import is gone; the two cases that need a real HTTPX skip individually and import the adapter inside the test. Sync-only environments now collect the whole offline suite and run every optional-dependency contract test, including the missing-HTTPX subprocess cases. No production behavior changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014DLLrrKnwptxpJq3bVmNK5 --- tests/test_async_mlb_dataadapter.py | 19 ++++++++++++++----- tests/test_async_optional_dependency.py | 15 +++++++++++++-- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/tests/test_async_mlb_dataadapter.py b/tests/test_async_mlb_dataadapter.py index d2893fe4..a5ab3b83 100644 --- a/tests/test_async_mlb_dataadapter.py +++ b/tests/test_async_mlb_dataadapter.py @@ -10,6 +10,9 @@ threaded HTTP server, since the async retry loop here is hand-rolled Python rather than logic buried inside urllib3/requests internals. +HTTPX ships only with the ``async`` extra, so the whole module skips when it is +absent. See the import section below. + These tests must not contact the live MLB API. """ @@ -20,20 +23,26 @@ from importlib.metadata import PackageNotFoundError from unittest.mock import AsyncMock, patch -import httpx import pytest -from mlbstatsapi import ( +# Every test below drives the real HTTPX-backed adapter, so a sync-only install +# has nothing here to run. Skipping at collection keeps ``pytest tests/`` +# working without the ``async`` extra instead of erroring on the import. The +# optional-dependency contract itself is asserted in +# tests/test_async_optional_dependency.py, which runs with or without HTTPX. +httpx = pytest.importorskip("httpx", reason="requires the async extra (HTTPX)") + +from mlbstatsapi import ( # noqa: E402 MlbDecodeError, MlbHttpCompatibilityWarning, MlbHttpError, MlbTimeoutError, MlbTransportError, ) -from mlbstatsapi.async_mlb_dataadapter import AsyncMlbDataAdapter -from mlbstatsapi.mlb_dataadapter import PACKAGE_DISTRIBUTION_NAME +from mlbstatsapi.async_mlb_dataadapter import AsyncMlbDataAdapter # noqa: E402 +from mlbstatsapi.mlb_dataadapter import PACKAGE_DISTRIBUTION_NAME # noqa: E402 -from http_contract_support import ( +from http_contract_support import ( # noqa: E402 RETRYABLE_STATUS_CODES, SERVER_ERRORS, assert_library_retry_policy, diff --git a/tests/test_async_optional_dependency.py b/tests/test_async_optional_dependency.py index 6f50bfe1..b6d8cca4 100644 --- a/tests/test_async_optional_dependency.py +++ b/tests/test_async_optional_dependency.py @@ -18,6 +18,11 @@ import at ``sys.meta_path`` before ``mlbstatsapi`` is imported at all, which also means the developer environment never has to uninstall anything. +Unlike tests/test_async_mlb_dataadapter.py, this module must never skip as a +whole: most of what it asserts is exactly the behavior of an install that has no +HTTPX, so it has to keep running in one. Nothing that needs HTTPX is imported at +module scope; the few cases that do require it skip individually. + These tests must not contact the live MLB API. """ @@ -32,7 +37,6 @@ import pytest import mlbstatsapi -from mlbstatsapi.async_mlb_dataadapter import AsyncMlbDataAdapter from test_public_api import ( OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS, @@ -129,11 +133,17 @@ def _run_child( # --------------------------------------------------------------------------- -# With HTTPX installed +# Package-root boundary +# +# These run in any environment. The two cases that need a real HTTPX to prove +# anything skip individually rather than taking the module with them. # --------------------------------------------------------------------------- def test_async_adapter_is_exported_from_the_package_root() -> None: + pytest.importorskip("httpx", reason="requires the async extra (HTTPX)") + from mlbstatsapi.async_mlb_dataadapter import AsyncMlbDataAdapter + from mlbstatsapi import AsyncMlbDataAdapter as exported assert exported is AsyncMlbDataAdapter @@ -173,6 +183,7 @@ def test_importing_the_package_does_not_import_httpx() -> None: def test_async_access_imports_httpx_on_demand() -> None: + pytest.importorskip("httpx", reason="requires the async extra (HTTPX)") _run_child( """ import sys From 1f71168eb0a4c173a65fdd9d3d826a38891f0aa8 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Mon, 17 Aug 2026 21:27:39 -0700 Subject: [PATCH 18/81] ci: exercise async coverage in v1.1 --- .github/workflows/build-and-test.yml | 48 +++++++++++++++++++++++++--- tests/test_release_validation.py | 31 ++++++++++-------- 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index b4efbd66..5f4e7d6c 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -4,11 +4,11 @@ on: pull_request: branches: - main - - release/1.0.0 + - "release/**" push: branches: - main - - release/1.0.0 + - "release/**" workflow_dispatch: permissions: @@ -27,7 +27,7 @@ jobs: # first failure, so a single-version incompatibility is easy to isolate. fail-fast: false # Python 3.15 is intentionally absent: it is still a prerelease during the - # 1.0 release work and is not claimed as a supported version. + # 1.1 release work and is not claimed as a supported version. matrix: python-version: - "3.10" @@ -48,13 +48,53 @@ jobs: virtualenvs-create: true virtualenvs-in-project: true - name: Install dependencies - run: poetry install --no-interaction + run: poetry install --no-interaction -E async - name: Run offline tests run: | poetry run pytest \ tests/ \ --ignore=tests/external_tests + sync-only: + name: Sync-only installation + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Install dependencies without async extra + run: poetry install --no-interaction --only main + + - name: Verify sync-only installation + run: | + poetry run python - <<'PY' + import importlib.util + + assert importlib.util.find_spec("httpx") is None, ( + "HTTPX should not be installed without the async extra" + ) + + import mlbstatsapi + from mlbstatsapi import Mlb, MlbDataAdapter + + assert mlbstatsapi.Mlb is Mlb + assert mlbstatsapi.MlbDataAdapter is MlbDataAdapter + + print("Sync-only installation verified without HTTPX") + PY + + build-package: name: Build and validate package needs: offline-tests diff --git a/tests/test_release_validation.py b/tests/test_release_validation.py index fd759ea9..a37cd41e 100644 --- a/tests/test_release_validation.py +++ b/tests/test_release_validation.py @@ -53,8 +53,8 @@ ) # Deterministic CI contract for the 1.0 release. -RELEASE_BRANCH = "release/1.0.0" -STALE_RELEASE_BRANCH = "release/0.9.0" +# Deterministic CI contract for maintained release branches. +RELEASE_BRANCH_PATTERN = 'release/**' SUPPORTED_PYTHON_VERSIONS = ("3.10", "3.11", "3.12", "3.13", "3.14") CI_VALIDATED_PYTHON_RANGE = "3.10 through 3.14" # Prerelease during this work, so it is deliberately excluded from the matrix. @@ -921,22 +921,27 @@ def _matrix_python_versions() -> list[str]: assert match is not None, "no python-version matrix found in the offline workflow" return re.findall(r'- "([^"]+)"', match.group(1)) - -def test_ci_watches_the_current_release_branch() -> None: - """Pull requests and pushes must watch main and release/1.0.0. - - The trigger is asserted literally instead of being derived from the package - version, which is still 0.9.0 until the release bump lands. - """ +def test_ci_watches_main_and_release_branches() -> None: + """Pull requests and pushes must watch main and release branches.""" text = OFFLINE_WORKFLOW.read_text(encoding="utf-8") - assert text.count(f"- {RELEASE_BRANCH}") == 2, text + assert text.count(f'- "{RELEASE_BRANCH_PATTERN}"') == 2, text assert text.count("- main") == 2, text - assert STALE_RELEASE_BRANCH not in text, ( - f"the stale {STALE_RELEASE_BRANCH} trigger must be removed" - ) assert "workflow_dispatch:" in text +def test_ci_matrix_installs_the_async_extra() -> None: + text = OFFLINE_WORKFLOW.read_text(encoding="utf-8") + + assert "poetry install --no-interaction -E async" in text + +def test_ci_preserves_a_sync_only_installation_check() -> None: + text = OFFLINE_WORKFLOW.read_text(encoding="utf-8") + + assert "sync-only:" in text + assert "poetry install --no-interaction --only main" in text + assert 'find_spec("httpx") is None' in text + assert "from mlbstatsapi import Mlb, MlbDataAdapter" in text + def test_ci_matrix_covers_every_supported_python_version() -> None: assert _matrix_python_versions() == list(SUPPORTED_PYTHON_VERSIONS) From 786c9e6566fa08d04fc0cff1ed0087ac7c36e5b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 20:54:34 +0000 Subject: [PATCH 19/81] ci: add Claude workflows to release/1.1.0 Bring the existing Claude Code and Claude Code Review GitHub Actions workflows over from main so they run for pull requests targeting release/1.1.0. Both files are byte-identical to their versions on main; no other files are touched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XhnfQFr66k3NRTN3GG7DEF --- .github/workflows/claude-code-review.yml | 45 +++++++++++++++++++++ .github/workflows/claude.yml | 50 ++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 .github/workflows/claude-code-review.yml create mode 100644 .github/workflows/claude.yml diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 00000000..37e66f3f --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,45 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' + plugins: 'code-review@claude-code-plugins' + prompt: '/code-review:code-review --comment ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + claude_args: '--allowedTools "mcp__github_inline_comment__create_inline_comment"' + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 00000000..6b15fac7 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,50 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr *)' + From 987f7eb718bd9aa5d41ff44ba17bf2dd1dfd7974 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 16:52:17 +0000 Subject: [PATCH 20/81] test: add async transport contract tests for #302 batch 1 Covers the remaining HTTP/result and error/warning contract gaps from #298 for AsyncMlbDataAdapter, without duplicating the suite added in #301/#314: - final non-404 4xx (403) raises MlbHttpError under strict_http=True, with structured status/reason/URL/method context - final non-404 4xx under strict_http=False emits one MlbHttpCompatibilityWarning and returns the historical empty MlbResult - compatibility warnings do not leak response bodies or headers - compatibility warnings are attributed to the awaiting caller's call site - a failure while extracting optional error-response context degrades that field instead of replacing the original MlbHttpError Test-only change. Existing helpers (run_async, _ScriptedHandler, _response, _owned_adapter, SLEEP_TARGET) and httpx.MockTransport are reused; no live MLB API requests. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EHYA36k526xUzTUxx4iPSZ --- tests/test_async_mlb_dataadapter.py | 141 ++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/tests/test_async_mlb_dataadapter.py b/tests/test_async_mlb_dataadapter.py index a5ab3b83..dd2b311e 100644 --- a/tests/test_async_mlb_dataadapter.py +++ b/tests/test_async_mlb_dataadapter.py @@ -20,6 +20,8 @@ import asyncio import contextlib +import inspect +import warnings from importlib.metadata import PackageNotFoundError from unittest.mock import AsyncMock, patch @@ -43,6 +45,7 @@ from mlbstatsapi.mlb_dataadapter import PACKAGE_DISTRIBUTION_NAME # noqa: E402 from http_contract_support import ( # noqa: E402 + HTTP_REASON_BY_STATUS, RETRYABLE_STATUS_CODES, SERVER_ERRORS, assert_library_retry_policy, @@ -61,6 +64,10 @@ MOCKED_PACKAGE_VERSION = "9.8.7" MOCKED_USER_AGENT = f"python-mlb-statsapi/{MOCKED_PACKAGE_VERSION}" +# Obvious sentinels, so a leak into a compatibility warning is unmistakable. +SECRET_BODY_MARKER = "SUPER_SECRET_RESPONSE" +SECRET_HEADER_MARKER = "SUPER_SECRET_HEADER" + # Adapters built by _owned_adapter(); run_async() closes them inside the same # event loop that used them, so no AsyncClient is left open by a test. @@ -683,6 +690,140 @@ async def scenario(): assert handler.call_count == 1 +# --- Final non-404 4xx contract --- + + +def test_final_non_404_client_error_raises_under_strict_http(): + """Strict mode raises MlbHttpError with the sync structured context. + + test_400_is_not_retried already proves a 4xx is final on the first + response; this asserts the #298 decision-table outcome for an explicit + strict_http=True adapter, including the structured error context. + """ + handler = _ScriptedHandler(_response(403)) + + async def scenario(): + adapter = _owned_adapter(handler, strict_http=True) + with pytest.raises(MlbHttpError) as exc_info: + await adapter.get(endpoint="sports") + return exc_info.value + + error = run_async(scenario()) + assert error.status_code == 403 + assert error.reason == HTTP_REASON_BY_STATUS[403] + assert error.method == "GET" + assert error.url == f"{BASE_URL}sports" + assert handler.call_count == 1 + + +def test_final_non_404_client_error_returns_empty_result_in_compatibility_mode(): + """strict_http=False suppresses a non-404 4xx into a warned empty result.""" + handler = _ScriptedHandler(_response(403, text='{"message": "denied"}')) + + async def scenario(): + adapter = _owned_adapter(handler, strict_http=False) + with pytest.warns(MlbHttpCompatibilityWarning) as warning_info: + result = await adapter.get(endpoint="sports") + return result, [str(warning.message) for warning in warning_info] + + result, messages = run_async(scenario()) + assert result.status_code == 403 + assert result.message == HTTP_REASON_BY_STATUS[403] + assert result.data == {} + assert len(messages) == 1 + assert "403" in messages[0] + assert f"{BASE_URL}sports" in messages[0] + assert handler.call_count == 1 + + +# --- Compatibility warning safety --- + + +def test_compatibility_warning_does_not_leak_response_body_or_headers(): + """Response bodies and headers must never reach the warning message.""" + handler = _ScriptedHandler( + _response( + 403, + headers={"X-Debug-Token": SECRET_HEADER_MARKER}, + text=f'{{"message": "{SECRET_BODY_MARKER}"}}', + ), + ) + + async def scenario(): + adapter = _owned_adapter(handler, strict_http=False) + with pytest.warns(MlbHttpCompatibilityWarning) as warning_info: + await adapter.get(endpoint="sports") + return [str(warning.message) for warning in warning_info] + + messages = run_async(scenario()) + assert len(messages) == 1 + assert SECRET_BODY_MARKER not in messages[0] + assert SECRET_HEADER_MARKER not in messages[0] + assert "X-Debug-Token" not in messages[0] + + +def test_compatibility_warning_points_to_awaiting_caller_line(): + """The warning is attributed to the awaiting caller, not package internals. + + Mirrors test_http_warnings.test_compatibility_warning_points_to_direct_ + adapter_caller_line for an awaited call. + """ + handler = _ScriptedHandler(_response(403)) + + async def scenario(): + adapter = _owned_adapter(handler, strict_http=False) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", MlbHttpCompatibilityWarning) + expected_lineno = inspect.currentframe().f_lineno + 1 + await adapter.get(endpoint="sports") + return caught, expected_lineno + + caught, expected_lineno = run_async(scenario()) + compatibility = [ + warning + for warning in caught + if issubclass(warning.category, MlbHttpCompatibilityWarning) + ] + assert len(compatibility) == 1 + assert compatibility[0].filename == __file__ + assert compatibility[0].lineno == expected_lineno + + +# --- Structured MlbHttpError context --- + + +def test_error_context_extraction_failure_does_not_replace_http_error(): + """A broken optional-context extraction must not hide the HTTP failure. + + The best-effort response context is a debugging aid, so a failure while + collecting it degrades that one field instead of raising something other + than the original MlbHttpError. + """ + handler = _ScriptedHandler( + _response(500, text='{"message": "Internal error occurred"}'), + ) + + async def scenario(): + adapter = _owned_adapter(handler) + with patch( + "mlbstatsapi._http._extract_error_response_data", + side_effect=RuntimeError("error-context extraction failed"), + ): + with patch(SLEEP_TARGET, new_callable=AsyncMock): + with pytest.raises(MlbHttpError) as exc_info: + await adapter.get(endpoint="sports") + return exc_info.value + + error = run_async(scenario()) + assert error.status_code == 500 + assert error.reason == HTTP_REASON_BY_STATUS[500] + assert error.method == "GET" + assert error.url == f"{BASE_URL}sports" + assert error.response_data is None + # The independent excerpt extraction still succeeds. + assert "Internal error occurred" in (error.body_excerpt or "") + + # --- Versioned User-Agent --- From 9874adccb259bc252bef1362b917d038457f3d8d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 17:41:25 +0000 Subject: [PATCH 21/81] test: add async retry budget contract tests for #302 batch 2 The default retry policy uses total=3, connect=3 and status=3, so the existing exhaustion tests that observe four attempts cannot tell those budgets apart, and the generic timeout/request-error branches had no coverage at all. Narrow one budget per test so the observed attempt count is uniquely attributable to it: - generic failures (pool timeout, read error) spend the total budget and still surface MlbTimeoutError / MlbTransportError with the original cause - a connection failure spends the connect budget, not the total one - a retryable status spends the status budget, not the total one Test-only change; no production behavior was modified. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EHYA36k526xUzTUxx4iPSZ --- tests/test_async_mlb_dataadapter.py | 78 +++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/tests/test_async_mlb_dataadapter.py b/tests/test_async_mlb_dataadapter.py index dd2b311e..a40ecd18 100644 --- a/tests/test_async_mlb_dataadapter.py +++ b/tests/test_async_mlb_dataadapter.py @@ -557,6 +557,84 @@ async def scenario(): assert handler.call_count == 4 +# --- Retry budget independence --- +# +# The default policy uses total=3, connect=3 and status=3, so an attempt count +# of four cannot tell those budgets apart. Each test below narrows the single +# budget it is about, which makes the observed attempt count uniquely +# attributable to that budget while the public failure stays unchanged. + + +@pytest.mark.parametrize( + "failure, expected_exception", + ( + (httpx.PoolTimeout("pool timed out"), MlbTimeoutError), + (httpx.ReadError("connection broken"), MlbTransportError), + ), + ids=("timeout", "transport"), +) +def test_generic_failures_spend_the_total_retry_budget(failure, expected_exception): + """Failures outside the connect/read branches are bounded by the total budget. + + A pool timeout and a read error are neither connect nor read failures, so + they fall through to the generic timeout/request handling. Narrowing total + to one retry makes that budget observable: spending connect (3) or read (2) + instead would allow four or three attempts here. + """ + handler = _ScriptedHandler(failure) + + async def scenario(): + adapter = _owned_adapter(handler) + adapter._retry_policy.total = 1 + with patch(SLEEP_TARGET, new_callable=AsyncMock): + with pytest.raises(expected_exception) as exc_info: + await adapter.get(endpoint="sports") + return exc_info.value + + error = run_async(scenario()) + assert handler.call_count == 2 + # MlbTimeoutError subclasses MlbTransportError, so only the exact type + # separates a timeout from a plain transport failure. + assert type(error) is expected_exception + assert isinstance(error.__cause__, type(failure)) + + +def test_connect_error_spends_the_connect_retry_budget(): + """A connection failure is bounded by the connect budget, not the total one. + + test_transport_error_exhausts_retries_and_raises_mlb_transport_error shows + four attempts under the default policy, which total=3 would also produce. + """ + handler = _ScriptedHandler(httpx.ConnectError("connection refused")) + + async def scenario(): + adapter = _owned_adapter(handler) + adapter._retry_policy.connect = 1 + with patch(SLEEP_TARGET, new_callable=AsyncMock): + with pytest.raises(MlbTransportError): + await adapter.get(endpoint="sports") + + run_async(scenario()) + assert handler.call_count == 2 + + +def test_retryable_status_spends_the_status_retry_budget(): + """Retryable statuses are bounded by the status budget, not the total one.""" + handler = _ScriptedHandler(_response(503)) + + async def scenario(): + adapter = _owned_adapter(handler) + adapter._retry_policy.status = 1 + with patch(SLEEP_TARGET, new_callable=AsyncMock): + with pytest.raises(MlbHttpError) as exc_info: + await adapter.get(endpoint="sports") + return exc_info.value.status_code + + status_code = run_async(scenario()) + assert status_code == 503 + assert handler.call_count == 2 + + def test_retry_after_header_drives_sleep_duration(): handler = _ScriptedHandler( _response(429, headers={"Retry-After": "7"}), From 413ed7494a90de9b968046d29896c15ce9d5108c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 18:42:33 +0000 Subject: [PATCH 22/81] test: drop the ReadError case from the total retry budget test httpx.ReadError currently falls through to the generic RequestError branch and so spends the total budget, but #298 does not define that mapping, and asserting it would freeze an implementation detail as public contract. The read budget already has deterministic coverage through ReadTimeout, and the pool timeout case is enough to prove the generic timeout path spends the total budget. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EHYA36k526xUzTUxx4iPSZ --- tests/test_async_mlb_dataadapter.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/tests/test_async_mlb_dataadapter.py b/tests/test_async_mlb_dataadapter.py index a40ecd18..9154ba9a 100644 --- a/tests/test_async_mlb_dataadapter.py +++ b/tests/test_async_mlb_dataadapter.py @@ -567,19 +567,16 @@ async def scenario(): @pytest.mark.parametrize( "failure, expected_exception", - ( - (httpx.PoolTimeout("pool timed out"), MlbTimeoutError), - (httpx.ReadError("connection broken"), MlbTransportError), - ), - ids=("timeout", "transport"), + ((httpx.PoolTimeout("pool timed out"), MlbTimeoutError),), + ids=("timeout",), ) def test_generic_failures_spend_the_total_retry_budget(failure, expected_exception): - """Failures outside the connect/read branches are bounded by the total budget. + """A generic timeout is bounded by the total budget. - A pool timeout and a read error are neither connect nor read failures, so - they fall through to the generic timeout/request handling. Narrowing total - to one retry makes that budget observable: spending connect (3) or read (2) - instead would allow four or three attempts here. + A pool timeout is neither a connect nor a read timeout, so it falls + through to the generic timeout handling. Narrowing total to one retry + makes that budget observable: spending connect (3) or read (2) instead + would allow four or three attempts here. """ handler = _ScriptedHandler(failure) From 727d855af89ea768f5340ead9fe0bcc461698b60 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 19:28:46 +0000 Subject: [PATCH 23/81] test: add async concurrency isolation contract tests for #302 batch 3 The suite already proved two concurrent requests can share one adapter and that cancelling one does not cancel another. These lock down the remaining #298 concurrency promises: - concurrent requests keep their own ep_params and their own response, now asserted against the query the transport actually observed - a request that exhausts its retry budget and raises MlbHttpError leaves an unrelated concurrent request untouched, on its single attempt - a second request completes while the first is parked inside its retry backoff, proven with asyncio.Event synchronization rather than wall-clock timing, and bounded so a serializing regression fails fast instead of hanging CI Test-only change; no production behavior was modified. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EHYA36k526xUzTUxx4iPSZ --- tests/test_async_mlb_dataadapter.py | 108 +++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 2 deletions(-) diff --git a/tests/test_async_mlb_dataadapter.py b/tests/test_async_mlb_dataadapter.py index 9154ba9a..846628e9 100644 --- a/tests/test_async_mlb_dataadapter.py +++ b/tests/test_async_mlb_dataadapter.py @@ -68,6 +68,10 @@ SECRET_BODY_MARKER = "SUPER_SECRET_RESPONSE" SECRET_HEADER_MARKER = "SUPER_SECRET_HEADER" +# Failure guard for the concurrency tests: a request that should never wait is +# bounded so a serializing regression fails fast instead of hanging CI. +BLOCKED_REQUEST_TIMEOUT = 10 + # Adapters built by _owned_adapter(); run_async() closes them inside the same # event loop that used them, so no AsyncClient is left open by a test. @@ -282,25 +286,125 @@ def test_tuple_timeout_translation(): def test_multiple_concurrent_requests_on_one_adapter(): + """Concurrent requests keep their own params and their own response. + + Each request carries different ep_params, so neither the query sent to the + transport nor the returned data may pick up the other request's values. + """ responses = { "sports": httpx.Response(200, json={"id": "sports"}), "teams": httpx.Response(200, json={"id": "teams"}), } + observed_params: dict[str, dict[str, str]] = {} def handler(request: httpx.Request) -> httpx.Response: endpoint = request.url.path.rsplit("/", 1)[-1] + observed_params[endpoint] = dict(request.url.params) return responses[endpoint] async def scenario(): adapter = _owned_adapter(handler) return await asyncio.gather( - adapter.get(endpoint="sports"), - adapter.get(endpoint="teams"), + adapter.get(endpoint="sports", ep_params={"sportId": 1}), + adapter.get(endpoint="teams", ep_params={"season": 2026}), ) sports_result, teams_result = run_async(scenario()) assert sports_result.data == {"id": "sports"} assert teams_result.data == {"id": "teams"} + # Query values arrive as strings; each endpoint sees only its own params. + assert observed_params == { + "sports": {"sportId": "1"}, + "teams": {"season": "2026"}, + } + + +def test_failure_of_one_concurrent_request_does_not_affect_another(): + """A failing request must not disturb an unrelated concurrent request. + + Request A exhausts the status retry budget and raises MlbHttpError while + request B, sharing the same adapter and client, still completes normally + on its single attempt. + """ + attempts: dict[str, int] = {"sports": 0, "teams": 0} + + def handler(request: httpx.Request) -> httpx.Response: + endpoint = request.url.path.rsplit("/", 1)[-1] + attempts[endpoint] += 1 + if endpoint == "sports": + return _response(503) + return httpx.Response(200, json={"id": "teams"}) + + async def scenario(): + adapter = _owned_adapter(handler) + with patch(SLEEP_TARGET, new_callable=AsyncMock): + # Caller-controlled orchestration: gather is the caller's choice, + # so a failure in A is reported without cancelling B. + return await asyncio.gather( + adapter.get(endpoint="sports"), + adapter.get(endpoint="teams"), + return_exceptions=True, + ) + + failure, success = run_async(scenario()) + assert isinstance(failure, MlbHttpError) + assert failure.status_code == 503 + assert success.status_code == 200 + assert success.data == {"id": "teams"} + # A spent its full status budget; B was never retried on A's behalf. + assert attempts == {"sports": 4, "teams": 1} + + +def test_backoff_in_one_request_does_not_block_another(): + """Another request makes progress while one is waiting out its backoff. + + test_retry_sleep_is_async_and_non_blocking proves an unrelated task keeps + running during backoff; this proves the same for a second request on the + same adapter, without depending on wall-clock timing: B's result exists + before b_completed is set, so A cannot have left its backoff first. + """ + attempts: dict[str, int] = {"sports": 0, "teams": 0} + + def handler(request: httpx.Request) -> httpx.Response: + endpoint = request.url.path.rsplit("/", 1)[-1] + attempts[endpoint] += 1 + # The first retry has no delay, so A must fail twice to reach a real + # backoff wait; the third attempt succeeds once the test releases it. + if endpoint == "sports" and attempts["sports"] <= 2: + return _response(503) + return httpx.Response(200, json={"id": endpoint}) + + async def scenario(): + a_in_backoff = asyncio.Event() + b_completed = asyncio.Event() + + async def parked_backoff(delay): + a_in_backoff.set() + await b_completed.wait() + + adapter = _owned_adapter(handler) + with patch(SLEEP_TARGET, parked_backoff): + a_task = asyncio.ensure_future(adapter.get(endpoint="sports")) + await asyncio.wait_for(a_in_backoff.wait(), BLOCKED_REQUEST_TIMEOUT) + + # Not a timing assertion: on the passing path nothing waits. The + # bound only turns a regression that serializes requests into a + # fast failure instead of a hung test run. + b_result = await asyncio.wait_for( + adapter.get(endpoint="teams"), + BLOCKED_REQUEST_TIMEOUT, + ) + + b_completed.set() + a_result = await a_task + + return a_result, b_result + + a_result, b_result = run_async(scenario()) + assert b_result.status_code == 200 + assert b_result.data == {"id": "teams"} + assert a_result.status_code == 200 + assert attempts == {"sports": 3, "teams": 1} def test_cancelling_one_request_does_not_cancel_another(): From 777a9eae0860c4185b33fa780e635e649a731a24 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 19:38:02 +0000 Subject: [PATCH 24/81] test: add async adapter cleanup-after-use tests for #302 batch 4 test_library_owned_client_closes only covers an adapter that never issued a request, so nothing asserted that a used adapter is still closable. Cover the three states a request can leave behind: - after a successful request, aclose() closes the library-owned client - after a request that raised MlbHttpError, cleanup succeeds and the error's public fields are unchanged - after an in-flight request is cancelled, CancelledError stays the caller's outcome and cleanup still closes the client The cancellation test waits on an event set inside the transport handler, so the request is genuinely in flight before it is cancelled. Test-only change; no production behavior was modified. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EHYA36k526xUzTUxx4iPSZ --- tests/test_async_mlb_dataadapter.py | 77 +++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/tests/test_async_mlb_dataadapter.py b/tests/test_async_mlb_dataadapter.py index 846628e9..8dfd8aed 100644 --- a/tests/test_async_mlb_dataadapter.py +++ b/tests/test_async_mlb_dataadapter.py @@ -269,6 +269,83 @@ async def scenario(): assert timeout.pool == 11.0 +# --- Explicit cleanup after the adapter has been used --- +# +# test_library_owned_client_closes covers an adapter that never issued a +# request. These cover the states a request can leave behind: success, a +# public failure, and caller cancellation. In each case explicit cleanup must +# still close the library-owned client without altering what the caller +# already observed. + + +def test_owned_client_closes_after_a_successful_request(): + """A used adapter is still safely closable.""" + handler = _ScriptedHandler(httpx.Response(200, json={"id": "sports"})) + + async def scenario(): + adapter = _owned_adapter(handler) + result = await adapter.get(endpoint="sports") + await adapter.aclose() + return result, adapter._client.is_closed + + result, is_closed = run_async(scenario()) + assert result.status_code == 200 + assert result.data == {"id": "sports"} + assert is_closed is True + + +def test_owned_client_closes_after_a_failed_request(): + """A failed request leaves the adapter closable, and the error intact.""" + handler = _ScriptedHandler(_response(503)) + + async def scenario(): + adapter = _owned_adapter(handler) + with patch(SLEEP_TARGET, new_callable=AsyncMock): + with pytest.raises(MlbHttpError) as exc_info: + await adapter.get(endpoint="sports") + + error = exc_info.value + await adapter.aclose() + return error, adapter._client.is_closed + + error, is_closed = run_async(scenario()) + assert error.status_code == 503 + assert error.reason == HTTP_REASON_BY_STATUS[503] + assert error.method == "GET" + assert error.url == f"{BASE_URL}sports" + assert is_closed is True + + +def test_owned_client_closes_after_a_cancelled_request(): + """Cancelling an in-flight request still leaves the adapter closable. + + The cancellation itself stays the caller's outcome: aclose() runs after + CancelledError has already propagated, and does not replace it. + """ + async def scenario(): + request_started = asyncio.Event() + + async def hanging_handler(request: httpx.Request) -> httpx.Response: + request_started.set() + await asyncio.sleep(10) + raise AssertionError("handler should have been cancelled before returning") + + adapter = _owned_adapter(hanging_handler) + task = asyncio.ensure_future(adapter.get(endpoint="sports")) + # Cancel only once the request is genuinely in flight. + await asyncio.wait_for(request_started.wait(), BLOCKED_REQUEST_TIMEOUT) + + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + await adapter.aclose() + return adapter._client.is_closed + + is_closed = run_async(scenario()) + assert is_closed is True + + def test_scalar_timeout_translation(): result = AsyncMlbDataAdapter._translate_timeout(5) assert result.connect == 5 From 28528a11352e9c2a450aedb189aefd5176bff079 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 20:05:01 +0000 Subject: [PATCH 25/81] test: prove a final 5xx raises regardless of strict_http The async suite proved the 5xx raise only under the default strict adapter; every strict_http=False test targeted a 4xx. Nothing stopped a regression that widened compatibility-mode suppression from the 4xx branch into the 5xx branch, which would have returned a warned empty MlbResult with the suite still green. A persistent 503 against a strict_http=False adapter still raises MlbHttpError after the full status retry budget, and emits no MlbHttpCompatibilityWarning. Test-only change; no production behavior was modified. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EHYA36k526xUzTUxx4iPSZ --- tests/test_async_mlb_dataadapter.py | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_async_mlb_dataadapter.py b/tests/test_async_mlb_dataadapter.py index 8dfd8aed..28605b87 100644 --- a/tests/test_async_mlb_dataadapter.py +++ b/tests/test_async_mlb_dataadapter.py @@ -565,6 +565,37 @@ async def scenario(): assert handler.call_count == 4 +def test_persistent_server_error_raises_despite_compatibility_mode(): + """A final 5xx raises MlbHttpError regardless of strict_http. + + Compatibility mode suppresses non-404 4xx only. A server error is never + downgraded to a warned empty MlbResult, so strict_http=False must not + change either the exception or the retry behavior here. + """ + handler = _ScriptedHandler(_response(503)) + + async def scenario(): + adapter = _owned_adapter(handler, strict_http=False) + with patch(SLEEP_TARGET, new_callable=AsyncMock): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", MlbHttpCompatibilityWarning) + with pytest.raises(MlbHttpError) as exc_info: + await adapter.get(endpoint="sports") + + return exc_info.value.status_code, caught + + status_code, caught = run_async(scenario()) + assert status_code == 503 + # One initial attempt plus the status retry budget. + assert handler.call_count == 4 + compatibility = [ + warning + for warning in caught + if issubclass(warning.category, MlbHttpCompatibilityWarning) + ] + assert compatibility == [] + + def test_owned_client_final_429_raises_under_strict_http(): handler = _ScriptedHandler(_response(429)) From f68349ba1369346e491f3470986e78ca24d46fe4 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 19 Aug 2026 17:27:20 -0700 Subject: [PATCH 26/81] feat: add initial AsyncMlb client vertical slice - Expose AsyncMlb lazily from the package root - Add async team, people, and schedule endpoint methods - Support async context management and resource cleanup - Preserve original exceptions and cancellations during cleanup - Add offline lifecycle tests and live API endpoint coverage --- mlbstatsapi/__init__.py | 23 ++- mlbstatsapi/async_mlb.py | 164 ++++++++++++++++++ .../async_mlb/test_async_mlb.py | 60 +++++++ tests/test_async_mlb.py | 115 ++++++++++++ 4 files changed, 353 insertions(+), 9 deletions(-) create mode 100644 mlbstatsapi/async_mlb.py create mode 100644 tests/external_tests/async_mlb/test_async_mlb.py create mode 100644 tests/test_async_mlb.py diff --git a/mlbstatsapi/__init__.py b/mlbstatsapi/__init__.py index e5a6cf7e..1eb2b924 100644 --- a/mlbstatsapi/__init__.py +++ b/mlbstatsapi/__init__.py @@ -32,20 +32,25 @@ # first access keeps async functionality discoverable from the package root # while the missing-dependency error surfaces only when async is actually # requested. See docs/public-api.md. -_LAZY_ASYNC_EXPORTS = ("AsyncMlbDataAdapter",) +_LAZY_ASYNC_EXPORTS = ( + "AsyncMlb", + "AsyncMlbDataAdapter", +) def __getattr__(name: str): - if name in _LAZY_ASYNC_EXPORTS: + if name == "AsyncMlb": + from .async_mlb import AsyncMlb + + globals()["AsyncMlb"] = AsyncMlb + return AsyncMlb + + if name == "AsyncMlbDataAdapter": from .async_mlb_dataadapter import AsyncMlbDataAdapter - # Cache on the module so later attribute access is an ordinary lookup. globals()["AsyncMlbDataAdapter"] = AsyncMlbDataAdapter return AsyncMlbDataAdapter - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - - -def __dir__() -> list[str]: - # Keeps the lazy async names discoverable without importing HTTPX. - return sorted(set(globals()) | set(_LAZY_ASYNC_EXPORTS)) + raise AttributeError( + f"module {__name__!r} has no attribute {name!r}" + ) diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py new file mode 100644 index 00000000..d5a944e6 --- /dev/null +++ b/mlbstatsapi/async_mlb.py @@ -0,0 +1,164 @@ +# mlbstatsapi/async_mlb.py + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from ._parsers.people import parse_person, parse_people +from ._parsers.schedules import parse_schedule +from ._parsers.teams import parse_team, parse_teams +from .async_mlb_dataadapter import AsyncMlbDataAdapter +from .mlb_dataadapter import DEFAULT_TIMEOUT, TimeoutType +from .models.people import Person +from .models.schedules import Schedule +from .models.teams import Team + +if TYPE_CHECKING: + import httpx + + +class AsyncMlb: + """Asynchronous client for the MLB Stats API.""" + + def __init__( + self, + hostname: str = "statsapi.mlb.com", + logger: logging.Logger | None = None, + timeout: TimeoutType = DEFAULT_TIMEOUT, + client: "httpx.AsyncClient | None" = None, + *, + strict_http: bool = True, + ): + self._logger = logger or logging.getLogger(__name__) + + self._mlb_adapter_v1 = AsyncMlbDataAdapter( + hostname=hostname, + ver="v1", + logger=self._logger, + timeout=timeout, + client=client, + strict_http=strict_http, + ) + + async def aclose(self) -> None: + """Close library-owned async resources.""" + await self._mlb_adapter_v1.aclose() + + async def __aenter__(self) -> "AsyncMlb": + return self + + async def __aexit__( + self, + exc_type, + exc, + traceback, + ) -> None: + try: + await self.aclose() + except BaseException: + # Cleanup must not replace an exception or cancellation that + # already occurred inside the async context. + if exc is None: + raise + + self._logger.exception( + "AsyncMlb cleanup failed while preserving the original exception" + ) + + async def get_team( + self, + team_id: int, + **params, + ) -> Team | None: + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"teams/{team_id}", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_team(mlb_data.data) + + async def get_teams( + self, + **params, + ) -> list[Team]: + mlb_data = await self._mlb_adapter_v1.get( + endpoint="teams", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_teams(mlb_data.data) + + async def get_person( + self, + player_id: int, + **params, + ) -> Person | None: + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"people/{player_id}", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_person(mlb_data.data) + + async def get_people( + self, + person_ids: Union[str, List[int]], + **params, + ) -> list[Person]: + + params['personIds'] = person_ids + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="people", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_people(mlb_data.data) + + + async def get_schedule( + self, + date: str = None, + start_date: str = None, + end_date: str = None, + sport_id: int = 1, + team_id: int = None, + **params, + ) -> Schedule | None: + + if start_date and end_date: + params["startDate"] = start_date + params["endDate"] = end_date + elif date and not (start_date or end_date): + params["date"] = date + elif "gamePks" not in params: + return None + + if team_id: + params['teamId'] = team_id + + params['sportId'] = sport_id + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="schedule", + ep_params=params, + ) + + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_schedule(mlb_data.data) diff --git a/tests/external_tests/async_mlb/test_async_mlb.py b/tests/external_tests/async_mlb/test_async_mlb.py new file mode 100644 index 00000000..691585ac --- /dev/null +++ b/tests/external_tests/async_mlb/test_async_mlb.py @@ -0,0 +1,60 @@ +import asyncio + +from mlbstatsapi import AsyncMlb +from mlbstatsapi.models.people import Person +from mlbstatsapi.models.schedules import Schedule +from mlbstatsapi.models.teams import Team + + +def test_async_get_team(): + async def scenario(): + async with AsyncMlb() as mlb: + team = await mlb.get_team(133) + + assert isinstance(team, Team) + assert team.id == 133 + + asyncio.run(scenario()) + +def test_async_get_teams(): + async def scenario(): + async with AsyncMlb() as mlb: + teams = await mlb.get_teams() + + assert isinstance(teams, list) + assert all(isinstance(team, Team) for team in teams) + + +def test_async_get_person(): + async def scenario(): + async with AsyncMlb() as mlb: + person = await mlb.get_person(664034) + + assert isinstance(person, Person) + assert person.id == 664034 + + asyncio.run(scenario()) + +def test_async_get_people(): + async def scenario(): + + player_ids_l = [605151,592450] + + async with AsyncMlb() as mlb: + people = await mlb.get_people(player_ids_l) + + assert isinstance(people, list) + assert all(isinstance(person, Person) for person in people) + + asyncio.run(scenario()) + + +def test_async_get_schedule(): + async def scenario(): + async with AsyncMlb() as mlb: + schedule = await mlb.get_schedule(date="2022-10-07") + + assert isinstance(schedule, Schedule) + assert schedule.dates + + asyncio.run(scenario()) diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py new file mode 100644 index 00000000..d5228869 --- /dev/null +++ b/tests/test_async_mlb.py @@ -0,0 +1,115 @@ +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest + +from mlbstatsapi.async_mlb import AsyncMlb + + +def run_async(coro): + return asyncio.run(coro) + + +def test_async_mlb_context_manager_returns_self(): + async def scenario(): + mlb = AsyncMlb() + + async with mlb as entered: + assert entered is mlb + + run_async(scenario()) + + +def test_async_mlb_aclose_delegates_to_adapter(): + async def scenario(): + mlb = AsyncMlb() + + mlb._mlb_adapter_v1.aclose = AsyncMock() + + await mlb.aclose() + + mlb._mlb_adapter_v1.aclose.assert_awaited_once() + + run_async(scenario()) + + +def test_async_mlb_context_manager_closes_on_normal_exit(): + async def scenario(): + mlb = AsyncMlb() + + mlb._mlb_adapter_v1.aclose = AsyncMock() + + async with mlb: + pass + + mlb._mlb_adapter_v1.aclose.assert_awaited_once() + + run_async(scenario()) + + +def test_async_mlb_context_manager_closes_when_body_raises(): + async def scenario(): + mlb = AsyncMlb() + + mlb._mlb_adapter_v1.aclose = AsyncMock() + + with pytest.raises(ValueError, match="boom"): + async with mlb: + raise ValueError("boom") + + mlb._mlb_adapter_v1.aclose.assert_awaited_once() + + run_async(scenario()) + + +def test_async_mlb_preserves_original_exception_if_cleanup_fails(): + async def scenario(): + mlb = AsyncMlb() + + mlb._mlb_adapter_v1.aclose = AsyncMock( + side_effect=RuntimeError("cleanup failed") + ) + + with pytest.raises(ValueError, match="original"): + async with mlb: + raise ValueError("original") + + run_async(scenario()) + + +def test_async_mlb_cleanup_failure_raises_when_no_original_exception(): + async def scenario(): + mlb = AsyncMlb() + + mlb._mlb_adapter_v1.aclose = AsyncMock( + side_effect=RuntimeError("cleanup failed") + ) + + with pytest.raises(RuntimeError, match="cleanup failed"): + async with mlb: + pass + + run_async(scenario()) + + +def test_async_mlb_preserves_cancellation_during_cleanup(): + async def scenario(): + mlb = AsyncMlb() + + mlb._mlb_adapter_v1.aclose = AsyncMock() + + async def worker(): + async with mlb: + await asyncio.sleep(60) + + task = asyncio.create_task(worker()) + + await asyncio.sleep(0) + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + mlb._mlb_adapter_v1.aclose.assert_awaited_once() + + run_async(scenario()) From aef77fa04ce15ca8077fba3f5e2a36d261aeddff Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 19 Aug 2026 19:18:21 -0700 Subject: [PATCH 27/81] refactor(async): extract schedule parameter construction - Move schedule request parameter building into a shared parser helper - Reuse the helper in AsyncMlb.get_schedule - Preserve existing handling for dates, game IDs, teams, and sports - Rename the live async test module to identify it as an external test --- mlbstatsapi/_parsers/schedules.py | 23 +++++++++++++++++++ mlbstatsapi/async_mlb.py | 23 +++++++++---------- ...est_async_mlb.py => test_ext_async_mlb.py} | 0 3 files changed, 34 insertions(+), 12 deletions(-) rename tests/external_tests/async_mlb/{test_async_mlb.py => test_ext_async_mlb.py} (100%) diff --git a/mlbstatsapi/_parsers/schedules.py b/mlbstatsapi/_parsers/schedules.py index 39fc0d6b..1126df21 100644 --- a/mlbstatsapi/_parsers/schedules.py +++ b/mlbstatsapi/_parsers/schedules.py @@ -7,3 +7,26 @@ def parse_schedule(data: dict) -> Schedule | None: return None return Schedule(**data) + +def build_schedule_params( + date: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + sport_id: int = 1, + team_id: int | None = None, + **params, +) -> dict | None: + if start_date and end_date: + params["startDate"] = start_date + params["endDate"] = end_date + elif date and not (start_date or end_date): + params["date"] = date + elif "gamePks" not in params: + return None + + if team_id: + params["teamId"] = team_id + + params["sportId"] = sport_id + + return params diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index d5a944e6..cc7f30a1 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING from ._parsers.people import parse_person, parse_people -from ._parsers.schedules import parse_schedule +from ._parsers.schedules import parse_schedule, build_schedule_params from ._parsers.teams import parse_team, parse_teams from .async_mlb_dataadapter import AsyncMlbDataAdapter from .mlb_dataadapter import DEFAULT_TIMEOUT, TimeoutType @@ -139,18 +139,17 @@ async def get_schedule( **params, ) -> Schedule | None: - if start_date and end_date: - params["startDate"] = start_date - params["endDate"] = end_date - elif date and not (start_date or end_date): - params["date"] = date - elif "gamePks" not in params: - return None - - if team_id: - params['teamId'] = team_id + params = build_schedule_params( + date=date, + start_date=start_date, + end_date=end_date, + sport_id=sport_id, + team_id=team_id, + **params, + ) - params['sportId'] = sport_id + if not params: + return None mlb_data = await self._mlb_adapter_v1.get( endpoint="schedule", diff --git a/tests/external_tests/async_mlb/test_async_mlb.py b/tests/external_tests/async_mlb/test_ext_async_mlb.py similarity index 100% rename from tests/external_tests/async_mlb/test_async_mlb.py rename to tests/external_tests/async_mlb/test_ext_async_mlb.py From b3071ef99af2ed3f0d5ed9987fa34f46466652c1 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 19 Aug 2026 19:31:48 -0700 Subject: [PATCH 28/81] fix(async): preserve lazy exports and schedule parameters - Include lazy async client exports in package introspection - Distinguish missing schedule parameters from valid parameter dictionaries --- mlbstatsapi/__init__.py | 5 +++++ mlbstatsapi/async_mlb.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/mlbstatsapi/__init__.py b/mlbstatsapi/__init__.py index 1eb2b924..35ba4784 100644 --- a/mlbstatsapi/__init__.py +++ b/mlbstatsapi/__init__.py @@ -54,3 +54,8 @@ def __getattr__(name: str): raise AttributeError( f"module {__name__!r} has no attribute {name!r}" ) + + +def __dir__() -> list[str]: + return sorted(set(globals()) | set(_LAZY_ASYNC_EXPORTS)) + ) diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index cc7f30a1..6a88e5b1 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -148,7 +148,7 @@ async def get_schedule( **params, ) - if not params: + if params is None: return None mlb_data = await self._mlb_adapter_v1.get( From 3150f9b016672ce661f6255a0abb7afcaf1d17fa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 15:31:50 +0000 Subject: [PATCH 29/81] fix(async): repair package root and align AsyncMlb with the sync API The package root carried a stray closing paren after __dir__, which made `import mlbstatsapi` a SyntaxError and took down all 17 offline test modules at collection, not just the async ones. The lazy AsyncMlb and AsyncMlbDataAdapter exports are unchanged. Two endpoints on the vertical slice had drifted from the synchronous client they port: * get_teams took no sport_id at all, so it never sent the sportId query parameter that Mlb.get_teams always sends. * get_people was really get_persons: it posted personIds to `people` rather than reading `sports/{sport_id}/players`. Its annotations also referenced Union and List, neither imported, which stayed latent only because annotations are deferred in this module. Both now match Mlb in argument names, defaults, endpoint, parameter construction, return type, and empty-result behavior. get_schedule and the shared build_schedule_params helper are unchanged; they already reproduce the sync logic exactly. The async surface no longer offers a personIds lookup. Adding a get_persons port is deliberately left out of this slice. Tests: * tests/test_async_mlb.py grows from 7 tests to 48. Endpoint tests drive the real adapter over httpx.MockTransport instead of mocking the adapter away, so a method that stopped issuing HTTP would fail rather than pass against a mock. Adds the package-root import, client ownership, idempotent cleanup, per-endpoint request and result behavior, a parametrized parity check against Mlb, a signature-drift guard, concurrency on a shared client, and absence of hidden fan-out or background tasks. The transport-contract matrix stays in #302. * The module now guards its import with pytest.importorskip("httpx"), so a sync-only install skips it instead of erroring at collection. * test_async_get_teams defined a scenario but never ran it. It now executes, and it and test_async_get_people assert the result is non-empty, since `all()` over an empty list proves nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013La4LovbUtWoZSrD3iQQKu --- mlbstatsapi/__init__.py | 1 - mlbstatsapi/async_mlb.py | 18 +- .../async_mlb/test_ext_async_mlb.py | 9 +- tests/test_async_mlb.py | 711 +++++++++++++++++- 4 files changed, 728 insertions(+), 11 deletions(-) diff --git a/mlbstatsapi/__init__.py b/mlbstatsapi/__init__.py index 35ba4784..7cde2d79 100644 --- a/mlbstatsapi/__init__.py +++ b/mlbstatsapi/__init__.py @@ -58,4 +58,3 @@ def __getattr__(name: str): def __dir__() -> list[str]: return sorted(set(globals()) | set(_LAZY_ASYNC_EXPORTS)) - ) diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index 6a88e5b1..080e4393 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -83,8 +83,16 @@ async def get_team( async def get_teams( self, + sport_id: int = 1, **params, ) -> list[Team]: + """Return every Team for a sport id. + + Async counterpart of ``Mlb.get_teams``; see that method for the + supported keyword parameters. + """ + params["sportId"] = sport_id + mlb_data = await self._mlb_adapter_v1.get( endpoint="teams", ep_params=params, @@ -112,14 +120,16 @@ async def get_person( async def get_people( self, - person_ids: Union[str, List[int]], + sport_id: int = 1, **params, ) -> list[Person]: + """Return every player for a sport id. - params['personIds'] = person_ids - + Async counterpart of ``Mlb.get_people``, which reads the + ``sports/{sport_id}/players`` endpoint rather than ``people``. + """ mlb_data = await self._mlb_adapter_v1.get( - endpoint="people", + endpoint=f"sports/{sport_id}/players", ep_params=params, ) diff --git a/tests/external_tests/async_mlb/test_ext_async_mlb.py b/tests/external_tests/async_mlb/test_ext_async_mlb.py index 691585ac..9ba91612 100644 --- a/tests/external_tests/async_mlb/test_ext_async_mlb.py +++ b/tests/external_tests/async_mlb/test_ext_async_mlb.py @@ -22,8 +22,11 @@ async def scenario(): teams = await mlb.get_teams() assert isinstance(teams, list) + assert teams assert all(isinstance(team, Team) for team in teams) + asyncio.run(scenario()) + def test_async_get_person(): async def scenario(): @@ -37,13 +40,11 @@ async def scenario(): def test_async_get_people(): async def scenario(): - - player_ids_l = [605151,592450] - async with AsyncMlb() as mlb: - people = await mlb.get_people(player_ids_l) + people = await mlb.get_people() assert isinstance(people, list) + assert people assert all(isinstance(person, Person) for person in people) asyncio.run(scenario()) diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py index d5228869..5aa1219f 100644 --- a/tests/test_async_mlb.py +++ b/tests/test_async_mlb.py @@ -1,13 +1,182 @@ +"""Focused offline tests for the AsyncMlb client (issue #303). + +Covers what the vertical slice actually promises: the package-root import, the +async context-manager and cleanup contract, and — for each endpoint on the +client — the request it builds and the parsed value it returns. + +AsyncMlb is deliberately thin: HTTP behavior belongs to AsyncMlbDataAdapter and +is asserted in tests/test_async_mlb_dataadapter.py, with the exhaustive +transport-contract matrix in #302. Nothing here re-tests retries, status +mapping, timeouts, or exception translation. What is tested here instead is +that the client hands the adapter the right endpoint and params, hands the +response to the shared parsers, and adds nothing of its own between the two. + +The endpoint tests therefore drive the real adapter over an +``httpx.MockTransport`` rather than mocking the adapter away, so an endpoint +that stopped producing a real HTTP request would fail rather than pass against +a mock. Request construction is additionally pinned to the synchronous client +in test_request_construction_matches_sync_client, because the async surface is +only correct insofar as it matches ``Mlb``. + +These tests must not contact the live MLB API. +""" + +from __future__ import annotations + import asyncio from unittest.mock import AsyncMock, patch import pytest -from mlbstatsapi.async_mlb import AsyncMlb +# The endpoint tests drive the real HTTPX-backed adapter, so a sync-only +# install has nothing here to run. Skipping at collection keeps +# ``pytest tests/`` working without the ``async`` extra instead of erroring on +# the import, matching tests/test_async_mlb_dataadapter.py. +httpx = pytest.importorskip("httpx", reason="requires the async extra (HTTPX)") + +from mlbstatsapi.async_mlb import AsyncMlb # noqa: E402 +from mlbstatsapi.models.people import Person # noqa: E402 +from mlbstatsapi.models.schedules import Schedule # noqa: E402 +from mlbstatsapi.models.teams import Team # noqa: E402 + + +# Patched only while a client is constructed, so AsyncMlb builds its own +# adapter and client through the production path and only the transport is +# swapped. Mirrors tests/test_async_mlb_dataadapter.py. +CLIENT_TARGET = "mlbstatsapi.async_mlb_dataadapter.httpx.AsyncClient" + +# Failure guard for the concurrency test: a request that should never wait is +# bounded so a serializing regression fails fast instead of hanging CI. +BLOCKED_REQUEST_TIMEOUT = 10 + +TEAM_PAYLOAD = {"teams": [{"id": 133, "link": "/api/v1/teams/133", "name": "Athletics"}]} +TEAMS_PAYLOAD = { + "teams": [ + {"id": 133, "link": "/api/v1/teams/133", "name": "Athletics"}, + {"id": 134, "link": "/api/v1/teams/134", "name": "Team 134"}, + ] +} +PERSON_PAYLOAD = { + "people": [{"id": 660271, "link": "/api/v1/people/660271", "fullName": "Shohei Ohtani"}] +} +PEOPLE_PAYLOAD = { + "people": [ + {"id": 660271, "link": "/api/v1/people/660271", "fullName": "Shohei Ohtani"}, + {"id": 605151, "link": "/api/v1/people/605151", "fullName": "Person 605151"}, + ] +} +SCHEDULE_PAYLOAD = { + "totalItems": 1, + "totalEvents": 0, + "totalGames": 1, + "totalGamesInProgress": 0, + "dates": [ + { + "date": "2022-10-07", + "totalItems": 1, + "totalEvents": 0, + "totalGames": 1, + "totalGamesInProgress": 0, + "games": [], + } + ], +} + + +# Clients built by _owned_client(); run_async() closes them inside the same +# event loop that used them, so no AsyncClient is left open by a test. +_CLIENTS_TO_CLOSE: list[AsyncMlb] = [] def run_async(coro): - return asyncio.run(coro) + async def runner(): + try: + return await coro + finally: + while _CLIENTS_TO_CLOSE: + await _CLIENTS_TO_CLOSE.pop().aclose() + + return asyncio.run(runner()) + + +class _RecordingHandler: + """Serve one response per endpoint path and record every request seen.""" + + def __init__(self, responses: dict[str, httpx.Response] | httpx.Response): + self._responses = responses + self.requests: list[httpx.Request] = [] + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + if isinstance(self._responses, httpx.Response): + return self._responses + # Keyed by the path after ``/api/v1/``, e.g. "teams/133". + endpoint = request.url.path.split("/api/v1/", 1)[-1] + return self._responses[endpoint] + + @property + def call_count(self) -> int: + return len(self.requests) + + def params_for(self, endpoint: str) -> dict[str, str]: + for request in self.requests: + if request.url.path.endswith(endpoint): + return dict(request.url.params) + raise AssertionError(f"no request was made to {endpoint!r}") + + +def _owned_client(handler, **kwargs) -> AsyncMlb: + """Build an AsyncMlb that owns its client, over a MockTransport. + + Call this from inside a run_async() scenario; run_async() closes what it + creates. + """ + real_async_client = httpx.AsyncClient + + def mock_transport_client(**client_kwargs) -> httpx.AsyncClient: + return real_async_client( + transport=httpx.MockTransport(handler), + **client_kwargs, + ) + + with patch(CLIENT_TARGET, mock_transport_client): + mlb = AsyncMlb(**kwargs) + + _CLIENTS_TO_CLOSE.append(mlb) + return mlb + + +def _json(payload: dict) -> httpx.Response: + return httpx.Response(200, json=payload) + + +# --------------------------------------------------------------------------- +# Package-root import +# --------------------------------------------------------------------------- + + +def test_async_mlb_is_importable_from_the_package_root(): + """AsyncMlb is reachable as ``from mlbstatsapi import AsyncMlb``. + + It resolves through the package-root lazy __getattr__, so this also proves + the lazy async export still works and is the same class the module exposes. + """ + from mlbstatsapi import AsyncMlb as RootAsyncMlb + + assert RootAsyncMlb is AsyncMlb + + +def test_async_mlb_is_advertised_by_package_dir(): + """dir(mlbstatsapi) advertises the lazily exported async names.""" + import mlbstatsapi + + assert "AsyncMlb" in dir(mlbstatsapi) + assert "AsyncMlbDataAdapter" in dir(mlbstatsapi) + + +# --------------------------------------------------------------------------- +# Lifecycle: context manager, cleanup, cancellation, ownership +# --------------------------------------------------------------------------- def test_async_mlb_context_manager_returns_self(): @@ -17,6 +186,8 @@ async def scenario(): async with mlb as entered: assert entered is mlb + return mlb + run_async(scenario()) @@ -113,3 +284,539 @@ async def worker(): mlb._mlb_adapter_v1.aclose.assert_awaited_once() run_async(scenario()) + + +def test_async_mlb_closes_the_client_it_owns(): + """Exiting the context manager really closes the underlying HTTPX client.""" + handler = _RecordingHandler(_json(TEAM_PAYLOAD)) + + async def scenario(): + mlb = _owned_client(handler) + client = mlb._mlb_adapter_v1._client + + async with mlb: + await mlb.get_team(133) + + assert client.is_closed + + run_async(scenario()) + + +def test_async_mlb_leaves_a_caller_injected_client_open(): + """A client the caller supplied is the caller's to close, not the library's. + + AsyncMlb must pass ownership through to the adapter unchanged: closing an + injected client would break a caller reusing it for its own requests after + the ``async with`` block. + """ + handler = _RecordingHandler(_json(TEAM_PAYLOAD)) + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + + async def scenario(): + mlb = AsyncMlb(client=client) + + assert mlb._mlb_adapter_v1._owns_client is False + + async with mlb: + await mlb.get_team(133) + + assert client.is_closed is False + + # Still usable afterwards, which is the point of injecting it. + await client.get("https://statsapi.mlb.com/api/v1/teams/133") + + await client.aclose() + + run_async(scenario()) + + +def test_async_mlb_cleanup_is_idempotent(): + """Repeated cleanup is safe, however the caller mixes the two forms.""" + handler = _RecordingHandler(_json(TEAM_PAYLOAD)) + + async def scenario(): + mlb = _owned_client(handler) + + async with mlb: + await mlb.get_team(133) + + # Already closed by __aexit__; neither of these may raise. + await mlb.aclose() + await mlb.aclose() + + async with mlb: + pass + + run_async(scenario()) + + +# --------------------------------------------------------------------------- +# Endpoints: request construction and parsed results +# --------------------------------------------------------------------------- + + +def test_get_team_requests_the_team_endpoint_and_parses_the_result(): + handler = _RecordingHandler(_json(TEAM_PAYLOAD)) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_team(133, season="2022") + + team = run_async(scenario()) + + assert handler.call_count == 1 + assert handler.requests[0].url.path == "/api/v1/teams/133" + assert handler.params_for("teams/133") == {"season": "2022"} + assert team == Team(id=133, link="/api/v1/teams/133", name="Athletics") + + +def test_get_team_returns_none_for_an_unknown_team(): + """A 404 is the adapter's empty result, which the client turns into None.""" + handler = _RecordingHandler(httpx.Response(404, json={})) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_team(1) + + assert run_async(scenario()) is None + + +def test_get_team_returns_none_for_an_empty_payload(): + """An empty 200 body has no team to parse, so there is no Team to return.""" + handler = _RecordingHandler(_json({"teams": []})) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_team(133) + + assert run_async(scenario()) is None + + +def test_get_teams_sends_sport_id_and_parses_every_team(): + """get_teams defaults to sportId=1 and promotes sport_id into the query.""" + handler = _RecordingHandler(_json(TEAMS_PAYLOAD)) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_teams() + + teams = run_async(scenario()) + + assert handler.params_for("teams") == {"sportId": "1"} + assert teams == [ + Team(id=133, link="/api/v1/teams/133", name="Athletics"), + Team(id=134, link="/api/v1/teams/134", name="Team 134"), + ] + + +def test_get_teams_passes_an_explicit_sport_id_and_extra_params(): + handler = _RecordingHandler(_json({"teams": []})) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_teams(11, season="2021") + + teams = run_async(scenario()) + + assert handler.params_for("teams") == {"sportId": "11", "season": "2021"} + assert teams == [] + + +def test_get_teams_returns_empty_list_for_an_unknown_sport(): + handler = _RecordingHandler(httpx.Response(404, json={})) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_teams(999) + + assert run_async(scenario()) == [] + + +def test_get_person_requests_the_people_endpoint_and_parses_the_result(): + handler = _RecordingHandler(_json(PERSON_PAYLOAD)) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_person(660271, hydrate="currentTeam") + + person = run_async(scenario()) + + assert handler.call_count == 1 + assert handler.requests[0].url.path == "/api/v1/people/660271" + assert handler.params_for("people/660271") == {"hydrate": "currentTeam"} + assert person == Person( + id=660271, link="/api/v1/people/660271", full_name="Shohei Ohtani" + ) + + +def test_get_person_returns_none_for_an_unknown_person(): + handler = _RecordingHandler(httpx.Response(404, json={})) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_person(1) + + assert run_async(scenario()) is None + + +def test_get_person_returns_none_for_an_empty_payload(): + handler = _RecordingHandler(_json({"people": []})) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_person(660271) + + assert run_async(scenario()) is None + + +def test_get_people_requests_the_sport_players_endpoint(): + """get_people reads sports/{sport_id}/players, exactly like Mlb.get_people. + + The sport id goes in the path, not the query, which is what separates this + endpoint from get_persons' ``people?personIds=`` form. + """ + handler = _RecordingHandler(_json(PEOPLE_PAYLOAD)) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_people() + + people = run_async(scenario()) + + assert handler.requests[0].url.path == "/api/v1/sports/1/players" + assert handler.params_for("sports/1/players") == {} + assert people == [ + Person(id=660271, link="/api/v1/people/660271", full_name="Shohei Ohtani"), + Person(id=605151, link="/api/v1/people/605151", full_name="Person 605151"), + ] + + +def test_get_people_passes_an_explicit_sport_id_and_extra_params(): + handler = _RecordingHandler(_json({"people": []})) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_people(11, season="2021") + + people = run_async(scenario()) + + assert handler.requests[0].url.path == "/api/v1/sports/11/players" + assert handler.params_for("sports/11/players") == {"season": "2021"} + assert people == [] + + +def test_get_people_returns_empty_list_for_an_unknown_sport(): + handler = _RecordingHandler(httpx.Response(404, json={})) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_people(999) + + assert run_async(scenario()) == [] + + +def test_get_schedule_sends_date_and_sport_id_and_parses_the_result(): + handler = _RecordingHandler(_json(SCHEDULE_PAYLOAD)) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_schedule(date="2022-10-07") + + schedule = run_async(scenario()) + + assert handler.requests[0].url.path == "/api/v1/schedule" + assert handler.params_for("schedule") == {"date": "2022-10-07", "sportId": "1"} + assert isinstance(schedule, Schedule) + assert schedule == Schedule(**SCHEDULE_PAYLOAD) + + +def test_get_schedule_sends_a_date_range_and_team_id(): + handler = _RecordingHandler(_json(SCHEDULE_PAYLOAD)) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_schedule( + start_date="2021-08-01", + end_date="2021-08-11", + team_id=133, + ) + + run_async(scenario()) + + assert handler.params_for("schedule") == { + "startDate": "2021-08-01", + "endDate": "2021-08-11", + "teamId": "133", + "sportId": "1", + } + + +def test_get_schedule_allows_game_pks_without_a_date(): + """gamePks is the one way to ask for a schedule with no date at all.""" + handler = _RecordingHandler(_json(SCHEDULE_PAYLOAD)) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_schedule(gamePks=531493) + + run_async(scenario()) + + assert handler.params_for("schedule") == {"gamePks": "531493", "sportId": "1"} + + +def test_get_schedule_without_dates_or_game_pks_makes_no_request(): + """An unanswerable schedule request returns None without touching the API.""" + handler = _RecordingHandler(_json(SCHEDULE_PAYLOAD)) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_schedule() + + assert run_async(scenario()) is None + assert handler.call_count == 0 + + +def test_get_schedule_returns_none_for_an_unknown_schedule(): + handler = _RecordingHandler(httpx.Response(404, json={})) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_schedule(date="2022-10-07") + + assert run_async(scenario()) is None + + +def test_get_schedule_returns_none_when_no_games_are_scheduled(): + """An empty ``dates`` list is a valid 200 that parses to no Schedule.""" + handler = _RecordingHandler( + _json( + { + "totalItems": 0, + "totalEvents": 0, + "totalGames": 0, + "totalGamesInProgress": 0, + "dates": [], + } + ) + ) + + async def scenario(): + mlb = _owned_client(handler) + return await mlb.get_schedule(date="2022-12-25") + + assert run_async(scenario()) is None + + +# --------------------------------------------------------------------------- +# Parity with the synchronous client +# --------------------------------------------------------------------------- + + +SYNC_PARITY_CASES = [ + ("get_team", (133,), {}), + ("get_team", (133,), {"season": "2022"}), + ("get_teams", (), {}), + ("get_teams", (11,), {"season": "2021"}), + ("get_person", (660271,), {}), + ("get_people", (), {}), + ("get_people", (11,), {"season": "2021"}), + ("get_schedule", (), {"date": "2022-10-07"}), + ("get_schedule", (), {"start_date": "2021-08-01", "end_date": "2021-08-11"}), + ("get_schedule", (), {"team_id": 133, "date": "2022-10-07"}), + ("get_schedule", (), {"gamePks": 531493}), + ("get_schedule", (), {}), +] + + +@pytest.mark.parametrize("method, args, kwargs", SYNC_PARITY_CASES) +def test_request_construction_matches_sync_client(method, args, kwargs): + """AsyncMlb asks the adapter for exactly what Mlb asks for. + + The async surface is a port of the sync one, so a drift in endpoint, + parameter name, or default belongs in this test rather than in a live + failure. Both adapters are stubbed, so nothing here reaches the network. + """ + from unittest.mock import MagicMock + + from mlbstatsapi import Mlb + from mlbstatsapi.mlb_dataadapter import MlbResult + + empty = MlbResult(status_code=200, message=None, data={}) + + sync_mlb = Mlb() + sync_mlb._mlb_adapter_v1.get = MagicMock(return_value=empty) + sync_result = getattr(sync_mlb, method)(*args, **kwargs) + + async def scenario(): + async_mlb = AsyncMlb() + async_mlb._mlb_adapter_v1.get = AsyncMock(return_value=empty) + result = await getattr(async_mlb, method)(*args, **kwargs) + return result, async_mlb._mlb_adapter_v1.get.call_args + + async_result, async_call = run_async(scenario()) + + assert async_call == sync_mlb._mlb_adapter_v1.get.call_args + assert async_result == sync_result + + +def test_signatures_match_the_sync_client(): + """Names, kinds, and defaults are identical to the sync client's.""" + import inspect + + from mlbstatsapi import Mlb + + for name in ("get_team", "get_teams", "get_person", "get_people", "get_schedule"): + sync_params = inspect.signature(getattr(Mlb, name)).parameters + async_params = inspect.signature(getattr(AsyncMlb, name)).parameters + + assert [ + (p.name, p.kind, p.default) for p in sync_params.values() + ] == [ + (p.name, p.kind, p.default) for p in async_params.values() + ], f"{name} drifted from Mlb.{name}" + + +# --------------------------------------------------------------------------- +# Concurrency and the absence of hidden work +# --------------------------------------------------------------------------- + + +def test_concurrent_endpoint_calls_share_one_client_without_crossing_results(): + """Two endpoints on one client keep their own request and their own result. + + Sharing an AsyncClient is the reason AsyncMlb exists; a client that mixed + up two in-flight responses would be worse than useless. + """ + handler = _RecordingHandler( + { + "teams/133": _json(TEAM_PAYLOAD), + "people/660271": _json(PERSON_PAYLOAD), + "schedule": _json(SCHEDULE_PAYLOAD), + } + ) + + async def scenario(): + mlb = _owned_client(handler) + return await asyncio.gather( + mlb.get_team(133), + mlb.get_person(660271), + mlb.get_schedule(date="2022-10-07"), + ) + + team, person, schedule = run_async(scenario()) + + assert team == Team(id=133, link="/api/v1/teams/133", name="Athletics") + assert person == Person( + id=660271, link="/api/v1/people/660271", full_name="Shohei Ohtani" + ) + assert schedule == Schedule(**SCHEDULE_PAYLOAD) + assert handler.call_count == 3 + + +def test_one_endpoint_call_does_not_block_another_on_the_same_client(): + """A slow endpoint must not serialize the rest of the client. + + Without real concurrency the fast call could not finish while the slow one + is still waiting, so the gate would never open and the test would hit its + timeout instead of passing. + """ + fast_call_completed = asyncio.Event() + + async def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("teams/133"): + await asyncio.wait_for( + fast_call_completed.wait(), + timeout=BLOCKED_REQUEST_TIMEOUT, + ) + return _json(TEAM_PAYLOAD) + return _json(PERSON_PAYLOAD) + + async def scenario(): + mlb = _owned_client(handler) + + slow = asyncio.ensure_future(mlb.get_team(133)) + await asyncio.sleep(0) + + person = await mlb.get_person(660271) + fast_call_completed.set() + + return await slow, person + + team, person = run_async(scenario()) + + assert team == Team(id=133, link="/api/v1/teams/133", name="Athletics") + assert person == Person( + id=660271, link="/api/v1/people/660271", full_name="Shohei Ohtani" + ) + + +def test_an_endpoint_call_issues_exactly_one_request(): + """No hidden fan-out: one call to one endpoint is one HTTP request. + + A successful call must not prefetch, hydrate, or otherwise widen itself + into extra traffic behind the caller's back. + """ + handler = _RecordingHandler(_json(TEAMS_PAYLOAD)) + + async def scenario(): + mlb = _owned_client(handler) + await mlb.get_teams() + + run_async(scenario()) + + assert handler.call_count == 1 + assert [request.url.path for request in handler.requests] == ["/api/v1/teams"] + + +def test_endpoint_calls_leave_no_background_tasks_behind(): + """No hidden background work: nothing outlives the awaited call. + + A stray task would keep running after the client is closed and surface as + an unpredictable warning or error somewhere else entirely. + """ + handler = _RecordingHandler( + { + "teams": _json(TEAMS_PAYLOAD), + "teams/133": _json(TEAM_PAYLOAD), + "people/660271": _json(PERSON_PAYLOAD), + "sports/1/players": _json(PEOPLE_PAYLOAD), + "schedule": _json(SCHEDULE_PAYLOAD), + } + ) + + async def scenario(): + mlb = _owned_client(handler) + + before = asyncio.all_tasks() + + # Every endpoint on the client, so none of them may leak a task. + await mlb.get_team(133) + await mlb.get_teams() + await mlb.get_person(660271) + await mlb.get_people() + await mlb.get_schedule(date="2022-10-07") + await mlb.aclose() + + # Let anything that was scheduled get a chance to appear. + await asyncio.sleep(0) + + assert asyncio.all_tasks() - before == set() + + run_async(scenario()) + + +def test_construction_starts_no_work(): + """Building a client is inert: no request, no task, until an endpoint is called.""" + handler = _RecordingHandler(_json(TEAM_PAYLOAD)) + + async def scenario(): + before = asyncio.all_tasks() + + _owned_client(handler) + + await asyncio.sleep(0) + + assert handler.call_count == 0 + assert asyncio.all_tasks() - before == set() + + run_async(scenario()) From 3a6925658350f866718e108a6f0ea8304d558860 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 17:13:18 +0000 Subject: [PATCH 30/81] test: focus the AsyncMlb suite and fix its resource cleanup The suite had grown to 48 tests over 838 lines, well past what the #303 vertical slice is worth. It is now 20 tests over 443 lines, covering the public import, the lifecycle contract, per-endpoint request construction and parsed results, signature parity, and one concurrency case. What went, and why: * Exhaustive schedule argument combinations collapse to one representative date-range-with-team case plus the no-selector case. * Six empty-result permutations become two parametrized tests over the two ways an endpoint comes back with nothing: 404 and an empty 200. * The twelve-case request-construction matrix is gone. Each endpoint test now derives its expected endpoint and params from Mlb itself through assert_matches_sync(), so drift is still caught where the endpoint is asserted rather than in a separate matrix. * The asyncio.all_tasks() tests asserted an implementation detail and are dropped. The standalone fan-out test is dropped too: _Handler asserts the request count when a test reads .request, so every endpoint test rules out fan-out on its own. * Status mapping and transport behavior belong to the adapter suite and the #302 matrix; payload parsing belongs to tests/parsers/. Neither is re-asserted here. Cleanup is also correct now, which it was not before. Tests built a bare AsyncMlb, which eagerly opens an HTTPX client, then replaced the adapter's aclose with a mock, so the client was never closed; the sync parity test leaked a requests Session per case. Eighteen tests leaked. Rather than a global registry, teardown is a local async_mlb() context manager that closes the adapter's client in its finally. It closes the client directly instead of calling AsyncMlb.aclose(), because the one test that mocks aclose would otherwise still leak. Verified with an instrumented run that counts unclosed clients and sessions: eighteen leaking tests before, zero after. Seven mutations of the client -- dropped sportId, get_people reverting to the people endpoint, a lost schedule short-circuit, a no-op aclose, aclose closing an injected client, a swallowed cleanup failure, and a drifted default -- all still fail, so the smaller suite protects what the larger one did. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013La4LovbUtWoZSrD3iQQKu --- tests/test_async_mlb.py | 853 +++++++++++----------------------------- 1 file changed, 237 insertions(+), 616 deletions(-) diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py index 5aa1219f..cafb2637 100644 --- a/tests/test_async_mlb.py +++ b/tests/test_async_mlb.py @@ -1,22 +1,21 @@ """Focused offline tests for the AsyncMlb client (issue #303). -Covers what the vertical slice actually promises: the package-root import, the -async context-manager and cleanup contract, and — for each endpoint on the -client — the request it builds and the parsed value it returns. - -AsyncMlb is deliberately thin: HTTP behavior belongs to AsyncMlbDataAdapter and -is asserted in tests/test_async_mlb_dataadapter.py, with the exhaustive -transport-contract matrix in #302. Nothing here re-tests retries, status -mapping, timeouts, or exception translation. What is tested here instead is -that the client hands the adapter the right endpoint and params, hands the -response to the shared parsers, and adds nothing of its own between the two. - -The endpoint tests therefore drive the real adapter over an -``httpx.MockTransport`` rather than mocking the adapter away, so an endpoint -that stopped producing a real HTTP request would fail rather than pass against -a mock. Request construction is additionally pinned to the synchronous client -in test_request_construction_matches_sync_client, because the async surface is -only correct insofar as it matches ``Mlb``. +AsyncMlb is deliberately thin: it builds a request, hands it to +AsyncMlbDataAdapter, and hands the response to a shared parser. So this module +asserts only what the client itself is responsible for — the package-root +import, the async lifecycle contract, and, per endpoint, the request built and +the value parsed back. + +Everything below the client belongs to other modules and is not retested here: +HTTP status mapping, retries, timeouts and exception translation live in +tests/test_async_mlb_dataadapter.py and the #302 transport matrix, and payload +parsing lives in tests/parsers/. + +Endpoint tests drive the real adapter over an ``httpx.MockTransport`` rather +than mocking the adapter away, so a method that stopped issuing a request would +fail rather than pass against a mock. Where an endpoint exists to mirror one on +the synchronous client, the expected request is derived from ``Mlb`` itself +rather than hardcoded, so drift shows up here instead of in production. These tests must not contact the live MLB API. """ @@ -24,47 +23,30 @@ from __future__ import annotations import asyncio -from unittest.mock import AsyncMock, patch +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock import pytest -# The endpoint tests drive the real HTTPX-backed adapter, so a sync-only -# install has nothing here to run. Skipping at collection keeps -# ``pytest tests/`` working without the ``async`` extra instead of erroring on -# the import, matching tests/test_async_mlb_dataadapter.py. +# These tests drive the real HTTPX-backed adapter, so a sync-only install has +# nothing here to run. Skipping at collection keeps ``pytest tests/`` working +# without the ``async`` extra instead of erroring on the import. The +# optional-dependency contract itself is asserted in +# tests/test_async_optional_dependency.py. httpx = pytest.importorskip("httpx", reason="requires the async extra (HTTPX)") +from mlbstatsapi import Mlb # noqa: E402 from mlbstatsapi.async_mlb import AsyncMlb # noqa: E402 +from mlbstatsapi.mlb_dataadapter import MlbResult # noqa: E402 from mlbstatsapi.models.people import Person # noqa: E402 from mlbstatsapi.models.schedules import Schedule # noqa: E402 from mlbstatsapi.models.teams import Team # noqa: E402 -# Patched only while a client is constructed, so AsyncMlb builds its own -# adapter and client through the production path and only the transport is -# swapped. Mirrors tests/test_async_mlb_dataadapter.py. -CLIENT_TARGET = "mlbstatsapi.async_mlb_dataadapter.httpx.AsyncClient" - -# Failure guard for the concurrency test: a request that should never wait is -# bounded so a serializing regression fails fast instead of hanging CI. -BLOCKED_REQUEST_TIMEOUT = 10 - TEAM_PAYLOAD = {"teams": [{"id": 133, "link": "/api/v1/teams/133", "name": "Athletics"}]} -TEAMS_PAYLOAD = { - "teams": [ - {"id": 133, "link": "/api/v1/teams/133", "name": "Athletics"}, - {"id": 134, "link": "/api/v1/teams/134", "name": "Team 134"}, - ] -} PERSON_PAYLOAD = { "people": [{"id": 660271, "link": "/api/v1/people/660271", "fullName": "Shohei Ohtani"}] } -PEOPLE_PAYLOAD = { - "people": [ - {"id": 660271, "link": "/api/v1/people/660271", "fullName": "Shohei Ohtani"}, - {"id": 605151, "link": "/api/v1/people/605151", "fullName": "Person 605151"}, - ] -} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -82,27 +64,28 @@ ], } +EXPECTED_TEAM = Team(id=133, link="/api/v1/teams/133", name="Athletics") +EXPECTED_PERSON = Person( + id=660271, link="/api/v1/people/660271", full_name="Shohei Ohtani" +) -# Clients built by _owned_client(); run_async() closes them inside the same -# event loop that used them, so no AsyncClient is left open by a test. -_CLIENTS_TO_CLOSE: list[AsyncMlb] = [] +# The two ways an endpoint legitimately comes back with nothing to parse. +NO_RESULT_RESPONSES = { + "404": httpx.Response(404, json={}), + "empty 200": httpx.Response(200, json={}), +} -def run_async(coro): - async def runner(): - try: - return await coro - finally: - while _CLIENTS_TO_CLOSE: - await _CLIENTS_TO_CLOSE.pop().aclose() - - return asyncio.run(runner()) +def _json(payload: dict) -> httpx.Response: + return httpx.Response(200, json=payload) -class _RecordingHandler: - """Serve one response per endpoint path and record every request seen.""" +class _Handler: + """Serve a canned response and record the requests that arrive.""" - def __init__(self, responses: dict[str, httpx.Response] | httpx.Response): + def __init__(self, responses: httpx.Response | dict[str, httpx.Response]): + # A bare Response answers any path; a dict is keyed by endpoint, + # e.g. {"teams/133": ...}. self._responses = responses self.requests: list[httpx.Request] = [] @@ -110,713 +93,351 @@ def __call__(self, request: httpx.Request) -> httpx.Response: self.requests.append(request) if isinstance(self._responses, httpx.Response): return self._responses - # Keyed by the path after ``/api/v1/``, e.g. "teams/133". - endpoint = request.url.path.split("/api/v1/", 1)[-1] - return self._responses[endpoint] + return self._responses[request.url.path.split("/api/v1/", 1)[-1]] @property - def call_count(self) -> int: - return len(self.requests) + def request(self) -> httpx.Request: + """The single request the call made. - def params_for(self, endpoint: str) -> dict[str, str]: - for request in self.requests: - if request.url.path.endswith(endpoint): - return dict(request.url.params) - raise AssertionError(f"no request was made to {endpoint!r}") + Asserting the count here means every test using it also rules out a + client that quietly fanned one call out into several. + """ + assert len(self.requests) == 1, f"expected 1 request, got {len(self.requests)}" + return self.requests[0] -def _owned_client(handler, **kwargs) -> AsyncMlb: - """Build an AsyncMlb that owns its client, over a MockTransport. +@asynccontextmanager +async def async_mlb(handler: _Handler): + """Yield an AsyncMlb whose own client talks to ``handler``, then close it. - Call this from inside a run_async() scenario; run_async() closes what it - creates. + AsyncMlb builds its adapter and client through the production path; only + the transport is swapped. Teardown closes the adapter's client directly + rather than calling AsyncMlb.aclose(), so the lifecycle tests that replace + aclose with a mock still get their real client closed. """ real_async_client = httpx.AsyncClient def mock_transport_client(**client_kwargs) -> httpx.AsyncClient: return real_async_client( - transport=httpx.MockTransport(handler), - **client_kwargs, + transport=httpx.MockTransport(handler), **client_kwargs ) - with patch(CLIENT_TARGET, mock_transport_client): - mlb = AsyncMlb(**kwargs) - - _CLIENTS_TO_CLOSE.append(mlb) - return mlb - - -def _json(payload: dict) -> httpx.Response: - return httpx.Response(200, json=payload) - + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setattr( + "mlbstatsapi.async_mlb_dataadapter.httpx.AsyncClient", + mock_transport_client, + ) + mlb = AsyncMlb() -# --------------------------------------------------------------------------- -# Package-root import -# --------------------------------------------------------------------------- + try: + yield mlb + finally: + await mlb._mlb_adapter_v1._client.aclose() -def test_async_mlb_is_importable_from_the_package_root(): - """AsyncMlb is reachable as ``from mlbstatsapi import AsyncMlb``. +def sync_request_for(method: str, *args, **kwargs) -> tuple[str, dict]: + """Return the endpoint and params ``Mlb`` builds for a call. - It resolves through the package-root lazy __getattr__, so this also proves - the lazy async export still works and is the same class the module exposes. + The adapter is stubbed, so this reaches no network; it just reads back what + the synchronous client asked for. """ - from mlbstatsapi import AsyncMlb as RootAsyncMlb + with Mlb() as sync_mlb: + sync_mlb._mlb_adapter_v1.get = MagicMock( + return_value=MlbResult(status_code=200, message=None, data={}) + ) + getattr(sync_mlb, method)(*args, **kwargs) + call = sync_mlb._mlb_adapter_v1.get.call_args - assert RootAsyncMlb is AsyncMlb + return call.kwargs["endpoint"], call.kwargs["ep_params"] -def test_async_mlb_is_advertised_by_package_dir(): - """dir(mlbstatsapi) advertises the lazily exported async names.""" - import mlbstatsapi +def assert_matches_sync(request: httpx.Request, method: str, *args, **kwargs) -> None: + """Assert an observed request is the one ``Mlb`` would have made.""" + endpoint, params = sync_request_for(method, *args, **kwargs) - assert "AsyncMlb" in dir(mlbstatsapi) - assert "AsyncMlbDataAdapter" in dir(mlbstatsapi) + assert request.url.path == f"/api/v1/{endpoint}" + # Query values arrive as strings, whatever type the client passed in. + assert dict(request.url.params) == {k: str(v) for k, v in params.items()} # --------------------------------------------------------------------------- -# Lifecycle: context manager, cleanup, cancellation, ownership +# Public API # --------------------------------------------------------------------------- -def test_async_mlb_context_manager_returns_self(): - async def scenario(): - mlb = AsyncMlb() - - async with mlb as entered: - assert entered is mlb - - return mlb - - run_async(scenario()) - - -def test_async_mlb_aclose_delegates_to_adapter(): - async def scenario(): - mlb = AsyncMlb() - - mlb._mlb_adapter_v1.aclose = AsyncMock() +def test_async_mlb_is_importable_from_the_package_root(): + """AsyncMlb resolves through the package root's lazy async export.""" + from mlbstatsapi import AsyncMlb as RootAsyncMlb - await mlb.aclose() + assert RootAsyncMlb is AsyncMlb - mlb._mlb_adapter_v1.aclose.assert_awaited_once() - run_async(scenario()) +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- -def test_async_mlb_context_manager_closes_on_normal_exit(): +def test_aenter_returns_self(): async def scenario(): - mlb = AsyncMlb() - - mlb._mlb_adapter_v1.aclose = AsyncMock() + async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: + async with mlb as entered: + assert entered is mlb - async with mlb: - pass + asyncio.run(scenario()) - mlb._mlb_adapter_v1.aclose.assert_awaited_once() - run_async(scenario()) - - -def test_async_mlb_context_manager_closes_when_body_raises(): +def test_context_exit_closes_the_owned_client(): async def scenario(): - mlb = AsyncMlb() + async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: + client = mlb._mlb_adapter_v1._client - mlb._mlb_adapter_v1.aclose = AsyncMock() - - with pytest.raises(ValueError, match="boom"): async with mlb: - raise ValueError("boom") + await mlb.get_team(133) - mlb._mlb_adapter_v1.aclose.assert_awaited_once() + assert client.is_closed - run_async(scenario()) + asyncio.run(scenario()) -def test_async_mlb_preserves_original_exception_if_cleanup_fails(): +def test_context_exit_closes_the_owned_client_when_the_body_raises(): async def scenario(): - mlb = AsyncMlb() - - mlb._mlb_adapter_v1.aclose = AsyncMock( - side_effect=RuntimeError("cleanup failed") - ) - - with pytest.raises(ValueError, match="original"): - async with mlb: - raise ValueError("original") + async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: + client = mlb._mlb_adapter_v1._client - run_async(scenario()) + with pytest.raises(ValueError, match="boom"): + async with mlb: + raise ValueError("boom") + assert client.is_closed -def test_async_mlb_cleanup_failure_raises_when_no_original_exception(): - async def scenario(): - mlb = AsyncMlb() + asyncio.run(scenario()) - mlb._mlb_adapter_v1.aclose = AsyncMock( - side_effect=RuntimeError("cleanup failed") - ) - with pytest.raises(RuntimeError, match="cleanup failed"): - async with mlb: - pass - - run_async(scenario()) +def test_cleanup_failure_does_not_replace_the_original_exception(): + """A failure while closing must not mask what actually went wrong. + With no original exception to protect, the cleanup failure is the only + thing to report and does surface. + """ -def test_async_mlb_preserves_cancellation_during_cleanup(): async def scenario(): - mlb = AsyncMlb() - - mlb._mlb_adapter_v1.aclose = AsyncMock() - - async def worker(): - async with mlb: - await asyncio.sleep(60) - - task = asyncio.create_task(worker()) - - await asyncio.sleep(0) - task.cancel() + async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: + mlb._mlb_adapter_v1.aclose = AsyncMock( + side_effect=RuntimeError("cleanup failed") + ) - with pytest.raises(asyncio.CancelledError): - await task + with pytest.raises(ValueError, match="original"): + async with mlb: + raise ValueError("original") - mlb._mlb_adapter_v1.aclose.assert_awaited_once() + with pytest.raises(RuntimeError, match="cleanup failed"): + async with mlb: + pass - run_async(scenario()) + asyncio.run(scenario()) -def test_async_mlb_closes_the_client_it_owns(): - """Exiting the context manager really closes the underlying HTTPX client.""" - handler = _RecordingHandler(_json(TEAM_PAYLOAD)) +def test_cancellation_is_preserved_through_cleanup(): + """Cleanup must not swallow a cancellation that arrived from outside.""" async def scenario(): - mlb = _owned_client(handler) - client = mlb._mlb_adapter_v1._client + async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: + client = mlb._mlb_adapter_v1._client - async with mlb: - await mlb.get_team(133) + async def worker(): + async with mlb: + await asyncio.sleep(60) - assert client.is_closed + task = asyncio.create_task(worker()) + await asyncio.sleep(0) + task.cancel() - run_async(scenario()) + with pytest.raises(asyncio.CancelledError): + await task + assert client.is_closed -def test_async_mlb_leaves_a_caller_injected_client_open(): - """A client the caller supplied is the caller's to close, not the library's. + asyncio.run(scenario()) - AsyncMlb must pass ownership through to the adapter unchanged: closing an - injected client would break a caller reusing it for its own requests after - the ``async with`` block. - """ - handler = _RecordingHandler(_json(TEAM_PAYLOAD)) + +def test_caller_injected_client_is_left_open(): + """A client the caller supplied is the caller's to close, not the library's.""" + handler = _Handler(_json(TEAM_PAYLOAD)) client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) async def scenario(): - mlb = AsyncMlb(client=client) - - assert mlb._mlb_adapter_v1._owns_client is False - - async with mlb: - await mlb.get_team(133) - - assert client.is_closed is False - - # Still usable afterwards, which is the point of injecting it. - await client.get("https://statsapi.mlb.com/api/v1/teams/133") + try: + async with AsyncMlb(client=client) as mlb: + await mlb.get_team(133) - await client.aclose() + assert client.is_closed is False + finally: + await client.aclose() - run_async(scenario()) + asyncio.run(scenario()) -def test_async_mlb_cleanup_is_idempotent(): - """Repeated cleanup is safe, however the caller mixes the two forms.""" - handler = _RecordingHandler(_json(TEAM_PAYLOAD)) +def test_aclose_is_idempotent(): + """Closing more than once, however the caller mixes the forms, is safe.""" async def scenario(): - mlb = _owned_client(handler) - - async with mlb: - await mlb.get_team(133) - - # Already closed by __aexit__; neither of these may raise. - await mlb.aclose() - await mlb.aclose() + async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: + async with mlb: + await mlb.get_team(133) - async with mlb: - pass + await mlb.aclose() + await mlb.aclose() - run_async(scenario()) + asyncio.run(scenario()) # --------------------------------------------------------------------------- -# Endpoints: request construction and parsed results +# Endpoints # --------------------------------------------------------------------------- def test_get_team_requests_the_team_endpoint_and_parses_the_result(): - handler = _RecordingHandler(_json(TEAM_PAYLOAD)) - - async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_team(133, season="2022") - - team = run_async(scenario()) - - assert handler.call_count == 1 - assert handler.requests[0].url.path == "/api/v1/teams/133" - assert handler.params_for("teams/133") == {"season": "2022"} - assert team == Team(id=133, link="/api/v1/teams/133", name="Athletics") - - -def test_get_team_returns_none_for_an_unknown_team(): - """A 404 is the adapter's empty result, which the client turns into None.""" - handler = _RecordingHandler(httpx.Response(404, json={})) + handler = _Handler(_json(TEAM_PAYLOAD)) async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_team(1) - - assert run_async(scenario()) is None + async with async_mlb(handler) as mlb: + return await mlb.get_team(133, season="2022") + team = asyncio.run(scenario()) -def test_get_team_returns_none_for_an_empty_payload(): - """An empty 200 body has no team to parse, so there is no Team to return.""" - handler = _RecordingHandler(_json({"teams": []})) + assert_matches_sync(handler.request, "get_team", 133, season="2022") + assert team == EXPECTED_TEAM - async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_team(133) - assert run_async(scenario()) is None - - -def test_get_teams_sends_sport_id_and_parses_every_team(): - """get_teams defaults to sportId=1 and promotes sport_id into the query.""" - handler = _RecordingHandler(_json(TEAMS_PAYLOAD)) +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_team_returns_none_when_there_is_no_team(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_teams() - - teams = run_async(scenario()) + async with async_mlb(handler) as mlb: + return await mlb.get_team(1) - assert handler.params_for("teams") == {"sportId": "1"} - assert teams == [ - Team(id=133, link="/api/v1/teams/133", name="Athletics"), - Team(id=134, link="/api/v1/teams/134", name="Team 134"), - ] + assert asyncio.run(scenario()) is None -def test_get_teams_passes_an_explicit_sport_id_and_extra_params(): - handler = _RecordingHandler(_json({"teams": []})) +def test_get_person_requests_the_person_endpoint_and_parses_the_result(): + handler = _Handler(_json(PERSON_PAYLOAD)) async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_teams(11, season="2021") + async with async_mlb(handler) as mlb: + return await mlb.get_person(660271, hydrate="currentTeam") - teams = run_async(scenario()) + person = asyncio.run(scenario()) - assert handler.params_for("teams") == {"sportId": "11", "season": "2021"} - assert teams == [] + assert_matches_sync(handler.request, "get_person", 660271, hydrate="currentTeam") + assert person == EXPECTED_PERSON -def test_get_teams_returns_empty_list_for_an_unknown_sport(): - handler = _RecordingHandler(httpx.Response(404, json={})) +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_person_returns_none_when_there_is_no_person(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_teams(999) + async with async_mlb(handler) as mlb: + return await mlb.get_person(1) - assert run_async(scenario()) == [] + assert asyncio.run(scenario()) is None -def test_get_person_requests_the_people_endpoint_and_parses_the_result(): - handler = _RecordingHandler(_json(PERSON_PAYLOAD)) +def test_get_schedule_requests_the_schedule_endpoint_and_parses_the_result(): + """A date range with a team is representative of the schedule params.""" + handler = _Handler(_json(SCHEDULE_PAYLOAD)) async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_person(660271, hydrate="currentTeam") + async with async_mlb(handler) as mlb: + return await mlb.get_schedule( + start_date="2021-08-01", end_date="2021-08-11", team_id=133 + ) - person = run_async(scenario()) + schedule = asyncio.run(scenario()) - assert handler.call_count == 1 - assert handler.requests[0].url.path == "/api/v1/people/660271" - assert handler.params_for("people/660271") == {"hydrate": "currentTeam"} - assert person == Person( - id=660271, link="/api/v1/people/660271", full_name="Shohei Ohtani" + assert_matches_sync( + handler.request, + "get_schedule", + start_date="2021-08-01", + end_date="2021-08-11", + team_id=133, ) - - -def test_get_person_returns_none_for_an_unknown_person(): - handler = _RecordingHandler(httpx.Response(404, json={})) - - async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_person(1) - - assert run_async(scenario()) is None - - -def test_get_person_returns_none_for_an_empty_payload(): - handler = _RecordingHandler(_json({"people": []})) - - async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_person(660271) - - assert run_async(scenario()) is None - - -def test_get_people_requests_the_sport_players_endpoint(): - """get_people reads sports/{sport_id}/players, exactly like Mlb.get_people. - - The sport id goes in the path, not the query, which is what separates this - endpoint from get_persons' ``people?personIds=`` form. - """ - handler = _RecordingHandler(_json(PEOPLE_PAYLOAD)) - - async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_people() - - people = run_async(scenario()) - - assert handler.requests[0].url.path == "/api/v1/sports/1/players" - assert handler.params_for("sports/1/players") == {} - assert people == [ - Person(id=660271, link="/api/v1/people/660271", full_name="Shohei Ohtani"), - Person(id=605151, link="/api/v1/people/605151", full_name="Person 605151"), - ] - - -def test_get_people_passes_an_explicit_sport_id_and_extra_params(): - handler = _RecordingHandler(_json({"people": []})) - - async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_people(11, season="2021") - - people = run_async(scenario()) - - assert handler.requests[0].url.path == "/api/v1/sports/11/players" - assert handler.params_for("sports/11/players") == {"season": "2021"} - assert people == [] - - -def test_get_people_returns_empty_list_for_an_unknown_sport(): - handler = _RecordingHandler(httpx.Response(404, json={})) - - async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_people(999) - - assert run_async(scenario()) == [] - - -def test_get_schedule_sends_date_and_sport_id_and_parses_the_result(): - handler = _RecordingHandler(_json(SCHEDULE_PAYLOAD)) - - async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_schedule(date="2022-10-07") - - schedule = run_async(scenario()) - - assert handler.requests[0].url.path == "/api/v1/schedule" - assert handler.params_for("schedule") == {"date": "2022-10-07", "sportId": "1"} - assert isinstance(schedule, Schedule) assert schedule == Schedule(**SCHEDULE_PAYLOAD) -def test_get_schedule_sends_a_date_range_and_team_id(): - handler = _RecordingHandler(_json(SCHEDULE_PAYLOAD)) - - async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_schedule( - start_date="2021-08-01", - end_date="2021-08-11", - team_id=133, - ) - - run_async(scenario()) - - assert handler.params_for("schedule") == { - "startDate": "2021-08-01", - "endDate": "2021-08-11", - "teamId": "133", - "sportId": "1", - } - - -def test_get_schedule_allows_game_pks_without_a_date(): - """gamePks is the one way to ask for a schedule with no date at all.""" - handler = _RecordingHandler(_json(SCHEDULE_PAYLOAD)) +def test_get_schedule_without_a_selector_returns_none_without_requesting(): + """No date and no gamePks is unanswerable, so nothing is sent.""" + handler = _Handler(_json(SCHEDULE_PAYLOAD)) async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_schedule(gamePks=531493) - - run_async(scenario()) + async with async_mlb(handler) as mlb: + return await mlb.get_schedule() - assert handler.params_for("schedule") == {"gamePks": "531493", "sportId": "1"} + assert asyncio.run(scenario()) is None + assert handler.requests == [] -def test_get_schedule_without_dates_or_game_pks_makes_no_request(): - """An unanswerable schedule request returns None without touching the API.""" - handler = _RecordingHandler(_json(SCHEDULE_PAYLOAD)) +def test_get_teams_request_matches_the_sync_client(): + """get_teams promotes sport_id into sportId exactly as Mlb.get_teams does.""" + handler = _Handler(_json({"teams": []})) async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_schedule() + async with async_mlb(handler) as mlb: + return await mlb.get_teams(11, season="2021") - assert run_async(scenario()) is None - assert handler.call_count == 0 + assert asyncio.run(scenario()) == [] + assert_matches_sync(handler.request, "get_teams", 11, season="2021") -def test_get_schedule_returns_none_for_an_unknown_schedule(): - handler = _RecordingHandler(httpx.Response(404, json={})) +def test_get_people_request_matches_the_sync_client(): + """get_people reads sports/{sport_id}/players, like Mlb.get_people. - async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_schedule(date="2022-10-07") - - assert run_async(scenario()) is None - - -def test_get_schedule_returns_none_when_no_games_are_scheduled(): - """An empty ``dates`` list is a valid 200 that parses to no Schedule.""" - handler = _RecordingHandler( - _json( - { - "totalItems": 0, - "totalEvents": 0, - "totalGames": 0, - "totalGamesInProgress": 0, - "dates": [], - } - ) - ) + The sport id belongs in the path, not the query; sending it as personIds + against ``people`` would be the get_persons endpoint instead. + """ + handler = _Handler(_json({"people": []})) async def scenario(): - mlb = _owned_client(handler) - return await mlb.get_schedule(date="2022-12-25") + async with async_mlb(handler) as mlb: + return await mlb.get_people(11, season="2021") - assert run_async(scenario()) is None + assert asyncio.run(scenario()) == [] + assert_matches_sync(handler.request, "get_people", 11, season="2021") # --------------------------------------------------------------------------- -# Parity with the synchronous client +# Parity and concurrency # --------------------------------------------------------------------------- -SYNC_PARITY_CASES = [ - ("get_team", (133,), {}), - ("get_team", (133,), {"season": "2022"}), - ("get_teams", (), {}), - ("get_teams", (11,), {"season": "2021"}), - ("get_person", (660271,), {}), - ("get_people", (), {}), - ("get_people", (11,), {"season": "2021"}), - ("get_schedule", (), {"date": "2022-10-07"}), - ("get_schedule", (), {"start_date": "2021-08-01", "end_date": "2021-08-11"}), - ("get_schedule", (), {"team_id": 133, "date": "2022-10-07"}), - ("get_schedule", (), {"gamePks": 531493}), - ("get_schedule", (), {}), -] - - -@pytest.mark.parametrize("method, args, kwargs", SYNC_PARITY_CASES) -def test_request_construction_matches_sync_client(method, args, kwargs): - """AsyncMlb asks the adapter for exactly what Mlb asks for. - - The async surface is a port of the sync one, so a drift in endpoint, - parameter name, or default belongs in this test rather than in a live - failure. Both adapters are stubbed, so nothing here reaches the network. - """ - from unittest.mock import MagicMock - - from mlbstatsapi import Mlb - from mlbstatsapi.mlb_dataadapter import MlbResult - - empty = MlbResult(status_code=200, message=None, data={}) - - sync_mlb = Mlb() - sync_mlb._mlb_adapter_v1.get = MagicMock(return_value=empty) - sync_result = getattr(sync_mlb, method)(*args, **kwargs) - - async def scenario(): - async_mlb = AsyncMlb() - async_mlb._mlb_adapter_v1.get = AsyncMock(return_value=empty) - result = await getattr(async_mlb, method)(*args, **kwargs) - return result, async_mlb._mlb_adapter_v1.get.call_args - - async_result, async_call = run_async(scenario()) - - assert async_call == sync_mlb._mlb_adapter_v1.get.call_args - assert async_result == sync_result - - -def test_signatures_match_the_sync_client(): - """Names, kinds, and defaults are identical to the sync client's.""" +def test_public_signatures_match_the_sync_client(): + """Argument names, kinds, and defaults must not drift from Mlb's.""" import inspect - from mlbstatsapi import Mlb - for name in ("get_team", "get_teams", "get_person", "get_people", "get_schedule"): sync_params = inspect.signature(getattr(Mlb, name)).parameters async_params = inspect.signature(getattr(AsyncMlb, name)).parameters - assert [ - (p.name, p.kind, p.default) for p in sync_params.values() - ] == [ + assert [(p.name, p.kind, p.default) for p in sync_params.values()] == [ (p.name, p.kind, p.default) for p in async_params.values() - ], f"{name} drifted from Mlb.{name}" + ], f"AsyncMlb.{name} drifted from Mlb.{name}" -# --------------------------------------------------------------------------- -# Concurrency and the absence of hidden work -# --------------------------------------------------------------------------- - - -def test_concurrent_endpoint_calls_share_one_client_without_crossing_results(): - """Two endpoints on one client keep their own request and their own result. - - Sharing an AsyncClient is the reason AsyncMlb exists; a client that mixed - up two in-flight responses would be worse than useless. - """ - handler = _RecordingHandler( +def test_concurrent_calls_on_one_client_do_not_cross_results(): + """Sharing one client is the point of AsyncMlb; results must stay distinct.""" + handler = _Handler( { "teams/133": _json(TEAM_PAYLOAD), "people/660271": _json(PERSON_PAYLOAD), - "schedule": _json(SCHEDULE_PAYLOAD), } ) async def scenario(): - mlb = _owned_client(handler) - return await asyncio.gather( - mlb.get_team(133), - mlb.get_person(660271), - mlb.get_schedule(date="2022-10-07"), - ) - - team, person, schedule = run_async(scenario()) - - assert team == Team(id=133, link="/api/v1/teams/133", name="Athletics") - assert person == Person( - id=660271, link="/api/v1/people/660271", full_name="Shohei Ohtani" - ) - assert schedule == Schedule(**SCHEDULE_PAYLOAD) - assert handler.call_count == 3 - - -def test_one_endpoint_call_does_not_block_another_on_the_same_client(): - """A slow endpoint must not serialize the rest of the client. - - Without real concurrency the fast call could not finish while the slow one - is still waiting, so the gate would never open and the test would hit its - timeout instead of passing. - """ - fast_call_completed = asyncio.Event() - - async def handler(request: httpx.Request) -> httpx.Response: - if request.url.path.endswith("teams/133"): - await asyncio.wait_for( - fast_call_completed.wait(), - timeout=BLOCKED_REQUEST_TIMEOUT, - ) - return _json(TEAM_PAYLOAD) - return _json(PERSON_PAYLOAD) - - async def scenario(): - mlb = _owned_client(handler) - - slow = asyncio.ensure_future(mlb.get_team(133)) - await asyncio.sleep(0) - - person = await mlb.get_person(660271) - fast_call_completed.set() - - return await slow, person - - team, person = run_async(scenario()) - - assert team == Team(id=133, link="/api/v1/teams/133", name="Athletics") - assert person == Person( - id=660271, link="/api/v1/people/660271", full_name="Shohei Ohtani" - ) - - -def test_an_endpoint_call_issues_exactly_one_request(): - """No hidden fan-out: one call to one endpoint is one HTTP request. - - A successful call must not prefetch, hydrate, or otherwise widen itself - into extra traffic behind the caller's back. - """ - handler = _RecordingHandler(_json(TEAMS_PAYLOAD)) - - async def scenario(): - mlb = _owned_client(handler) - await mlb.get_teams() - - run_async(scenario()) - - assert handler.call_count == 1 - assert [request.url.path for request in handler.requests] == ["/api/v1/teams"] - - -def test_endpoint_calls_leave_no_background_tasks_behind(): - """No hidden background work: nothing outlives the awaited call. - - A stray task would keep running after the client is closed and surface as - an unpredictable warning or error somewhere else entirely. - """ - handler = _RecordingHandler( - { - "teams": _json(TEAMS_PAYLOAD), - "teams/133": _json(TEAM_PAYLOAD), - "people/660271": _json(PERSON_PAYLOAD), - "sports/1/players": _json(PEOPLE_PAYLOAD), - "schedule": _json(SCHEDULE_PAYLOAD), - } - ) - - async def scenario(): - mlb = _owned_client(handler) - - before = asyncio.all_tasks() - - # Every endpoint on the client, so none of them may leak a task. - await mlb.get_team(133) - await mlb.get_teams() - await mlb.get_person(660271) - await mlb.get_people() - await mlb.get_schedule(date="2022-10-07") - await mlb.aclose() - - # Let anything that was scheduled get a chance to appear. - await asyncio.sleep(0) - - assert asyncio.all_tasks() - before == set() - - run_async(scenario()) - - -def test_construction_starts_no_work(): - """Building a client is inert: no request, no task, until an endpoint is called.""" - handler = _RecordingHandler(_json(TEAM_PAYLOAD)) - - async def scenario(): - before = asyncio.all_tasks() - - _owned_client(handler) - - await asyncio.sleep(0) + async with async_mlb(handler) as mlb: + return await asyncio.gather(mlb.get_team(133), mlb.get_person(660271)) - assert handler.call_count == 0 - assert asyncio.all_tasks() - before == set() + team, person = asyncio.run(scenario()) - run_async(scenario()) + assert team == EXPECTED_TEAM + assert person == EXPECTED_PERSON From 28029ab75ec5832c565855bf20a351bf00bb36cf Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Fri, 21 Aug 2026 04:29:01 -0700 Subject: [PATCH 31/81] refactor(async): move schedule params to helper --- mlbstatsapi/_helpers/__init__.py | 0 mlbstatsapi/_helpers/schedule.py | 22 ++++++++++++++++++++++ mlbstatsapi/_parsers/schedules.py | 23 ----------------------- mlbstatsapi/async_mlb.py | 3 ++- 4 files changed, 24 insertions(+), 24 deletions(-) create mode 100644 mlbstatsapi/_helpers/__init__.py create mode 100644 mlbstatsapi/_helpers/schedule.py diff --git a/mlbstatsapi/_helpers/__init__.py b/mlbstatsapi/_helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mlbstatsapi/_helpers/schedule.py b/mlbstatsapi/_helpers/schedule.py new file mode 100644 index 00000000..0d6b4a5b --- /dev/null +++ b/mlbstatsapi/_helpers/schedule.py @@ -0,0 +1,22 @@ +def build_schedule_params( + date: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + sport_id: int = 1, + team_id: int | None = None, + **params, +) -> dict | None: + if start_date and end_date: + params["startDate"] = start_date + params["endDate"] = end_date + elif date and not (start_date or end_date): + params["date"] = date + elif "gamePks" not in params: + return None + + if team_id: + params["teamId"] = team_id + + params["sportId"] = sport_id + + return params diff --git a/mlbstatsapi/_parsers/schedules.py b/mlbstatsapi/_parsers/schedules.py index 1126df21..39fc0d6b 100644 --- a/mlbstatsapi/_parsers/schedules.py +++ b/mlbstatsapi/_parsers/schedules.py @@ -7,26 +7,3 @@ def parse_schedule(data: dict) -> Schedule | None: return None return Schedule(**data) - -def build_schedule_params( - date: str | None = None, - start_date: str | None = None, - end_date: str | None = None, - sport_id: int = 1, - team_id: int | None = None, - **params, -) -> dict | None: - if start_date and end_date: - params["startDate"] = start_date - params["endDate"] = end_date - elif date and not (start_date or end_date): - params["date"] = date - elif "gamePks" not in params: - return None - - if team_id: - params["teamId"] = team_id - - params["sportId"] = sport_id - - return params diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index 080e4393..12cac734 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -5,8 +5,9 @@ import logging from typing import TYPE_CHECKING +from ._helpers.schedule import build_schedule_params from ._parsers.people import parse_person, parse_people -from ._parsers.schedules import parse_schedule, build_schedule_params +from ._parsers.schedules import parse_schedule from ._parsers.teams import parse_team, parse_teams from .async_mlb_dataadapter import AsyncMlbDataAdapter from .mlb_dataadapter import DEFAULT_TIMEOUT, TimeoutType From 90eceb5ef568e9f31f25ed285bdcc48b150e1531 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Fri, 21 Aug 2026 07:55:34 -0700 Subject: [PATCH 32/81] test: freeze AsyncMlb public API contract --- docs/public-api.md | 87 +++++++++++++++++++++++++++++++------- tests/test_public_api.py | 90 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 157 insertions(+), 20 deletions(-) diff --git a/docs/public-api.md b/docs/public-api.md index 923fe084..0e44a0dc 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -4,9 +4,10 @@ This document is the authoritative public API contract for the `python-mlb-statsapi` **1.x** series. It defines which package-root symbols, constructor signatures, exception and -warning relationships, Session ownership rules, and `Mlb` endpoint methods are -supported after version 1.0. Maintainers should use this document when deciding -whether a change is a patch, a minor release, or a major release. +warning relationships, resource ownership rules, and `Mlb` and `AsyncMlb` +endpoint methods are supported after version 1.0. Maintainers should use this +document when deciding whether a change is a patch, a minor release, or a major +release. This package is an unofficial wrapper for the MLB Stats API and is not affiliated with Major League Baseball. @@ -78,9 +79,10 @@ from mlbstatsapi import ( ) ``` -The symbols above are available in every install. `AsyncMlbDataAdapter` is -equally public, but it resolves only when the optional `async` extra is -installed; see [Optional async support](#optional-async-support). +The symbols above are available in every install. `AsyncMlb` and +`AsyncMlbDataAdapter` are equally public, but they resolve only when the +optional `async` extra is installed; see +[Optional async support](#optional-async-support). ### Classification of package-root symbols @@ -91,6 +93,7 @@ resolving it needs an optional dependency. | Symbol | Status | Availability | | --- | --- | --- | | `Mlb` | Public and stable in 1.x | Always available | +| `AsyncMlb` | Public and stable in 1.x | Requires the optional `async` extra | | `MlbDataAdapter` | Public and stable in 1.x | Always available | | `AsyncMlbDataAdapter` | Public and stable in 1.x | Requires the optional `async` extra | | `MlbResult` | Public and stable in 1.x | Always available | @@ -104,10 +107,11 @@ resolving it needs an optional dependency. | `return_splits` | Public legacy helper, stable in 1.x but not preferred for new code | Always available | | `get_stat_attributes` | Public legacy helper, stable in 1.x but not preferred for new code | Always available | -`AsyncMlbDataAdapter` is supported 1.x API on the same terms as the synchronous -symbols: it will not be removed or renamed during the series, and its documented -behavior stays compatible. Only its availability is conditional, because its -HTTP dependency ships with the `async` extra. See +`AsyncMlb` and `AsyncMlbDataAdapter` are supported 1.x API on the same terms as +the synchronous symbols: they will not be removed or renamed during the +series, and their documented behavior stays compatible. Only their +availability is conditional, because their HTTP dependency ships with the +`async` extra. See [Optional async support](#optional-async-support). No package-root symbol is marked deprecated in version 1.0. Deprecation requires @@ -150,9 +154,9 @@ accidental submodule names (for example, a documented deprecation period). ## Optional async support -`AsyncMlbDataAdapter` is a public package-root symbol, like `MlbDataAdapter`, -and appears in the classification table above. Its HTTP dependency is optional -and installed with the `async` extra: +`AsyncMlb` and `AsyncMlbDataAdapter` are public package-root symbols, like +`Mlb` and `MlbDataAdapter`, and appear in the classification table above. Their +HTTP dependency is optional and installed with the `async` extra: ```bash pip install "python-mlb-statsapi[async]" @@ -161,7 +165,7 @@ pip install "python-mlb-statsapi[async]" With the extra installed: ```python -from mlbstatsapi import AsyncMlbDataAdapter +from mlbstatsapi import AsyncMlb, AsyncMlbDataAdapter ``` Async symbols are resolved on first access, so the optional dependency is not @@ -228,6 +232,61 @@ Session. Most endpoint methods use `v1`. `get_game` uses the `v1.1` live feed endpoint. Standalone `MlbDataAdapter(ver="v1")` and `MlbDataAdapter(ver="v1.1")` remain supported. +## AsyncMlb public client + +`AsyncMlb` is the public asynchronous client and requires the optional `async` +extra. + +### Constructor + +```text +AsyncMlb( + hostname="statsapi.mlb.com", + logger=None, + timeout=(3.05, 30.0), + client=None, + *, + strict_http=True, +) +``` + +Parameter order and default values above are part of the API. +`strict_http` is keyword-only. + +### Lifecycle + +* `async with AsyncMlb(...) as mlb` returns the `AsyncMlb` instance itself +* `AsyncMlb.__aexit__` awaits cleanup +* Explicit cleanup with `await mlb.aclose()` is supported +* Repeated `aclose()` calls are safe +* Library-owned async clients are closed +* Caller-injected async clients remain caller-owned and open + +### Concurrency + +One `AsyncMlb` instance supports concurrent in-flight requests on the same +event loop. Concurrency is caller-controlled. Cross-event-loop use is not +promised. + +### Endpoint methods + +The currently supported awaitable endpoint methods are: + +```text +get_team(team_id: int, **params) +get_teams(sport_id: int = 1, **params) +get_person(player_id: int, **params) +get_people(sport_id: int = 1, **params) +get_schedule( + date: str = None, + start_date: str = None, + end_date: str = None, + sport_id: int = 1, + team_id: int = None, + **params, +) +``` + ## Low-level adapter `MlbDataAdapter` is the public low-level HTTP adapter. diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 2575c3a9..b76164dc 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -2,7 +2,8 @@ These tests freeze the supported package-root symbols, constructor signatures, exception and warning inheritance, Session ownership guarantees, and the -explicit ``Mlb`` public-method manifest documented in ``docs/public-api.md``. +explicit ``Mlb`` and ``AsyncMlb`` public-method manifests documented in +``docs/public-api.md``. The package-root surface is split across two manifests because "public API" and "available without optional dependencies" are different questions. Everything in @@ -75,6 +76,7 @@ # ``async`` extra (HTTPX). These are public and stable exactly like the symbols # above; only their availability is conditional. See docs/public-api.md. OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS: tuple[str, ...] = ( + "AsyncMlb", "AsyncMlbDataAdapter", ) @@ -131,7 +133,11 @@ def _async_extra_installed() -> bool: def _normalize_annotation(annotation: Any) -> str: - rendered = inspect.formatannotation(annotation) + rendered = ( + annotation + if isinstance(annotation, str) + else inspect.formatannotation(annotation) + ) for legacy, pep604 in LEGACY_UNION_RENDERINGS.items(): rendered = rendered.replace(legacy, pep604) return rendered @@ -224,6 +230,22 @@ def _normalize_signature(fn: Any) -> str: "get_stats": "(stats: list, groups: list, **params: dict)", } +# Explicit inventory of public methods defined directly on AsyncMlb. +# Only currently supported async endpoints belong here. +ASYNC_MLB_PUBLIC_METHOD_MANIFEST: dict[str, str] = { + "aclose": "()", + "__aenter__": "()", + "__aexit__": "(exc_type, exc, traceback)", + "get_team": "(team_id: int, **params)", + "get_teams": "(sport_id: int=1, **params)", + "get_person": "(player_id: int, **params)", + "get_people": "(sport_id: int=1, **params)", + "get_schedule": ( + "(date: str=None, start_date: str=None, end_date: str=None, " + "sport_id: int=1, team_id: int=None, **params)" + ), +} + # --------------------------------------------------------------------------- # Package-root symbols @@ -254,10 +276,11 @@ def test_supported_package_root_api_is_the_union_of_both_manifests() -> None: assert len(SUPPORTED_PACKAGE_ROOT_API) == len(set(SUPPORTED_PACKAGE_ROOT_API)) -def test_async_data_adapter_is_part_of_the_supported_api() -> None: - """The async adapter is supported 1.x API, not merely an optional add-on.""" - assert "AsyncMlbDataAdapter" in OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS - assert "AsyncMlbDataAdapter" in SUPPORTED_PACKAGE_ROOT_API +def test_async_symbols_are_part_of_the_supported_api() -> None: + """Async symbols are supported 1.x API, not merely optional add-ons.""" + for name in ("AsyncMlb", "AsyncMlbDataAdapter"): + assert name in OPTIONAL_ASYNC_PACKAGE_ROOT_SYMBOLS + assert name in SUPPORTED_PACKAGE_ROOT_API def test_supported_package_root_symbols_are_importable_from_package() -> None: @@ -396,6 +419,26 @@ def test_mlb_constructor_parameter_order_and_defaults() -> None: assert parameters["strict_http"].kind is inspect.Parameter.KEYWORD_ONLY +@requires_async_extra +def test_async_mlb_constructor_parameter_order_and_defaults() -> None: + async_mlb = mlbstatsapi.AsyncMlb + parameters = inspect.signature(async_mlb.__init__).parameters + + assert _parameter_names(async_mlb.__init__) == [ + "hostname", + "logger", + "timeout", + "client", + "strict_http", + ] + assert parameters["hostname"].default == "statsapi.mlb.com" + assert parameters["logger"].default is None + assert parameters["timeout"].default == (3.05, 30.0) + assert parameters["client"].default is None + assert parameters["strict_http"].default is True + assert parameters["strict_http"].kind is inspect.Parameter.KEYWORD_ONLY + + def test_mlb_data_adapter_constructor_parameter_order_and_defaults() -> None: parameters = inspect.signature(MlbDataAdapter.__init__).parameters @@ -483,6 +526,41 @@ def test_mlb_public_endpoint_count() -> None: assert len(MLB_PUBLIC_METHOD_MANIFEST) == 43 +# --------------------------------------------------------------------------- +# AsyncMlb public method manifest +# --------------------------------------------------------------------------- + + +def test_async_mlb_public_method_manifest_has_unique_names() -> None: + assert len(ASYNC_MLB_PUBLIC_METHOD_MANIFEST) == len( + set(ASYNC_MLB_PUBLIC_METHOD_MANIFEST) + ) + + +@requires_async_extra +def test_async_mlb_public_method_manifest_matches_class_dict() -> None: + async_mlb = mlbstatsapi.AsyncMlb + discovered = { + name + for name, obj in async_mlb.__dict__.items() + if inspect.isfunction(obj) + and (not name.startswith("_") or name in ("__aenter__", "__aexit__")) + and name != "__init__" + } + assert discovered == set(ASYNC_MLB_PUBLIC_METHOD_MANIFEST) + + +@requires_async_extra +@pytest.mark.parametrize( + "method_name, expected", ASYNC_MLB_PUBLIC_METHOD_MANIFEST.items() +) +def test_async_mlb_public_method_signature(method_name: str, expected: str) -> None: + method = getattr(mlbstatsapi.AsyncMlb, method_name) + assert inspect.iscoroutinefunction(method), method_name + actual = _normalize_signature(method) + assert actual == expected, f"{method_name}: {actual} != {expected}" + + # --------------------------------------------------------------------------- # Exception and warning inheritance # --------------------------------------------------------------------------- From 9bfcfe842ba68d0cc5dc1ccb0225e2065d5ac037 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Fri, 21 Aug 2026 08:02:14 -0700 Subject: [PATCH 33/81] test: remove live AsyncMlb tests from issue 303 --- .../async_mlb/test_ext_async_mlb.py | 61 ------------------- 1 file changed, 61 deletions(-) delete mode 100644 tests/external_tests/async_mlb/test_ext_async_mlb.py diff --git a/tests/external_tests/async_mlb/test_ext_async_mlb.py b/tests/external_tests/async_mlb/test_ext_async_mlb.py deleted file mode 100644 index 9ba91612..00000000 --- a/tests/external_tests/async_mlb/test_ext_async_mlb.py +++ /dev/null @@ -1,61 +0,0 @@ -import asyncio - -from mlbstatsapi import AsyncMlb -from mlbstatsapi.models.people import Person -from mlbstatsapi.models.schedules import Schedule -from mlbstatsapi.models.teams import Team - - -def test_async_get_team(): - async def scenario(): - async with AsyncMlb() as mlb: - team = await mlb.get_team(133) - - assert isinstance(team, Team) - assert team.id == 133 - - asyncio.run(scenario()) - -def test_async_get_teams(): - async def scenario(): - async with AsyncMlb() as mlb: - teams = await mlb.get_teams() - - assert isinstance(teams, list) - assert teams - assert all(isinstance(team, Team) for team in teams) - - asyncio.run(scenario()) - - -def test_async_get_person(): - async def scenario(): - async with AsyncMlb() as mlb: - person = await mlb.get_person(664034) - - assert isinstance(person, Person) - assert person.id == 664034 - - asyncio.run(scenario()) - -def test_async_get_people(): - async def scenario(): - async with AsyncMlb() as mlb: - people = await mlb.get_people() - - assert isinstance(people, list) - assert people - assert all(isinstance(person, Person) for person in people) - - asyncio.run(scenario()) - - -def test_async_get_schedule(): - async def scenario(): - async with AsyncMlb() as mlb: - schedule = await mlb.get_schedule(date="2022-10-07") - - assert isinstance(schedule, Schedule) - assert schedule.dates - - asyncio.run(scenario()) From bbc92d8595653ee6ee1fec30a95cf1dc63c6a5c8 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Fri, 21 Aug 2026 06:00:24 -0700 Subject: [PATCH 34/81] test: add sync and async live smoke coverage --- .../async_mlb/test_async_mlb_smoke.py | 39 +++++++++++++++++++ tests/external_tests/mlb/test_mlb_smoke.py | 28 +++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 tests/external_tests/async_mlb/test_async_mlb_smoke.py create mode 100644 tests/external_tests/mlb/test_mlb_smoke.py diff --git a/tests/external_tests/async_mlb/test_async_mlb_smoke.py b/tests/external_tests/async_mlb/test_async_mlb_smoke.py new file mode 100644 index 00000000..9e39cf6a --- /dev/null +++ b/tests/external_tests/async_mlb/test_async_mlb_smoke.py @@ -0,0 +1,39 @@ +import asyncio + +from mlbstatsapi import AsyncMlb +from mlbstatsapi.models.people import Person +from mlbstatsapi.models.schedules import Schedule +from mlbstatsapi.models.teams import Team + + +def test_async_get_team(): + async def scenario(): + async with AsyncMlb() as mlb: + team = await mlb.get_team(133) + + assert isinstance(team, Team) + assert team.id == 133 + + asyncio.run(scenario()) + + +def test_async_get_person(): + async def scenario(): + async with AsyncMlb() as mlb: + person = await mlb.get_person(664034) + + assert isinstance(person, Person) + assert person.id == 664034 + + asyncio.run(scenario()) + + +def test_async_get_schedule(): + async def scenario(): + async with AsyncMlb() as mlb: + schedule = await mlb.get_schedule(date="2022-10-07") + + assert isinstance(schedule, Schedule) + assert schedule.dates + + asyncio.run(scenario()) diff --git a/tests/external_tests/mlb/test_mlb_smoke.py b/tests/external_tests/mlb/test_mlb_smoke.py new file mode 100644 index 00000000..be48da76 --- /dev/null +++ b/tests/external_tests/mlb/test_mlb_smoke.py @@ -0,0 +1,28 @@ +from mlbstatsapi import Mlb +from mlbstatsapi.models.people import Person +from mlbstatsapi.models.schedules import Schedule +from mlbstatsapi.models.teams import Team + + +def test_get_team(): + with Mlb() as mlb: + team = mlb.get_team(133) + + assert isinstance(team, Team) + assert team.id == 133 + + +def test_get_person(): + with Mlb() as mlb: + person = mlb.get_person(664034) + + assert isinstance(person, Person) + assert person.id == 664034 + + +def test_get_schedule(): + with Mlb() as mlb: + schedule = mlb.get_schedule(date="2022-10-07") + + assert isinstance(schedule, Schedule) + assert schedule.dates From b2e497109fc4dcad5996502255bc2615d4d56035 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Fri, 21 Aug 2026 11:25:59 -0700 Subject: [PATCH 35/81] ci: install async extra for external tests --- .github/workflows/external-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/external-tests.yml b/.github/workflows/external-tests.yml index cc76258a..5d458715 100644 --- a/.github/workflows/external-tests.yml +++ b/.github/workflows/external-tests.yml @@ -26,7 +26,7 @@ jobs: virtualenvs-create: true virtualenvs-in-project: true - name: Install dependencies - run: poetry install --no-interaction + run: poetry install --no-interaction -E async - name: Run external MLB API tests run: | poetry run pytest \ From 96528f300d592e51686884d6f834c8ca96a7dbf1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 19:05:38 +0000 Subject: [PATCH 36/81] test: add sync/async parity coverage for get_team and get_person Batch 1 of issue #304. Adds offline parity tests that drive the public Mlb and AsyncMlb clients over equivalent canned responses and compare only what a caller can see: - successful 2xx produces the same model type and parsed values - a successful empty response returns None on both clients - a 404 returns None on both clients Transport behavior is already covered elsewhere, so nothing here compares Requests and HTTPX internals. Later #304 batches cover get_schedule, strict non-404 4xx, compatibility mode, 5xx, timeout, transport and decode errors. Refs #304 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RYDDda51C9LGsiv2cS9Pkr --- tests/test_sync_async_parity.py | 151 ++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 tests/test_sync_async_parity.py diff --git a/tests/test_sync_async_parity.py b/tests/test_sync_async_parity.py new file mode 100644 index 00000000..532b7e3f --- /dev/null +++ b/tests/test_sync_async_parity.py @@ -0,0 +1,151 @@ +"""Sync/async behavioral parity tests (issue #304, batch 1). + +`Mlb` is the compatibility baseline. These tests prove that `AsyncMlb`'s +public endpoint behavior stays aligned with it: the same response produces the +same model type, the same parsed values, and the same "nothing to return" +answer. + +The scope is deliberately narrow. Transport behavior — retries, timeouts, +strict-mode status mapping, exception translation — is already covered by +tests/test_http_contract.py, tests/test_mlb_dataadapter.py and +tests/test_async_mlb_dataadapter.py, and payload parsing by tests/parsers/. +None of that is re-asserted here, and nothing compares Requests internals with +HTTPX internals. Each test drives both public clients over an equivalent canned +response and compares only what a caller can see. + +These tests must not contact the live MLB API. +""" + +from __future__ import annotations + +import asyncio + +import pytest +import requests +import requests_mock + +# The async client needs the optional HTTPX extra; without it there is no +# async side to compare against, so the whole module is skipped rather than +# failing a sync-only install at import time. +httpx = pytest.importorskip("httpx", reason="requires the async extra (HTTPX)") + +from mlbstatsapi import Mlb # noqa: E402 +from mlbstatsapi.async_mlb import AsyncMlb # noqa: E402 +from mlbstatsapi.models.people import Person # noqa: E402 +from mlbstatsapi.models.teams import Team # noqa: E402 + + +TEAM_PAYLOAD = {"teams": [{"id": 133, "link": "/api/v1/teams/133", "name": "Athletics"}]} +PERSON_PAYLOAD = { + "people": [{"id": 660271, "link": "/api/v1/people/660271", "fullName": "Shohei Ohtani"}] +} + +# The two ways a call legitimately comes back with nothing to parse. Both +# clients are expected to answer None for get_team and get_person. +NO_RESULT_RESPONSES = { + "empty 200": (200, {}), + "404": (404, {}), +} + + +def call_sync(method: str, *args, status: int, payload: dict, **kwargs): + """Call a method on `Mlb` against a canned response.""" + adapter = requests_mock.Adapter() + adapter.register_uri("GET", requests_mock.ANY, status_code=status, json=payload) + + session = requests.Session() + session.mount("https://", adapter) + + try: + with Mlb(session=session) as mlb: + return getattr(mlb, method)(*args, **kwargs) + finally: + session.close() + + +def call_async(method: str, *args, status: int, payload: dict, **kwargs): + """Call the matching method on `AsyncMlb` against the same canned response.""" + client = httpx.AsyncClient( + transport=httpx.MockTransport(lambda request: httpx.Response(status, json=payload)) + ) + + async def scenario(): + try: + async with AsyncMlb(client=client) as mlb: + return await getattr(mlb, method)(*args, **kwargs) + finally: + await client.aclose() + + return asyncio.run(scenario()) + + +def call_both(method: str, *args, status: int = 200, payload: dict, **kwargs): + """Return the sync and async results for one call, in that order. + + Both clients are handed an equivalent response through their own public + constructor, so a failure below names the side that drifted. + """ + return ( + call_sync(method, *args, status=status, payload=payload, **kwargs), + call_async(method, *args, status=status, payload=payload, **kwargs), + ) + + +# --------------------------------------------------------------------------- +# get_team +# --------------------------------------------------------------------------- + + +def test_get_team_success_parity(): + """A successful team response parses to the same Team on both clients.""" + sync_team, async_team = call_both("get_team", 133, payload=TEAM_PAYLOAD) + + assert isinstance(sync_team, Team), "sync get_team did not return a Team" + assert isinstance(async_team, Team), "async get_team did not return a Team" + + expected = (133, "/api/v1/teams/133", "Athletics") + assert (sync_team.id, sync_team.link, sync_team.name) == expected + assert (async_team.id, async_team.link, async_team.name) == expected + assert async_team == sync_team + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_team_no_result_parity(label): + """An empty success and a 404 both return None on either client.""" + status, payload = NO_RESULT_RESPONSES[label] + + sync_team, async_team = call_both("get_team", 133, status=status, payload=payload) + + assert sync_team is None, f"sync get_team returned {sync_team!r} for {label}" + assert async_team is None, f"async get_team returned {async_team!r} for {label}" + + +# --------------------------------------------------------------------------- +# get_person +# --------------------------------------------------------------------------- + + +def test_get_person_success_parity(): + """A successful person response parses to the same Person on both clients.""" + sync_person, async_person = call_both("get_person", 660271, payload=PERSON_PAYLOAD) + + assert isinstance(sync_person, Person), "sync get_person did not return a Person" + assert isinstance(async_person, Person), "async get_person did not return a Person" + + expected = (660271, "/api/v1/people/660271", "Shohei Ohtani") + assert (sync_person.id, sync_person.link, sync_person.full_name) == expected + assert (async_person.id, async_person.link, async_person.full_name) == expected + assert async_person == sync_person + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_person_no_result_parity(label): + """An empty success and a 404 both return None on either client.""" + status, payload = NO_RESULT_RESPONSES[label] + + sync_person, async_person = call_both( + "get_person", 660271, status=status, payload=payload + ) + + assert sync_person is None, f"sync get_person returned {sync_person!r} for {label}" + assert async_person is None, f"async get_person returned {async_person!r} for {label}" From c4a354701ebea2269fcd59992dabccc7aaac9310 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Fri, 21 Aug 2026 18:44:27 -0700 Subject: [PATCH 37/81] test: add get_schedule sync async parity coverage --- tests/test_sync_async_parity.py | 199 ++++++++++++++++++++++++++++++-- 1 file changed, 188 insertions(+), 11 deletions(-) diff --git a/tests/test_sync_async_parity.py b/tests/test_sync_async_parity.py index 532b7e3f..c306078b 100644 --- a/tests/test_sync_async_parity.py +++ b/tests/test_sync_async_parity.py @@ -1,4 +1,4 @@ -"""Sync/async behavioral parity tests (issue #304, batch 1). +"""Sync/async behavioral parity tests (issue #304, batches 1-2). `Mlb` is the compatibility baseline. These tests prove that `AsyncMlb`'s public endpoint behavior stays aligned with it: the same response produces the @@ -19,6 +19,7 @@ from __future__ import annotations import asyncio +from urllib.parse import parse_qsl, urlsplit import pytest import requests @@ -29,9 +30,9 @@ # failing a sync-only install at import time. httpx = pytest.importorskip("httpx", reason="requires the async extra (HTTPX)") -from mlbstatsapi import Mlb # noqa: E402 -from mlbstatsapi.async_mlb import AsyncMlb # noqa: E402 +from mlbstatsapi import AsyncMlb, Mlb # noqa: E402 from mlbstatsapi.models.people import Person # noqa: E402 +from mlbstatsapi.models.schedules import Schedule # noqa: E402 from mlbstatsapi.models.teams import Team # noqa: E402 @@ -39,6 +40,22 @@ PERSON_PAYLOAD = { "people": [{"id": 660271, "link": "/api/v1/people/660271", "fullName": "Shohei Ohtani"}] } +SCHEDULE_PAYLOAD = { + "totalItems": 1, + "totalEvents": 0, + "totalGames": 1, + "totalGamesInProgress": 0, + "dates": [ + { + "date": "2022-10-07", + "totalItems": 1, + "totalEvents": 0, + "totalGames": 1, + "totalGamesInProgress": 0, + "games": [], + } + ], +} # The two ways a call legitimately comes back with nothing to parse. Both # clients are expected to answer None for get_team and get_person. @@ -46,9 +63,35 @@ "empty 200": (200, {}), "404": (404, {}), } +SCHEDULE_NO_RESULT_RESPONSES = { + "empty 200": ( + 200, + { + "totalItems": 0, + "totalEvents": 0, + "totalGames": 0, + "totalGamesInProgress": 0, + "dates": [], + }, + ), + "404": (404, {}), +} + + +def request_signature(method: str, url: str) -> tuple[str, str, dict[str, str]]: + """Normalize one observed request for transport-independent comparison.""" + parsed_url = urlsplit(url) + return method, parsed_url.path, dict(parse_qsl(parsed_url.query)) -def call_sync(method: str, *args, status: int, payload: dict, **kwargs): +def call_sync( + method: str, + *args, + status: int, + payload: dict, + request_signatures: list[tuple[str, str, dict[str, str]]] | None = None, + **kwargs, +): """Call a method on `Mlb` against a canned response.""" adapter = requests_mock.Adapter() adapter.register_uri("GET", requests_mock.ANY, status_code=status, json=payload) @@ -58,15 +101,35 @@ def call_sync(method: str, *args, status: int, payload: dict, **kwargs): try: with Mlb(session=session) as mlb: - return getattr(mlb, method)(*args, **kwargs) + result = getattr(mlb, method)(*args, **kwargs) + + if request_signatures is not None: + assert len(adapter.request_history) == 1 + request = adapter.request_history[0] + request_signatures.append(request_signature(request.method, request.url)) + + return result finally: session.close() -def call_async(method: str, *args, status: int, payload: dict, **kwargs): +def call_async( + method: str, + *args, + status: int, + payload: dict, + request_signatures: list[tuple[str, str, dict[str, str]]] | None = None, + **kwargs, +): """Call the matching method on `AsyncMlb` against the same canned response.""" + requests_seen = [] + + def handler(request): + requests_seen.append(request) + return httpx.Response(status, json=payload) + client = httpx.AsyncClient( - transport=httpx.MockTransport(lambda request: httpx.Response(status, json=payload)) + transport=httpx.MockTransport(handler) ) async def scenario(): @@ -76,18 +139,46 @@ async def scenario(): finally: await client.aclose() - return asyncio.run(scenario()) + result = asyncio.run(scenario()) + if request_signatures is not None: + assert len(requests_seen) == 1 + request = requests_seen[0] + request_signatures.append(request_signature(request.method, str(request.url))) -def call_both(method: str, *args, status: int = 200, payload: dict, **kwargs): + return result + + +def call_both( + method: str, + *args, + status: int = 200, + payload: dict, + request_signatures: list[tuple[str, str, dict[str, str]]] | None = None, + **kwargs, +): """Return the sync and async results for one call, in that order. Both clients are handed an equivalent response through their own public constructor, so a failure below names the side that drifted. """ return ( - call_sync(method, *args, status=status, payload=payload, **kwargs), - call_async(method, *args, status=status, payload=payload, **kwargs), + call_sync( + method, + *args, + status=status, + payload=payload, + request_signatures=request_signatures, + **kwargs, + ), + call_async( + method, + *args, + status=status, + payload=payload, + request_signatures=request_signatures, + **kwargs, + ), ) @@ -149,3 +240,89 @@ def test_get_person_no_result_parity(label): assert sync_person is None, f"sync get_person returned {sync_person!r} for {label}" assert async_person is None, f"async get_person returned {async_person!r} for {label}" + + +# --------------------------------------------------------------------------- +# get_schedule +# --------------------------------------------------------------------------- + + +def test_get_schedule_success_parity(): + """A successful date schedule parses identically and sends the same request.""" + requests_seen = [] + + sync_schedule, async_schedule = call_both( + "get_schedule", + date="2022-10-07", + payload=SCHEDULE_PAYLOAD, + request_signatures=requests_seen, + ) + + assert isinstance(sync_schedule, Schedule), "sync get_schedule did not return a Schedule" + assert isinstance(async_schedule, Schedule), "async get_schedule did not return a Schedule" + + expected = (1, 1, "2022-10-07", 1) + assert ( + sync_schedule.total_items, + sync_schedule.total_games, + sync_schedule.dates[0].date, + sync_schedule.dates[0].total_games, + ) == expected + assert ( + async_schedule.total_items, + async_schedule.total_games, + async_schedule.dates[0].date, + async_schedule.dates[0].total_games, + ) == expected + assert async_schedule == sync_schedule + + expected_request = ( + "GET", + "/api/v1/schedule", + {"date": "2022-10-07", "sportId": "1"}, + ) + assert requests_seen == [expected_request, expected_request] + + +@pytest.mark.parametrize("label", list(SCHEDULE_NO_RESULT_RESPONSES)) +def test_get_schedule_no_result_parity(label): + """An empty success and a 404 both return None on either client.""" + status, payload = SCHEDULE_NO_RESULT_RESPONSES[label] + + sync_schedule, async_schedule = call_both( + "get_schedule", + date="2022-10-07", + status=status, + payload=payload, + ) + + assert sync_schedule is None, f"sync get_schedule returned {sync_schedule!r} for {label}" + assert async_schedule is None, f"async get_schedule returned {async_schedule!r} for {label}" + + +def test_get_schedule_range_team_and_sport_request_parity(): + """A date range, team, and non-default sport produce equivalent requests.""" + requests_seen = [] + + sync_schedule, async_schedule = call_both( + "get_schedule", + start_date="2022-10-07", + end_date="2022-10-09", + team_id=133, + sport_id=11, + payload=SCHEDULE_PAYLOAD, + request_signatures=requests_seen, + ) + + assert async_schedule == sync_schedule + expected_request = ( + "GET", + "/api/v1/schedule", + { + "startDate": "2022-10-07", + "endDate": "2022-10-09", + "teamId": "133", + "sportId": "11", + }, + ) + assert requests_seen == [expected_request, expected_request] From 076d7a8775adefd7ba4ab4c631098201025aa594 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sat, 22 Aug 2026 04:42:39 -0700 Subject: [PATCH 38/81] test: add sync async failure parity coverage --- tests/test_sync_async_parity.py | 218 ++++++++++++++++++++++++++++++-- 1 file changed, 208 insertions(+), 10 deletions(-) diff --git a/tests/test_sync_async_parity.py b/tests/test_sync_async_parity.py index c306078b..4ae9d5e5 100644 --- a/tests/test_sync_async_parity.py +++ b/tests/test_sync_async_parity.py @@ -1,17 +1,17 @@ -"""Sync/async behavioral parity tests (issue #304, batches 1-2). +"""Sync/async behavioral parity tests (issue #304, batches 1-3). `Mlb` is the compatibility baseline. These tests prove that `AsyncMlb`'s public endpoint behavior stays aligned with it: the same response produces the same model type, the same parsed values, and the same "nothing to return" answer. -The scope is deliberately narrow. Transport behavior — retries, timeouts, -strict-mode status mapping, exception translation — is already covered by +The scope is deliberately narrow. Detailed transport behavior — retries, +timing, backoff, and transport-specific context — is already covered by tests/test_http_contract.py, tests/test_mlb_dataadapter.py and tests/test_async_mlb_dataadapter.py, and payload parsing by tests/parsers/. None of that is re-asserted here, and nothing compares Requests internals with HTTPX internals. Each test drives both public clients over an equivalent canned -response and compares only what a caller can see. +response or failure and compares only what a caller can see. These tests must not contact the live MLB API. """ @@ -19,6 +19,7 @@ from __future__ import annotations import asyncio +from http import HTTPStatus from urllib.parse import parse_qsl, urlsplit import pytest @@ -30,7 +31,15 @@ # failing a sync-only install at import time. httpx = pytest.importorskip("httpx", reason="requires the async extra (HTTPX)") -from mlbstatsapi import AsyncMlb, Mlb # noqa: E402 +from mlbstatsapi import ( # noqa: E402 + AsyncMlb, + Mlb, + MlbDecodeError, + MlbHttpCompatibilityWarning, + MlbHttpError, + MlbTimeoutError, + MlbTransportError, +) from mlbstatsapi.models.people import Person # noqa: E402 from mlbstatsapi.models.schedules import Schedule # noqa: E402 from mlbstatsapi.models.teams import Team # noqa: E402 @@ -88,19 +97,52 @@ def call_sync( method: str, *args, status: int, - payload: dict, + payload: dict | None = None, + raw_body: bytes | None = None, + failure: str | None = None, + mlb_options: dict | None = None, request_signatures: list[tuple[str, str, dict[str, str]]] | None = None, **kwargs, ): """Call a method on `Mlb` against a canned response.""" adapter = requests_mock.Adapter() - adapter.register_uri("GET", requests_mock.ANY, status_code=status, json=payload) + if failure == "timeout": + adapter.register_uri( + "GET", + requests_mock.ANY, + exc=requests.exceptions.Timeout("timed out"), + ) + elif failure == "transport": + adapter.register_uri( + "GET", + requests_mock.ANY, + exc=requests.exceptions.ConnectionError("connection refused"), + ) + elif failure is not None: + raise ValueError(f"Unsupported canned failure: {failure}") + elif raw_body is not None: + adapter.register_uri( + "GET", + requests_mock.ANY, + status_code=status, + content=raw_body, + reason=HTTPStatus(status).phrase, + ) + else: + assert payload is not None + adapter.register_uri( + "GET", + requests_mock.ANY, + status_code=status, + json=payload, + reason=HTTPStatus(status).phrase, + ) session = requests.Session() session.mount("https://", adapter) try: - with Mlb(session=session) as mlb: + with Mlb(session=session, **(mlb_options or {})) as mlb: result = getattr(mlb, method)(*args, **kwargs) if request_signatures is not None: @@ -117,7 +159,10 @@ def call_async( method: str, *args, status: int, - payload: dict, + payload: dict | None = None, + raw_body: bytes | None = None, + failure: str | None = None, + mlb_options: dict | None = None, request_signatures: list[tuple[str, str, dict[str, str]]] | None = None, **kwargs, ): @@ -126,6 +171,16 @@ def call_async( def handler(request): requests_seen.append(request) + if failure == "timeout": + raise httpx.ReadTimeout("timed out", request=request) + if failure == "transport": + raise httpx.ConnectError("connection refused", request=request) + if failure is not None: + raise ValueError(f"Unsupported canned failure: {failure}") + if raw_body is not None: + return httpx.Response(status, content=raw_body) + + assert payload is not None return httpx.Response(status, json=payload) client = httpx.AsyncClient( @@ -134,7 +189,7 @@ def handler(request): async def scenario(): try: - async with AsyncMlb(client=client) as mlb: + async with AsyncMlb(client=client, **(mlb_options or {})) as mlb: return await getattr(mlb, method)(*args, **kwargs) finally: await client.aclose() @@ -326,3 +381,146 @@ def test_get_schedule_range_team_and_sport_request_parity(): }, ) assert requests_seen == [expected_request, expected_request] + + +# --------------------------------------------------------------------------- +# Representative public failure behavior (get_team) +# --------------------------------------------------------------------------- + + +def assert_http_error_parity( + sync_error: MlbHttpError, + async_error: MlbHttpError, + *, + status_code: int, + reason: str, + response_data: dict, +): + """Compare stable public HTTP error context without transport internals.""" + expected = ( + status_code, + reason, + "GET", + "https://statsapi.mlb.com/api/v1/teams/133", + response_data, + ) + attributes = ("status_code", "reason", "method", "url", "response_data") + + assert tuple(getattr(sync_error, name) for name in attributes) == expected + assert tuple(getattr(async_error, name) for name in attributes) == expected + + +def test_get_team_strict_client_error_parity(): + """Strict non-404 4xx responses expose equivalent public error context.""" + payload = {"message": "access denied"} + options = {"strict_http": True} + + with pytest.raises(MlbHttpError) as sync_exc: + call_sync( + "get_team", + 133, + status=403, + payload=payload, + mlb_options=options, + ) + with pytest.raises(MlbHttpError) as async_exc: + call_async( + "get_team", + 133, + status=403, + payload=payload, + mlb_options=options, + ) + + assert_http_error_parity( + sync_exc.value, + async_exc.value, + status_code=403, + reason="Forbidden", + response_data=payload, + ) + + +def test_get_team_compatibility_client_error_parity(): + """Compatibility mode warns and returns None on both public clients.""" + options = {"strict_http": False} + + with pytest.warns(MlbHttpCompatibilityWarning) as sync_warnings: + sync_team = call_sync( + "get_team", + 133, + status=403, + payload={"message": "access denied"}, + mlb_options=options, + ) + with pytest.warns(MlbHttpCompatibilityWarning) as async_warnings: + async_team = call_async( + "get_team", + 133, + status=403, + payload={"message": "access denied"}, + mlb_options=options, + ) + + assert sync_team is None + assert async_team is None + assert len(sync_warnings) == len(async_warnings) == 1 + assert ( + sync_warnings[0].category + is async_warnings[0].category + is MlbHttpCompatibilityWarning + ) + assert str(sync_warnings[0].message) == str(async_warnings[0].message) + + +def test_get_team_server_error_parity(): + """One representative 5xx exposes equivalent public error context.""" + payload = {"message": "server error"} + + with pytest.raises(MlbHttpError) as sync_exc: + call_sync("get_team", 133, status=500, payload=payload) + with pytest.raises(MlbHttpError) as async_exc: + call_async("get_team", 133, status=500, payload=payload) + + assert_http_error_parity( + sync_exc.value, + async_exc.value, + status_code=500, + reason="Internal Server Error", + response_data=payload, + ) + + +def test_get_team_timeout_parity(): + """A deterministic timeout raises the same public exception on both clients.""" + with pytest.raises(MlbTimeoutError) as sync_exc: + call_sync("get_team", 133, status=200, failure="timeout") + with pytest.raises(MlbTimeoutError) as async_exc: + call_async("get_team", 133, status=200, failure="timeout") + + assert type(sync_exc.value) is type(async_exc.value) is MlbTimeoutError + assert str(sync_exc.value) == str(async_exc.value) + + +def test_get_team_transport_failure_parity(): + """A generic transport failure has the same public result on both clients.""" + with pytest.raises(MlbTransportError) as sync_exc: + call_sync("get_team", 133, status=200, failure="transport") + with pytest.raises(MlbTransportError) as async_exc: + call_async("get_team", 133, status=200, failure="transport") + + assert type(sync_exc.value) is type(async_exc.value) is MlbTransportError + assert str(sync_exc.value) == str(async_exc.value) + + +def test_get_team_invalid_json_parity(): + """Invalid JSON in a successful response raises on both public clients.""" + raw_body = b'{"teams": [' + + with pytest.raises(MlbDecodeError) as sync_exc: + call_sync("get_team", 133, status=200, raw_body=raw_body) + with pytest.raises(MlbDecodeError) as async_exc: + call_async("get_team", 133, status=200, raw_body=raw_body) + + assert type(sync_exc.value) is type(async_exc.value) is MlbDecodeError + assert str(sync_exc.value) == str(async_exc.value) From 0eec6f62b43d6386921c207f304569fa0a3a46c7 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sat, 22 Aug 2026 05:05:11 -0700 Subject: [PATCH 39/81] test: refine sync async parity assertions --- tests/test_sync_async_parity.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/test_sync_async_parity.py b/tests/test_sync_async_parity.py index 4ae9d5e5..1c04df4c 100644 --- a/tests/test_sync_async_parity.py +++ b/tests/test_sync_async_parity.py @@ -255,6 +255,15 @@ def test_get_team_success_parity(): assert async_team == sync_team +def test_get_team_empty_response_body_parity(): + """A successful response with no body returns None on both clients.""" + sync_team = call_sync("get_team", 133, status=200, raw_body=b"") + async_team = call_async("get_team", 133, status=200, raw_body=b"") + + assert sync_team is None + assert async_team is None + + @pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) def test_get_team_no_result_parity(label): """An empty success and a 404 both return None on either client.""" @@ -470,7 +479,6 @@ def test_get_team_compatibility_client_error_parity(): is async_warnings[0].category is MlbHttpCompatibilityWarning ) - assert str(sync_warnings[0].message) == str(async_warnings[0].message) def test_get_team_server_error_parity(): @@ -499,7 +507,6 @@ def test_get_team_timeout_parity(): call_async("get_team", 133, status=200, failure="timeout") assert type(sync_exc.value) is type(async_exc.value) is MlbTimeoutError - assert str(sync_exc.value) == str(async_exc.value) def test_get_team_transport_failure_parity(): @@ -510,7 +517,6 @@ def test_get_team_transport_failure_parity(): call_async("get_team", 133, status=200, failure="transport") assert type(sync_exc.value) is type(async_exc.value) is MlbTransportError - assert str(sync_exc.value) == str(async_exc.value) def test_get_team_invalid_json_parity(): @@ -523,4 +529,3 @@ def test_get_team_invalid_json_parity(): call_async("get_team", 133, status=200, raw_body=raw_body) assert type(sync_exc.value) is type(async_exc.value) is MlbDecodeError - assert str(sync_exc.value) == str(async_exc.value) From b365df88f9021050e23e0de6a4c4b74215a3059e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 12:39:21 +0000 Subject: [PATCH 40/81] test: simplify and strengthen the sync/async parity suite Restructure the #304 parity tests around a single `call_both` helper that always captures and compares both clients' requests, rather than an opt-in `request_signatures` list threaded through three helper signatures. Every parity test now checks request parity, not just the two get_schedule cases. Injecting endpoint drift into AsyncMlb.get_team is caught by six tests instead of two: the MockTransport handler answers any path, so a wrong async endpoint previously slipped past the success and no-result tests and showed up only in the URL carried by MlbHttpError. Other cleanups: - Hold the no-result responses as the keyword arguments that produce them, so the empty-body case folds into the table and covers get_person and get_schedule too. The schedule table now extends the shared one with its empty-envelope case instead of restating 404. - Table-drive the two canned transport failures per client, replacing the branch-per-failure dispatch duplicated in both helpers. The async side now rejects an unknown failure name at the same point the sync side does, instead of lazily inside the transport handler. - Merge the two MlbHttpError tests and the two transport-failure tests into parametrized pairs, and share the pytest.raises pairing in `raise_both`. The exact-type assertion stays: MlbTimeoutError subclasses MlbTransportError, which pytest.raises alone would not distinguish. - Drop the duplicated per-field assertions on the async result. Pydantic equality compares the model class, so asserting the sync type plus cross-client equality pins the async type and every field. - Default `status` to 200 so failure cases stop passing a status that is never used, and build the httpx client inside the coroutine. 424 lines and 20 tests, from 531 lines and 17 tests. Full offline suite: 765 passed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CqA6vNdLdccVfTPB8Dj9yq --- tests/test_sync_async_parity.py | 519 +++++++++++++------------------- 1 file changed, 206 insertions(+), 313 deletions(-) diff --git a/tests/test_sync_async_parity.py b/tests/test_sync_async_parity.py index 1c04df4c..813e0507 100644 --- a/tests/test_sync_async_parity.py +++ b/tests/test_sync_async_parity.py @@ -1,9 +1,9 @@ -"""Sync/async behavioral parity tests (issue #304, batches 1-3). +"""Sync/async behavioral parity tests (issue #304). `Mlb` is the compatibility baseline. These tests prove that `AsyncMlb`'s public endpoint behavior stays aligned with it: the same response produces the -same model type, the same parsed values, and the same "nothing to return" -answer. +same request, the same model type, the same parsed values, and the same +"nothing to return" answer. The scope is deliberately narrow. Detailed transport behavior — retries, timing, backoff, and transport-specific context — is already covered by @@ -19,7 +19,9 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass from http import HTTPStatus +from typing import Any from urllib.parse import parse_qsl, urlsplit import pytest @@ -66,28 +68,44 @@ ], } -# The two ways a call legitimately comes back with nothing to parse. Both -# clients are expected to answer None for get_team and get_person. +# Every way a call legitimately comes back with nothing to parse, held as the +# keyword arguments that produce it. Both clients are expected to answer None. NO_RESULT_RESPONSES = { - "empty 200": (200, {}), - "404": (404, {}), + "empty 200": {"payload": {}}, + "empty body": {"raw_body": b""}, + "404": {"status": 404, "payload": {}}, } -SCHEDULE_NO_RESULT_RESPONSES = { - "empty 200": ( - 200, - { +# A schedule can also answer with a well-formed envelope holding no dates. +SCHEDULE_NO_RESULT_RESPONSES = NO_RESULT_RESPONSES | { + "no dates": { + "payload": { "totalItems": 0, "totalEvents": 0, "totalGames": 0, "totalGamesInProgress": 0, "dates": [], - }, + } + }, +} + +# The canned transport failures, per client. Each pair is the closest +# equivalent the two libraries offer, so the public exception is the only +# thing being compared. +SYNC_FAILURES = { + "timeout": requests.exceptions.Timeout("timed out"), + "transport": requests.exceptions.ConnectionError("connection refused"), +} +ASYNC_FAILURES = { + "timeout": lambda request: httpx.ReadTimeout("timed out", request=request), + "transport": lambda request: httpx.ConnectError( + "connection refused", request=request ), - "404": (404, {}), } +RequestSignature = tuple[str, str, dict[str, str]] + -def request_signature(method: str, url: str) -> tuple[str, str, dict[str, str]]: +def request_signature(method: str, url: str) -> RequestSignature: """Normalize one observed request for transport-independent comparison.""" parsed_url = urlsplit(url) return method, parsed_url.path, dict(parse_qsl(parsed_url.query)) @@ -96,46 +114,26 @@ def request_signature(method: str, url: str) -> tuple[str, str, dict[str, str]]: def call_sync( method: str, *args, - status: int, + status: int = 200, payload: dict | None = None, raw_body: bytes | None = None, failure: str | None = None, mlb_options: dict | None = None, - request_signatures: list[tuple[str, str, dict[str, str]]] | None = None, + observed: list[RequestSignature] | None = None, **kwargs, ): """Call a method on `Mlb` against a canned response.""" adapter = requests_mock.Adapter() - if failure == "timeout": - adapter.register_uri( - "GET", - requests_mock.ANY, - exc=requests.exceptions.Timeout("timed out"), - ) - elif failure == "transport": - adapter.register_uri( - "GET", - requests_mock.ANY, - exc=requests.exceptions.ConnectionError("connection refused"), - ) - elif failure is not None: - raise ValueError(f"Unsupported canned failure: {failure}") - elif raw_body is not None: - adapter.register_uri( - "GET", - requests_mock.ANY, - status_code=status, - content=raw_body, - reason=HTTPStatus(status).phrase, - ) + if failure is not None: + adapter.register_uri("GET", requests_mock.ANY, exc=SYNC_FAILURES[failure]) else: - assert payload is not None + body = {"content": raw_body} if raw_body is not None else {"json": payload} adapter.register_uri( "GET", requests_mock.ANY, status_code=status, - json=payload, reason=HTTPStatus(status).phrase, + **body, ) session = requests.Session() @@ -143,243 +141,169 @@ def call_sync( try: with Mlb(session=session, **(mlb_options or {})) as mlb: - result = getattr(mlb, method)(*args, **kwargs) - - if request_signatures is not None: - assert len(adapter.request_history) == 1 - request = adapter.request_history[0] - request_signatures.append(request_signature(request.method, request.url)) - - return result + return getattr(mlb, method)(*args, **kwargs) finally: + if observed is not None: + observed.extend( + request_signature(request.method, request.url) + for request in adapter.request_history + ) + # Mlb leaves a caller-injected session open, so closing it is this + # helper's job. session.close() def call_async( method: str, *args, - status: int, + status: int = 200, payload: dict | None = None, raw_body: bytes | None = None, failure: str | None = None, mlb_options: dict | None = None, - request_signatures: list[tuple[str, str, dict[str, str]]] | None = None, + observed: list[RequestSignature] | None = None, **kwargs, ): """Call the matching method on `AsyncMlb` against the same canned response.""" - requests_seen = [] - - def handler(request): - requests_seen.append(request) - if failure == "timeout": - raise httpx.ReadTimeout("timed out", request=request) - if failure == "transport": - raise httpx.ConnectError("connection refused", request=request) + seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) if failure is not None: - raise ValueError(f"Unsupported canned failure: {failure}") + raise ASYNC_FAILURES[failure](request) if raw_body is not None: return httpx.Response(status, content=raw_body) - - assert payload is not None return httpx.Response(status, json=payload) - client = httpx.AsyncClient( - transport=httpx.MockTransport(handler) - ) - async def scenario(): + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) try: async with AsyncMlb(client=client, **(mlb_options or {})) as mlb: return await getattr(mlb, method)(*args, **kwargs) finally: + # AsyncMlb leaves a caller-injected client open, as Mlb does above. await client.aclose() - result = asyncio.run(scenario()) - - if request_signatures is not None: - assert len(requests_seen) == 1 - request = requests_seen[0] - request_signatures.append(request_signature(request.method, str(request.url))) - - return result - - -def call_both( - method: str, - *args, - status: int = 200, - payload: dict, - request_signatures: list[tuple[str, str, dict[str, str]]] | None = None, - **kwargs, -): - """Return the sync and async results for one call, in that order. - - Both clients are handed an equivalent response through their own public - constructor, so a failure below names the side that drifted. - """ - return ( - call_sync( - method, - *args, - status=status, - payload=payload, - request_signatures=request_signatures, - **kwargs, - ), - call_async( - method, - *args, - status=status, - payload=payload, - request_signatures=request_signatures, - **kwargs, - ), - ) + try: + return asyncio.run(scenario()) + finally: + if observed is not None: + observed.extend( + request_signature(request.method, str(request.url)) + for request in seen + ) -# --------------------------------------------------------------------------- -# get_team -# --------------------------------------------------------------------------- +@dataclass(frozen=True) +class ParityResult: + """What each client returned, plus the one request they both sent.""" + sync: Any + asynchronous: Any + request: RequestSignature -def test_get_team_success_parity(): - """A successful team response parses to the same Team on both clients.""" - sync_team, async_team = call_both("get_team", 133, payload=TEAM_PAYLOAD) - assert isinstance(sync_team, Team), "sync get_team did not return a Team" - assert isinstance(async_team, Team), "async get_team did not return a Team" +def call_both(method: str, *args, **kwargs) -> ParityResult: + """Drive both public clients over one canned response. - expected = (133, "/api/v1/teams/133", "Athletics") - assert (sync_team.id, sync_team.link, sync_team.name) == expected - assert (async_team.id, async_team.link, async_team.name) == expected - assert async_team == sync_team + Each client is handed an equivalent response through its own public + constructor, so a failure below names the side that drifted. Request + parity is asserted here rather than per test, which also rules out a + client that quietly fanned one call out into several. + """ + sync_requests: list[RequestSignature] = [] + async_requests: list[RequestSignature] = [] + sync_result = call_sync(method, *args, observed=sync_requests, **kwargs) + async_result = call_async(method, *args, observed=async_requests, **kwargs) -def test_get_team_empty_response_body_parity(): - """A successful response with no body returns None on both clients.""" - sync_team = call_sync("get_team", 133, status=200, raw_body=b"") - async_team = call_async("get_team", 133, status=200, raw_body=b"") + assert len(sync_requests) == 1, f"sync sent {len(sync_requests)} requests" + assert async_requests == sync_requests, "the clients sent different requests" - assert sync_team is None - assert async_team is None + return ParityResult(sync_result, async_result, sync_requests[0]) -@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) -def test_get_team_no_result_parity(label): - """An empty success and a 404 both return None on either client.""" - status, payload = NO_RESULT_RESPONSES[label] +def raise_both(expected: type[BaseException], method: str, *args, **kwargs): + """Return the exception each client raised for one canned failure.""" + with pytest.raises(expected) as sync_exc: + call_sync(method, *args, **kwargs) + with pytest.raises(expected) as async_exc: + call_async(method, *args, **kwargs) - sync_team, async_team = call_both("get_team", 133, status=status, payload=payload) + # pytest.raises accepts subclasses, so pin the exact type on both sides: + # MlbTimeoutError is itself an MlbTransportError. + assert type(sync_exc.value) is type(async_exc.value) is expected - assert sync_team is None, f"sync get_team returned {sync_team!r} for {label}" - assert async_team is None, f"async get_team returned {async_team!r} for {label}" + return sync_exc.value, async_exc.value # --------------------------------------------------------------------------- -# get_person +# Successful responses # --------------------------------------------------------------------------- -def test_get_person_success_parity(): - """A successful person response parses to the same Person on both clients.""" - sync_person, async_person = call_both("get_person", 660271, payload=PERSON_PAYLOAD) - - assert isinstance(sync_person, Person), "sync get_person did not return a Person" - assert isinstance(async_person, Person), "async get_person did not return a Person" - - expected = (660271, "/api/v1/people/660271", "Shohei Ohtani") - assert (sync_person.id, sync_person.link, sync_person.full_name) == expected - assert (async_person.id, async_person.link, async_person.full_name) == expected - assert async_person == sync_person - - -@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) -def test_get_person_no_result_parity(label): - """An empty success and a 404 both return None on either client.""" - status, payload = NO_RESULT_RESPONSES[label] +def test_get_team_success_parity(): + """A successful team response parses to the same Team on both clients.""" + result = call_both("get_team", 133, payload=TEAM_PAYLOAD) - sync_person, async_person = call_both( - "get_person", 660271, status=status, payload=payload + assert isinstance(result.sync, Team), "sync get_team did not return a Team" + assert (result.sync.id, result.sync.link, result.sync.name) == ( + 133, + "/api/v1/teams/133", + "Athletics", ) + # Pydantic equality compares the model class too, so this pins the async + # return type as well as every parsed field. + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/teams/133", {}) - assert sync_person is None, f"sync get_person returned {sync_person!r} for {label}" - assert async_person is None, f"async get_person returned {async_person!r} for {label}" +def test_get_person_success_parity(): + """A successful person response parses to the same Person on both clients.""" + result = call_both("get_person", 660271, payload=PERSON_PAYLOAD) -# --------------------------------------------------------------------------- -# get_schedule -# --------------------------------------------------------------------------- + assert isinstance(result.sync, Person), "sync get_person did not return a Person" + assert (result.sync.id, result.sync.link, result.sync.full_name) == ( + 660271, + "/api/v1/people/660271", + "Shohei Ohtani", + ) + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/people/660271", {}) def test_get_schedule_success_parity(): """A successful date schedule parses identically and sends the same request.""" - requests_seen = [] - - sync_schedule, async_schedule = call_both( - "get_schedule", - date="2022-10-07", - payload=SCHEDULE_PAYLOAD, - request_signatures=requests_seen, - ) + result = call_both("get_schedule", date="2022-10-07", payload=SCHEDULE_PAYLOAD) - assert isinstance(sync_schedule, Schedule), "sync get_schedule did not return a Schedule" - assert isinstance(async_schedule, Schedule), "async get_schedule did not return a Schedule" - - expected = (1, 1, "2022-10-07", 1) - assert ( - sync_schedule.total_items, - sync_schedule.total_games, - sync_schedule.dates[0].date, - sync_schedule.dates[0].total_games, - ) == expected + assert isinstance(result.sync, Schedule), "sync get_schedule did not return a Schedule" assert ( - async_schedule.total_items, - async_schedule.total_games, - async_schedule.dates[0].date, - async_schedule.dates[0].total_games, - ) == expected - assert async_schedule == sync_schedule - - expected_request = ( + result.sync.total_items, + result.sync.total_games, + result.sync.dates[0].date, + result.sync.dates[0].total_games, + ) == (1, 1, "2022-10-07", 1) + assert result.asynchronous == result.sync + assert result.request == ( "GET", "/api/v1/schedule", {"date": "2022-10-07", "sportId": "1"}, ) - assert requests_seen == [expected_request, expected_request] - - -@pytest.mark.parametrize("label", list(SCHEDULE_NO_RESULT_RESPONSES)) -def test_get_schedule_no_result_parity(label): - """An empty success and a 404 both return None on either client.""" - status, payload = SCHEDULE_NO_RESULT_RESPONSES[label] - - sync_schedule, async_schedule = call_both( - "get_schedule", - date="2022-10-07", - status=status, - payload=payload, - ) - - assert sync_schedule is None, f"sync get_schedule returned {sync_schedule!r} for {label}" - assert async_schedule is None, f"async get_schedule returned {async_schedule!r} for {label}" def test_get_schedule_range_team_and_sport_request_parity(): """A date range, team, and non-default sport produce equivalent requests.""" - requests_seen = [] - - sync_schedule, async_schedule = call_both( + result = call_both( "get_schedule", start_date="2022-10-07", end_date="2022-10-09", team_id=133, sport_id=11, payload=SCHEDULE_PAYLOAD, - request_signatures=requests_seen, ) - assert async_schedule == sync_schedule - expected_request = ( + assert result.asynchronous == result.sync + assert result.request == ( "GET", "/api/v1/schedule", { @@ -389,7 +313,46 @@ def test_get_schedule_range_team_and_sport_request_parity(): "sportId": "11", }, ) - assert requests_seen == [expected_request, expected_request] + + +# --------------------------------------------------------------------------- +# Nothing to return +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_team_no_result_parity(label): + """Every no-result response returns None on either client.""" + result = call_both("get_team", 133, **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, f"sync get_team returned {result.sync!r} for {label}" + assert result.asynchronous is None, ( + f"async get_team returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_person_no_result_parity(label): + """Every no-result response returns None on either client.""" + result = call_both("get_person", 660271, **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, f"sync get_person returned {result.sync!r} for {label}" + assert result.asynchronous is None, ( + f"async get_person returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(SCHEDULE_NO_RESULT_RESPONSES)) +def test_get_schedule_no_result_parity(label): + """Every no-result response returns None on either client.""" + result = call_both( + "get_schedule", date="2022-10-07", **SCHEDULE_NO_RESULT_RESPONSES[label] + ) + + assert result.sync is None, f"sync get_schedule returned {result.sync!r} for {label}" + assert result.asynchronous is None, ( + f"async get_schedule returned {result.asynchronous!r} for {label}" + ) # --------------------------------------------------------------------------- @@ -397,135 +360,65 @@ def test_get_schedule_range_team_and_sport_request_parity(): # --------------------------------------------------------------------------- -def assert_http_error_parity( - sync_error: MlbHttpError, - async_error: MlbHttpError, - *, - status_code: int, - reason: str, - response_data: dict, -): - """Compare stable public HTTP error context without transport internals.""" +@pytest.mark.parametrize( + "status, reason", + [ + # A final non-404 4xx under the strict_http default, and one + # representative 5xx. + (403, "Forbidden"), + (500, "Internal Server Error"), + ], +) +def test_get_team_http_error_parity(status, reason): + """Both clients expose the same stable public HTTP error context.""" + payload = {"message": "no"} + + sync_error, async_error = raise_both( + MlbHttpError, "get_team", 133, status=status, payload=payload + ) + expected = ( - status_code, + status, reason, "GET", "https://statsapi.mlb.com/api/v1/teams/133", - response_data, + payload, ) attributes = ("status_code", "reason", "method", "url", "response_data") - assert tuple(getattr(sync_error, name) for name in attributes) == expected assert tuple(getattr(async_error, name) for name in attributes) == expected -def test_get_team_strict_client_error_parity(): - """Strict non-404 4xx responses expose equivalent public error context.""" - payload = {"message": "access denied"} - options = {"strict_http": True} - - with pytest.raises(MlbHttpError) as sync_exc: - call_sync( - "get_team", - 133, - status=403, - payload=payload, - mlb_options=options, - ) - with pytest.raises(MlbHttpError) as async_exc: - call_async( - "get_team", - 133, - status=403, - payload=payload, - mlb_options=options, - ) - - assert_http_error_parity( - sync_exc.value, - async_exc.value, - status_code=403, - reason="Forbidden", - response_data=payload, - ) - - def test_get_team_compatibility_client_error_parity(): """Compatibility mode warns and returns None on both public clients.""" - options = {"strict_http": False} + response = { + "status": 403, + "payload": {"message": "access denied"}, + "mlb_options": {"strict_http": False}, + } with pytest.warns(MlbHttpCompatibilityWarning) as sync_warnings: - sync_team = call_sync( - "get_team", - 133, - status=403, - payload={"message": "access denied"}, - mlb_options=options, - ) + sync_team = call_sync("get_team", 133, **response) with pytest.warns(MlbHttpCompatibilityWarning) as async_warnings: - async_team = call_async( - "get_team", - 133, - status=403, - payload={"message": "access denied"}, - mlb_options=options, - ) + async_team = call_async("get_team", 133, **response) assert sync_team is None assert async_team is None assert len(sync_warnings) == len(async_warnings) == 1 - assert ( - sync_warnings[0].category - is async_warnings[0].category - is MlbHttpCompatibilityWarning - ) - - -def test_get_team_server_error_parity(): - """One representative 5xx exposes equivalent public error context.""" - payload = {"message": "server error"} - - with pytest.raises(MlbHttpError) as sync_exc: - call_sync("get_team", 133, status=500, payload=payload) - with pytest.raises(MlbHttpError) as async_exc: - call_async("get_team", 133, status=500, payload=payload) - - assert_http_error_parity( - sync_exc.value, - async_exc.value, - status_code=500, - reason="Internal Server Error", - response_data=payload, - ) -def test_get_team_timeout_parity(): - """A deterministic timeout raises the same public exception on both clients.""" - with pytest.raises(MlbTimeoutError) as sync_exc: - call_sync("get_team", 133, status=200, failure="timeout") - with pytest.raises(MlbTimeoutError) as async_exc: - call_async("get_team", 133, status=200, failure="timeout") - - assert type(sync_exc.value) is type(async_exc.value) is MlbTimeoutError - - -def test_get_team_transport_failure_parity(): - """A generic transport failure has the same public result on both clients.""" - with pytest.raises(MlbTransportError) as sync_exc: - call_sync("get_team", 133, status=200, failure="transport") - with pytest.raises(MlbTransportError) as async_exc: - call_async("get_team", 133, status=200, failure="transport") - - assert type(sync_exc.value) is type(async_exc.value) is MlbTransportError +@pytest.mark.parametrize( + "failure, expected", + [ + ("timeout", MlbTimeoutError), + ("transport", MlbTransportError), + ], +) +def test_get_team_transport_failure_parity(failure, expected): + """A deterministic transport failure raises the same exception on both.""" + raise_both(expected, "get_team", 133, failure=failure) def test_get_team_invalid_json_parity(): """Invalid JSON in a successful response raises on both public clients.""" - raw_body = b'{"teams": [' - - with pytest.raises(MlbDecodeError) as sync_exc: - call_sync("get_team", 133, status=200, raw_body=raw_body) - with pytest.raises(MlbDecodeError) as async_exc: - call_async("get_team", 133, status=200, raw_body=raw_body) - - assert type(sync_exc.value) is type(async_exc.value) is MlbDecodeError + raise_both(MlbDecodeError, "get_team", 133, raw_body=b'{"teams": [') From 8a2695e004d22b2e2c6892c0296bc7c25ad7efd5 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sat, 22 Aug 2026 11:24:27 -0700 Subject: [PATCH 41/81] feat(async): expand AsyncMlb endpoint coverage for sport/league/division and team roster/coaches Adds get_sport(s), get_league(s), get_division(s), get_team_roster, and get_team_coaches to AsyncMlb, per issue #305's plan to expand async coverage in small, reviewable batches. New shared parsers (_parsers/sports.py, leagues.py, divisions.py, roster.py) are reused by both Mlb and AsyncMlb; Mlb's existing methods are refactored internally onto them with no behavior change. AsyncMlb docstrings are mirrored from their Mlb counterparts. Adds parser tests, AsyncMlb endpoint tests, sync/async parity tests, and live external smoke tests for the new endpoints, and extends the frozen ASYNC_MLB_PUBLIC_METHOD_MANIFEST and docs/public-api.md accordingly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BtqT3jocrSJRouEew7Nnto --- docs/public-api.md | 12 + mlbstatsapi/_parsers/divisions.py | 21 + mlbstatsapi/_parsers/leagues.py | 21 + mlbstatsapi/_parsers/roster.py | 24 + mlbstatsapi/_parsers/sports.py | 21 + mlbstatsapi/async_mlb.py | 745 +++++++++++++++++- mlbstatsapi/mlb_api.py | 53 +- .../async_mlb/test_async_mlb_smoke.py | 60 +- tests/parsers/test_divisions.py | 49 ++ tests/parsers/test_leagues.py | 43 + tests/parsers/test_roster_parser.py | 65 ++ tests/parsers/test_sports.py | 43 + tests/test_async_mlb.py | 228 +++++- tests/test_public_api.py | 8 + tests/test_sync_async_parity.py | 166 +++- 15 files changed, 1507 insertions(+), 52 deletions(-) create mode 100644 mlbstatsapi/_parsers/divisions.py create mode 100644 mlbstatsapi/_parsers/leagues.py create mode 100644 mlbstatsapi/_parsers/roster.py create mode 100644 mlbstatsapi/_parsers/sports.py create mode 100644 tests/parsers/test_divisions.py create mode 100644 tests/parsers/test_leagues.py create mode 100644 tests/parsers/test_roster_parser.py create mode 100644 tests/parsers/test_sports.py diff --git a/docs/public-api.md b/docs/public-api.md index 0e44a0dc..a8ea0abb 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -275,6 +275,8 @@ The currently supported awaitable endpoint methods are: ```text get_team(team_id: int, **params) get_teams(sport_id: int = 1, **params) +get_team_roster(team_id: int, **params) +get_team_coaches(team_id: int, **params) get_person(player_id: int, **params) get_people(sport_id: int = 1, **params) get_schedule( @@ -285,8 +287,18 @@ get_schedule( team_id: int = None, **params, ) +get_sport(sport_id: int, **params) +get_sports(**params) +get_league(league_id: int, **params) +get_leagues(**params) +get_division(division_id: int, **params) +get_divisions(**params) ``` +Every other `Mlb` endpoint method not listed above is not yet supported on +`AsyncMlb`; calling it there raises `AttributeError`. See issue #305 for the +tracked expansion plan. + ## Low-level adapter `MlbDataAdapter` is the public low-level HTTP adapter. diff --git a/mlbstatsapi/_parsers/divisions.py b/mlbstatsapi/_parsers/divisions.py new file mode 100644 index 00000000..7a791aff --- /dev/null +++ b/mlbstatsapi/_parsers/divisions.py @@ -0,0 +1,21 @@ +from mlbstatsapi.models.divisions import Division + + +def parse_divisions(data: dict) -> list[Division]: + """Parse Division models from an MLB /divisions response body. + + Expects the full response, e.g. ``{"divisions": [...]}``, not the inner list. + """ + if not data or not data.get("divisions"): + return [] + return [Division(**division) for division in data["divisions"]] + + +def parse_division(data: dict) -> Division | None: + """Parse a Division from a single division payload.""" + divisions = parse_divisions(data) + + if not divisions: + return None + + return divisions[0] diff --git a/mlbstatsapi/_parsers/leagues.py b/mlbstatsapi/_parsers/leagues.py new file mode 100644 index 00000000..c866dfd4 --- /dev/null +++ b/mlbstatsapi/_parsers/leagues.py @@ -0,0 +1,21 @@ +from mlbstatsapi.models.leagues import League + + +def parse_leagues(data: dict) -> list[League]: + """Parse League models from an MLB /leagues response body. + + Expects the full response, e.g. ``{"leagues": [...]}``, not the inner list. + """ + if not data or not data.get("leagues"): + return [] + return [League(**league) for league in data["leagues"]] + + +def parse_league(data: dict) -> League | None: + """Parse a League from a single league payload.""" + leagues = parse_leagues(data) + + if not leagues: + return None + + return leagues[0] diff --git a/mlbstatsapi/_parsers/roster.py b/mlbstatsapi/_parsers/roster.py new file mode 100644 index 00000000..9ee36dbf --- /dev/null +++ b/mlbstatsapi/_parsers/roster.py @@ -0,0 +1,24 @@ +from mlbstatsapi import mlb_module +from mlbstatsapi.models.people import Coach, Player + + +def parse_roster_players(data: dict) -> list[Player]: + """Parse Player models from an MLB /teams/{id}/roster response body.""" + if not data or not data.get("roster"): + return [] + return [ + Player(**mlb_module.merge_keys(player, ["person"])) for player in data["roster"] + ] + + +def parse_roster_coaches(data: dict) -> list[Coach]: + """Parse Coach models from an MLB /teams/{id}/coaches response body. + + The coaches endpoint reuses the same ``roster`` envelope as the player + roster endpoint. + """ + if not data or not data.get("roster"): + return [] + return [ + Coach(**mlb_module.merge_keys(coach, ["person"])) for coach in data["roster"] + ] diff --git a/mlbstatsapi/_parsers/sports.py b/mlbstatsapi/_parsers/sports.py new file mode 100644 index 00000000..4872aa48 --- /dev/null +++ b/mlbstatsapi/_parsers/sports.py @@ -0,0 +1,21 @@ +from mlbstatsapi.models.sports import Sport + + +def parse_sports(data: dict) -> list[Sport]: + """Parse Sport models from an MLB /sports response body. + + Expects the full response, e.g. ``{"sports": [...]}``, not the inner list. + """ + if not data or not data.get("sports"): + return [] + return [Sport(**sport) for sport in data["sports"]] + + +def parse_sport(data: dict) -> Sport | None: + """Parse a Sport from a single sport payload.""" + sports = parse_sports(data) + + if not sports: + return None + + return sports[0] diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index 12cac734..3271d915 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -6,13 +6,20 @@ from typing import TYPE_CHECKING from ._helpers.schedule import build_schedule_params +from ._parsers.divisions import parse_division, parse_divisions +from ._parsers.leagues import parse_league, parse_leagues from ._parsers.people import parse_person, parse_people +from ._parsers.roster import parse_roster_coaches, parse_roster_players from ._parsers.schedules import parse_schedule +from ._parsers.sports import parse_sport, parse_sports from ._parsers.teams import parse_team, parse_teams from .async_mlb_dataadapter import AsyncMlbDataAdapter from .mlb_dataadapter import DEFAULT_TIMEOUT, TimeoutType -from .models.people import Person +from .models.divisions import Division +from .models.leagues import League +from .models.people import Coach, Person, Player from .models.schedules import Schedule +from .models.sports import Sport from .models.teams import Team if TYPE_CHECKING: @@ -72,6 +79,62 @@ async def get_team( team_id: int, **params, ) -> Team | None: + """ + Returns a team based on teamId. + + Async counterpart of ``Mlb.get_team``. + + Parameters + ---------- + team_id : int + Insert teamId to return a directory of team information for a + particular club. + + Other Parameters + ---------------- + season : int + Insert year to return a directory of team information for a + particular club in a specific season. + sportId : int + Insert a sportId to return a directory of team information for a + particular club in a sport. + hydrate : str + Insert Hydration(s) to return data for any available team + hydration. Format "league,venue" + Available Hydrations: + previousSchedule + nextSchedule + venue + social + deviceProperties + game(promotions) + game(atBatPromotions) + game(tickets) + game(atBatTickets) + game(sponsorships) + league + person + sport + division + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + Team + returns a Team from team id + + See Also + -------- + AsyncMlb.get_teams : Return a list of Teams from sport id. + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... team = await mlb.get_team(133) + Team + """ mlb_data = await self._mlb_adapter_v1.get( endpoint=f"teams/{team_id}", ep_params=params, @@ -87,10 +150,71 @@ async def get_teams( sport_id: int = 1, **params, ) -> list[Team]: - """Return every Team for a sport id. - - Async counterpart of ``Mlb.get_teams``; see that method for the - supported keyword parameters. + """ + return the all Teams + + Async counterpart of ``Mlb.get_teams``. + + Parameters + ---------- + sport_id : int + Insert sportId to return team information for a particular sportId + + Other Parameters + ---------------- + season : str + Insert year to return team information for a particular season. + leagueIds : int + Insert leagueId to return team information for particular league. + activeStatus : str + Insert activeStatus to populate a teams based on active/inactive + status for a given season. There are three status types: Y, N, B + allStarStatuses : str + Insert allStarStatuses to populate a teams based on Allstar status + for a given season. There are two status types: Y and N + sportIds : str + Insert sportId to return team information for a particular sportId + Usage: '1' or '1,11,12' + gameType : str + Insert gameType to return team information for a particular + gameType. For a list of all gameTypes: + https://statsapi.mlb.com/api/v1/gameTypes + hydrate : str + Insert Hydration(s) to return data for any available team + hydration. Format "league,venue" + Available Hydrations: + previousSchedule + nextSchedule + venue + social + deviceProperties + game(promotions) + game(atBatPromotions) + game(tickets) + game(atBatTickets) + game(sponsorships) + league + person + sport + division + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + list of Teams + returns a list of teams + + See Also + -------- + AsyncMlb.get_team : Return a Team from id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... teams = await mlb.get_teams() + [Team, Team, Team] """ params["sportId"] = sport_id @@ -104,11 +228,168 @@ async def get_teams( return parse_teams(mlb_data.data) + async def get_team_roster( + self, + team_id: int, + **params, + ) -> list[Player]: + """ + return the team player roster + + Async counterpart of ``Mlb.get_team_roster``. + + Parameters + ---------- + team_id : int + teamId to return a directory of players based on roster status for + a particular club. + + Other Parameters + ---------------- + rosterType : str + Insert teamId to return a directory of players based on roster + status for a particular club. rosterType's include 40Man, + fullSeason, fullRoster, nonRosterInvitees, active, allTime, + depthChart, gameday, and coach. + season : str + Insert year to return a directory of players based on roster + status for a particular club in a specific season. + date : str + Insert date to return a directory of players based on roster + status for a particular club on a specific date. + hydrate : str + Insert Hydration(s) to return data for any available team + hydration. The hydration for Teams contains "person" which has + subhydrations Format "person(subHydration1, subHydrations2)" + Available Hydrations: + "person" + Hydrations Available Through Person + hydrations + awards + currentTeam + team + rosterEntries + relatives + transactions + social + education + stats + draft + mixedFeed + articles + video + xrefId + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + list of players + + See Also + -------- + AsyncMlb.get_team : Return a Team from id + AsyncMlb.get_team_coaches : Return a list of Coaches from team id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... roster = await mlb.get_team_roster(133) + [Player, Player, Player] + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"teams/{team_id}/roster", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_roster_players(mlb_data.data) + + async def get_team_coaches( + self, + team_id: int, + **params, + ) -> list[Coach]: + """ + Return a directory of coaches for a particular team. + + Async counterpart of ``Mlb.get_team_coaches``. + + Parameters + ---------- + team_id : int + Insert teamId to return a directory of coaches for a given team. + + Other Parameters + ---------------- + season : str + Insert year to return a directory of players based on roster status for a particular club in a specific season. + date : str + Insert date to return a directory of players based on roster status for a particular club on a specific date. + fields : str + Comma delimited list of specific fields to be returned. Format: topLevelNode, childNode, attribute + + Returns + ------- + list of Coaches + returns a list of Coaches + + See Also + -------- + AsyncMlb.get_team : Return a Team from id + AsyncMlb.get_team_roster : Return a list of Players from team id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... coaches = await mlb.get_team_coaches(133) + [Coach, Coach, Coach] + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"teams/{team_id}/coaches", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_roster_coaches(mlb_data.data) + async def get_person( self, player_id: int, **params, ) -> Person | None: + """ + This endpoint returns statistical data and biographical information + for a player,coach or umpire based on playerId. + + Async counterpart of ``Mlb.get_person``. + + Parameters + ---------- + player_id : int + Insert personId for a specific player, coach or umpire based on + playerId. + + Returns + ------- + Person + Returns a Person + + See Also + -------- + AsyncMlb.get_people : Return a list of People from sport id. + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... person = await mlb.get_person(660271) + Person + """ mlb_data = await self._mlb_adapter_v1.get( endpoint=f"people/{player_id}", ep_params=params, @@ -124,10 +405,40 @@ async def get_people( sport_id: int = 1, **params, ) -> list[Person]: - """Return every player for a sport id. + """ + return the all players for sportid Async counterpart of ``Mlb.get_people``, which reads the ``sports/{sport_id}/players`` endpoint rather than ``people``. + + Parameters + ---------- + sport_id : int + Insert a sportId to return player information for a particular + sport. + + Other Parameters + ---------------- + season : str + Insert year to return player information for a particular season. + gameType : str + Insert gameType to return player information for a particular + gameType. + + Returns + ------- + list + Returns a list of People + + See Also + -------- + AsyncMlb.get_person : Return Person from id. + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... people = await mlb.get_people() + [Person, Person, Person] """ mlb_data = await self._mlb_adapter_v1.get( endpoint=f"sports/{sport_id}/players", @@ -149,7 +460,162 @@ async def get_schedule( team_id: int = None, **params, ) -> Schedule | None: - + """ + return the schedule created from the included params. + + Async counterpart of ``Mlb.get_schedule``. + + Calling get_schedule without startDate or endDate results in a schedule returned + for todays date. Calling with startDate and endDate as the same date returns a + schedule for just that desired date. Different results in the schedule for multiple + days. + + Parameters + ---------- + date : str + Date + start_date : str "yyyy-mm-dd" + Start date + end_date : str "yyyy-mm-dd" + End date + sport_id : int + sport id of schedule defaults to 1 + team_id : int + get schedule for team with team_id + + Other Parameters + ---------------- + leagueId : int,str + Insert leagueId to return all schedules based on a particular + scheduleType for a specific league. Usage: 1 or '1,11 + gamePks : int,str + Insert gamePks to return all schedules based on a particular + scheduleType for specific games. Usage: 531493 or '531493,531497' + venueIds : int + Insert venueId to return all schedules based on a particular + scheduleType for a specific venueId. + gameTypes : str + Insert gameTypes to return schedule information for all games in + particular gameTypes. For a list of all gameTypes: + https://statsapi.mlb.com/api/v1/gameTypes + + scheduleType : str + Insert one or mutliple of the three available scheduleTypes to + return data for a particular schedule. Format "games,events,xref" + eventTypes : str + Insert one or mutliple of the three available eventTypes to + return data for a particular schedule. Format "primary,secondary" + There are two different schedule eventTypes: + primary- returns calendar/schedule pages. + secondary returns ticket pages. + hydrate : str + Insert Hydration(s) to return data for any available schedule + hydration. The hydrations for schedule contain "venue" and "team" + which have subhydrations. + Format "team(subHydration1, subHydrations2)" + Available Hydrations: + tickets + game(content) + game(content(all)) + game(content(media(all))) + game(content(editorial(all))) + game(content(highlights(all))) + game(content(editorial(preview))) + game(content(editorial(recap))) + game(content(editorial(articles))) + game(content(editorial(wrap))) + game(content(media(epg))) + game(content(media(milestones))) + game(content(highlights(scoreboard))) + game(content(highlights(scoreboardPreview))) + game(content(highlights(highlights))) + game(content(highlights(gamecenter))) + game(content(highlights(milestone))) + game(content(highlights(live))) + game(content(media(featured))) + game(content(summary)) + game(content(gamenotes)) + game(tickets) + game(atBatTickets) + game(promotions) + game(atBatPromotions) + game(sponsorships) + lineup + linescore + linescore(matchup) + linescore(runners) + linescore(defense) + decisions + scoringplays + broadcasts + broadcasts(all) + radioBroadcasts + metadata + game(seriesSummary) + seriesStatus + event(performers) + event(promotions) + event(timezone) + event(tickets) + event(venue) + event(designations) + event(game) + event(status) + weather + officials + probablePitcher + venue + relatedVenues + parentVenues + residentVenues + relatedVenues(venue) + parentVenues(venue) + residentVenues(venue) + location + social + relatedApplications + timezone + menu + metadata + performers + images + schedule + nextSchedule + previousSchedule + ticketManagement + xrefId + team + previousSchedule + nextSchedule + venue + springVenue + social + deviceProperties + game(promotions) + game(promotions) + game(atBatPromotions) + game(tickets) + game(atBatTickets) + game(sponsorships) + league + videos + person + sport + standings + division + xref + + Returns + ------- + Schedule + returns the Schedule for the dates + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... schedule = await mlb.get_schedule(start_date="2021-08-01", end_date="2021-08-11") + Schedule + """ params = build_schedule_params( date=date, start_date=start_date, @@ -172,3 +638,268 @@ async def get_schedule( return None return parse_schedule(mlb_data.data) + + async def get_sport( + self, + sport_id: int, + **params, + ) -> Sport | None: + """ + return sport object from sport_id + + Async counterpart of ``Mlb.get_sport``. + + Parameters + ---------- + sport_id : int + Insert a sportId to return a directory of sport(s). + For a list of all sportIds: http://statsapi.mlb.com/api/v1/sports + + Other Parameters + ---------------- + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + Sport + + See Also + -------- + AsyncMlb.get_sports : return a list of sports + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... sport = await mlb.get_sport(1) + Sport + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"sports/{sport_id}", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_sport(mlb_data.data) + + async def get_sports( + self, + **params, + ) -> list[Sport]: + """ + return all sports + + Async counterpart of ``Mlb.get_sports``. + + Returns + ------- + list of Sports + returns a list of sport objects + + Other Parameters + ---------------- + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + See Also + -------- + AsyncMlb.get_sport : return a sport from id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... sports = await mlb.get_sports() + [Sport, Sport, Sport] + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint="sports", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_sports(mlb_data.data) + + async def get_league( + self, + league_id: int, + **params, + ) -> League | None: + """ + return league + + Async counterpart of ``Mlb.get_league``. + + Parameters + ---------- + league_id : int + leagueId to return league information for a specific league + + Other Parameters + ---------------- + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + League + + See Also + -------- + AsyncMlb.get_leagues : return a list of Leagues + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... league = await mlb.get_league(103) + League + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"leagues/{league_id}", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_league(mlb_data.data) + + async def get_leagues( + self, + **params, + ) -> list[League]: + """ + return all leagues + + Async counterpart of ``Mlb.get_leagues``. + + Returns + ------- + list of Leagues + + Other Parameters + ---------------- + leagueId : str + leagueId(s) to return league information for specific leagues. + Format '103,104' + sportId : int + Insert sportId to return league information for a specific sport. + For a list of all sportIds: http://statsapi.mlb.com/api/v1/sports + seasons : str + Insert year(s) to return league information for a specific season. + Format '2017,2018' + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + See Also + -------- + AsyncMlb.get_league : return a League from league id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... leagues = await mlb.get_leagues() + [League, League, League] + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint="leagues", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_leagues(mlb_data.data) + + async def get_division( + self, + division_id: int, + **params, + ) -> Division | None: + """ + Returns a division based on divisionId, + + Async counterpart of ``Mlb.get_division``. + + Parameters + ---------- + division_id : int + divisionId to return a directory of division(s) for a specific division. + + Returns + ------- + Division + returns a Division + + See Also + -------- + AsyncMlb.get_divisions : return a list of Divisions + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... division = await mlb.get_division(200) + Division + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"divisions/{division_id}", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_division(mlb_data.data) + + async def get_divisions( + self, + **params, + ) -> list[Division]: + """ + return all divisons + + Async counterpart of ``Mlb.get_divisions``. + + Other Parameters + ---------------- + divisionId : str + Insert divisionId(s) to return a directory of division(s) for a + specific division. Format '200,201' + leagueId : int + Insert leagueId to return a directory of division(s) for all + divisions in a specific league. + sportId : int + Insert a sportId to return a directory of division(s) for all + divisions in a specific sport. + + Returns + ------- + list of Divisions + returns a list of all divisions + + See Also + -------- + AsyncMlb.get_division : return a Division from id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... divisions = await mlb.get_divisions() + [Division, Division, Division] + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint="divisions", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_divisions(mlb_data.data) diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index ace2c570..fa23befd 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -23,7 +23,11 @@ from mlbstatsapi.models.standings import Standings +from ._parsers.divisions import parse_divisions, parse_division +from ._parsers.leagues import parse_leagues, parse_league from ._parsers.people import parse_people, parse_person +from ._parsers.roster import parse_roster_coaches, parse_roster_players +from ._parsers.sports import parse_sports, parse_sport from ._parsers.teams import parse_teams, parse_team from ._parsers.schedules import parse_schedule @@ -573,13 +577,7 @@ def get_team_roster(self, team_id: int, **params) -> List[Player]: if 400 <= mlb_data.status_code <= 499: return [] - players = [] - - if 'roster' in mlb_data.data and mlb_data.data['roster']: - for player in mlb_data.data['roster']: - players.append(Player(**mlb_module.merge_keys(player, ['person']))) - - return players + return parse_roster_players(mlb_data.data) def get_team_coaches(self, team_id: int, **params) -> List[Coach]: """ @@ -623,13 +621,7 @@ def get_team_coaches(self, team_id: int, **params) -> List[Coach]: if 400 <= mlb_data.status_code <= 499: return [] - coaches = [] - - if 'roster' in mlb_data.data and mlb_data.data['roster']: - for coach in mlb_data.data['roster']: - coaches.append(Coach(**mlb_module.merge_keys(coach, ['person']))) - - return coaches + return parse_roster_coaches(mlb_data.data) def get_schedule(self, date: str = None, @@ -1381,9 +1373,7 @@ def get_sport(self, sport_id: int, **params) -> Union[Sport, None]: if 400 <= mlb_data.status_code <= 499: return None - if 'sports' in mlb_data.data and mlb_data.data['sports']: - for sport in mlb_data.data['sports']: - return Sport(**sport) + return parse_sport(mlb_data.data) def get_sports(self, **params) -> List[Sport]: """ @@ -1416,12 +1406,7 @@ def get_sports(self, **params) -> List[Sport]: if 400 <= mlb_data.status_code <= 499: return [] - sports = [] - - if 'sports' in mlb_data.data and mlb_data.data['sports']: - sports = [Sport(**sport) for sport in mlb_data.data['sports']] - - return sports + return parse_sports(mlb_data.data) def get_sport_id(self, sport_name: str, search_key: str = 'name', **params) -> List[int]: @@ -1503,9 +1488,7 @@ def get_league(self, league_id: int, **params) -> Union[League, None]: if 400 <= mlb_data.status_code <= 499: return None - if 'leagues' in mlb_data.data and mlb_data.data['leagues']: - for league in mlb_data.data['leagues']: - return League(**league) + return parse_league(mlb_data.data) def get_leagues(self, **params) -> List[League]: """ @@ -1546,12 +1529,7 @@ def get_leagues(self, **params) -> List[League]: if 400 <= mlb_data.status_code <= 499: return [] - leagues = [] - - if 'leagues' in mlb_data.data and mlb_data.data['leagues']: - leagues = [League(**league) for league in mlb_data.data['leagues']] - - return leagues + return parse_leagues(mlb_data.data) def get_league_id(self, league_name: str, search_key: str = 'name', **params) -> List[int]: @@ -1626,9 +1604,7 @@ def get_division(self, division_id: int, **params) -> Union[Division, None]: if 400 <= mlb_data.status_code <= 499: return None - if 'divisions' in mlb_data.data and mlb_data.data['divisions']: - for division in mlb_data.data['divisions']: - return Division(**division) + return parse_division(mlb_data.data) def get_divisions(self, **params) -> List[Division]: """ @@ -1667,12 +1643,7 @@ def get_divisions(self, **params) -> List[Division]: if 400 <= mlb_data.status_code <= 499: return [] - divisions = [] - - if 'divisions' in mlb_data.data and mlb_data.data['divisions']: - divisions = [Division(**division) for division in mlb_data.data['divisions']] - - return divisions + return parse_divisions(mlb_data.data) def get_division_id(self, division_name: str, search_key: str = 'name', **params) -> List[int]: diff --git a/tests/external_tests/async_mlb/test_async_mlb_smoke.py b/tests/external_tests/async_mlb/test_async_mlb_smoke.py index 9e39cf6a..8d3f7238 100644 --- a/tests/external_tests/async_mlb/test_async_mlb_smoke.py +++ b/tests/external_tests/async_mlb/test_async_mlb_smoke.py @@ -1,8 +1,11 @@ import asyncio from mlbstatsapi import AsyncMlb -from mlbstatsapi.models.people import Person +from mlbstatsapi.models.divisions import Division +from mlbstatsapi.models.leagues import League +from mlbstatsapi.models.people import Coach, Person, Player from mlbstatsapi.models.schedules import Schedule +from mlbstatsapi.models.sports import Sport from mlbstatsapi.models.teams import Team @@ -17,6 +20,28 @@ async def scenario(): asyncio.run(scenario()) +def test_async_get_team_roster(): + async def scenario(): + async with AsyncMlb() as mlb: + roster = await mlb.get_team_roster(133) + + assert roster + assert isinstance(roster[0], Player) + + asyncio.run(scenario()) + + +def test_async_get_team_coaches(): + async def scenario(): + async with AsyncMlb() as mlb: + coaches = await mlb.get_team_coaches(133) + + assert coaches + assert isinstance(coaches[0], Coach) + + asyncio.run(scenario()) + + def test_async_get_person(): async def scenario(): async with AsyncMlb() as mlb: @@ -37,3 +62,36 @@ async def scenario(): assert schedule.dates asyncio.run(scenario()) + + +def test_async_get_sport(): + async def scenario(): + async with AsyncMlb() as mlb: + sport = await mlb.get_sport(1) + + assert isinstance(sport, Sport) + assert sport.id == 1 + + asyncio.run(scenario()) + + +def test_async_get_league(): + async def scenario(): + async with AsyncMlb() as mlb: + league = await mlb.get_league(103) + + assert isinstance(league, League) + assert league.id == 103 + + asyncio.run(scenario()) + + +def test_async_get_division(): + async def scenario(): + async with AsyncMlb() as mlb: + division = await mlb.get_division(200) + + assert isinstance(division, Division) + assert division.id == 200 + + asyncio.run(scenario()) diff --git a/tests/parsers/test_divisions.py b/tests/parsers/test_divisions.py new file mode 100644 index 00000000..0e1bbeda --- /dev/null +++ b/tests/parsers/test_divisions.py @@ -0,0 +1,49 @@ +import pytest +from pydantic import ValidationError + +from mlbstatsapi._parsers.divisions import parse_division, parse_divisions +from mlbstatsapi.models.divisions import Division + + +def test_parse_divisions(): + """parse_divisions reads the MLB divisions envelope and returns Division models.""" + assert parse_divisions({}) == [] + assert parse_divisions({"divisions": []}) == [] + + divisions = parse_divisions( + { + "divisions": [ + {"id": 200, "link": "/api/v1/divisions/200", "name": "American League West"}, + {"id": 201, "link": "/api/v1/divisions/201", "name": "American League East"}, + ] + } + ) + + assert divisions == [ + Division(id=200, link="/api/v1/divisions/200", name="American League West"), + Division(id=201, link="/api/v1/divisions/201", name="American League East"), + ] + + +def test_parse_division(): + """parse_division builds a Division from one division payload.""" + assert parse_division({}) is None + + division = parse_division( + { + "divisions": [ + {"id": 200, "link": "/api/v1/divisions/200", "name": "American League West"} + ] + } + ) + + assert isinstance(division, Division) + assert division == Division( + id=200, link="/api/v1/divisions/200", name="American League West" + ) + + +def test_parse_division_requires_link(): + """Division requires link, the same required field used by the MLB API.""" + with pytest.raises(ValidationError): + parse_division({"divisions": [{"id": 200, "name": "American League West"}]}) diff --git a/tests/parsers/test_leagues.py b/tests/parsers/test_leagues.py new file mode 100644 index 00000000..efcea7e2 --- /dev/null +++ b/tests/parsers/test_leagues.py @@ -0,0 +1,43 @@ +import pytest +from pydantic import ValidationError + +from mlbstatsapi._parsers.leagues import parse_league, parse_leagues +from mlbstatsapi.models.leagues import League + + +def test_parse_leagues(): + """parse_leagues reads the MLB leagues envelope and returns League models.""" + assert parse_leagues({}) == [] + assert parse_leagues({"leagues": []}) == [] + + leagues = parse_leagues( + { + "leagues": [ + {"id": 103, "link": "/api/v1/leagues/103", "name": "American League"}, + {"id": 104, "link": "/api/v1/leagues/104", "name": "National League"}, + ] + } + ) + + assert leagues == [ + League(id=103, link="/api/v1/leagues/103", name="American League"), + League(id=104, link="/api/v1/leagues/104", name="National League"), + ] + + +def test_parse_league(): + """parse_league builds a League from one league payload.""" + assert parse_league({}) is None + + league = parse_league( + {"leagues": [{"id": 103, "link": "/api/v1/leagues/103", "name": "American League"}]} + ) + + assert isinstance(league, League) + assert league == League(id=103, link="/api/v1/leagues/103", name="American League") + + +def test_parse_league_requires_link(): + """League requires link, the same required field used by the MLB API.""" + with pytest.raises(ValidationError): + parse_league({"leagues": [{"id": 103, "name": "American League"}]}) diff --git a/tests/parsers/test_roster_parser.py b/tests/parsers/test_roster_parser.py new file mode 100644 index 00000000..c8c11e26 --- /dev/null +++ b/tests/parsers/test_roster_parser.py @@ -0,0 +1,65 @@ +from mlbstatsapi._parsers.roster import parse_roster_coaches, parse_roster_players +from mlbstatsapi.models.people import Coach, Player + + +PLAYER_ROSTER_PAYLOAD = { + "roster": [ + { + "person": {"id": 675961, "fullName": "Alika Williams", "link": "/api/v1/people/675961"}, + "jerseyNumber": "12", + "status": {"code": "A", "description": "Active"}, + "parentTeamId": 133, + } + ] +} + +COACH_ROSTER_PAYLOAD = { + "roster": [ + { + "person": {"id": 117276, "fullName": "Mark Kotsay", "link": "/api/v1/people/117276"}, + "jerseyNumber": "7", + "job": "Manager", + "jobId": "MNGR", + "title": "Manager", + } + ] +} + + +def test_parse_roster_players(): + """parse_roster_players merges the nested person dict and returns Players.""" + assert parse_roster_players({}) == [] + assert parse_roster_players({"roster": []}) == [] + + players = parse_roster_players(PLAYER_ROSTER_PAYLOAD) + + assert players == [ + Player( + id=675961, + full_name="Alika Williams", + link="/api/v1/people/675961", + jersey_number="12", + status={"code": "A", "description": "Active"}, + parent_team_id=133, + ) + ] + + +def test_parse_roster_coaches(): + """parse_roster_coaches merges the nested person dict and returns Coaches.""" + assert parse_roster_coaches({}) == [] + assert parse_roster_coaches({"roster": []}) == [] + + coaches = parse_roster_coaches(COACH_ROSTER_PAYLOAD) + + assert coaches == [ + Coach( + id=117276, + full_name="Mark Kotsay", + link="/api/v1/people/117276", + jersey_number="7", + job="Manager", + job_id="MNGR", + title="Manager", + ) + ] diff --git a/tests/parsers/test_sports.py b/tests/parsers/test_sports.py new file mode 100644 index 00000000..a97f0615 --- /dev/null +++ b/tests/parsers/test_sports.py @@ -0,0 +1,43 @@ +import pytest +from pydantic import ValidationError + +from mlbstatsapi._parsers.sports import parse_sport, parse_sports +from mlbstatsapi.models.sports import Sport + + +def test_parse_sports(): + """parse_sports reads the MLB sports envelope and returns Sport models.""" + assert parse_sports({}) == [] + assert parse_sports({"sports": []}) == [] + + sports = parse_sports( + { + "sports": [ + {"id": 1, "link": "/api/v1/sports/1", "name": "Major League Baseball"}, + {"id": 11, "link": "/api/v1/sports/11", "name": "Triple-A"}, + ] + } + ) + + assert sports == [ + Sport(id=1, link="/api/v1/sports/1", name="Major League Baseball"), + Sport(id=11, link="/api/v1/sports/11", name="Triple-A"), + ] + + +def test_parse_sport(): + """parse_sport builds a Sport from one sport payload.""" + assert parse_sport({}) is None + + sport = parse_sport( + {"sports": [{"id": 1, "link": "/api/v1/sports/1", "name": "Major League Baseball"}]} + ) + + assert isinstance(sport, Sport) + assert sport == Sport(id=1, link="/api/v1/sports/1", name="Major League Baseball") + + +def test_parse_sport_requires_link(): + """Sport requires link, the same required field used by the MLB API.""" + with pytest.raises(ValidationError): + parse_sport({"sports": [{"id": 1, "name": "Major League Baseball"}]}) diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py index cafb2637..788907e5 100644 --- a/tests/test_async_mlb.py +++ b/tests/test_async_mlb.py @@ -38,8 +38,11 @@ from mlbstatsapi import Mlb # noqa: E402 from mlbstatsapi.async_mlb import AsyncMlb # noqa: E402 from mlbstatsapi.mlb_dataadapter import MlbResult # noqa: E402 -from mlbstatsapi.models.people import Person # noqa: E402 +from mlbstatsapi.models.divisions import Division # noqa: E402 +from mlbstatsapi.models.leagues import League # noqa: E402 +from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 from mlbstatsapi.models.schedules import Schedule # noqa: E402 +from mlbstatsapi.models.sports import Sport # noqa: E402 from mlbstatsapi.models.teams import Team # noqa: E402 @@ -47,6 +50,38 @@ PERSON_PAYLOAD = { "people": [{"id": 660271, "link": "/api/v1/people/660271", "fullName": "Shohei Ohtani"}] } +SPORT_PAYLOAD = { + "sports": [{"id": 1, "link": "/api/v1/sports/1", "name": "Major League Baseball"}] +} +LEAGUE_PAYLOAD = { + "leagues": [{"id": 103, "link": "/api/v1/leagues/103", "name": "American League"}] +} +DIVISION_PAYLOAD = { + "divisions": [ + {"id": 200, "link": "/api/v1/divisions/200", "name": "American League West"} + ] +} +ROSTER_PLAYER_PAYLOAD = { + "roster": [ + { + "person": {"id": 675961, "fullName": "Alika Williams", "link": "/api/v1/people/675961"}, + "jerseyNumber": "12", + "status": {"code": "A", "description": "Active"}, + "parentTeamId": 133, + } + ] +} +ROSTER_COACH_PAYLOAD = { + "roster": [ + { + "person": {"id": 117276, "fullName": "Mark Kotsay", "link": "/api/v1/people/117276"}, + "jerseyNumber": "7", + "job": "Manager", + "jobId": "MNGR", + "title": "Manager", + } + ] +} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -68,6 +103,28 @@ EXPECTED_PERSON = Person( id=660271, link="/api/v1/people/660271", full_name="Shohei Ohtani" ) +EXPECTED_SPORT = Sport(id=1, link="/api/v1/sports/1", name="Major League Baseball") +EXPECTED_LEAGUE = League(id=103, link="/api/v1/leagues/103", name="American League") +EXPECTED_DIVISION = Division( + id=200, link="/api/v1/divisions/200", name="American League West" +) +EXPECTED_ROSTER_PLAYER = Player( + id=675961, + full_name="Alika Williams", + link="/api/v1/people/675961", + jersey_number="12", + status={"code": "A", "description": "Active"}, + parent_team_id=133, +) +EXPECTED_ROSTER_COACH = Coach( + id=117276, + full_name="Mark Kotsay", + link="/api/v1/people/117276", + jersey_number="7", + job="Manager", + job_id="MNGR", + title="Manager", +) # The two ways an endpoint legitimately comes back with nothing to parse. NO_RESULT_RESPONSES = { @@ -406,6 +463,159 @@ async def scenario(): assert_matches_sync(handler.request, "get_people", 11, season="2021") +def test_get_sport_requests_the_sport_endpoint_and_parses_the_result(): + handler = _Handler(_json(SPORT_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_sport(1) + + sport = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_sport", 1) + assert sport == EXPECTED_SPORT + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_sport_returns_none_when_there_is_no_sport(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_sport(1) + + assert asyncio.run(scenario()) is None + + +def test_get_sports_request_matches_the_sync_client(): + handler = _Handler(_json({"sports": []})) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_sports() + + assert asyncio.run(scenario()) == [] + assert_matches_sync(handler.request, "get_sports") + + +def test_get_league_requests_the_league_endpoint_and_parses_the_result(): + handler = _Handler(_json(LEAGUE_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_league(103) + + league = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_league", 103) + assert league == EXPECTED_LEAGUE + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_league_returns_none_when_there_is_no_league(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_league(103) + + assert asyncio.run(scenario()) is None + + +def test_get_leagues_request_matches_the_sync_client(): + handler = _Handler(_json({"leagues": []})) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_leagues() + + assert asyncio.run(scenario()) == [] + assert_matches_sync(handler.request, "get_leagues") + + +def test_get_division_requests_the_division_endpoint_and_parses_the_result(): + handler = _Handler(_json(DIVISION_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_division(200) + + division = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_division", 200) + assert division == EXPECTED_DIVISION + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_division_returns_none_when_there_is_no_division(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_division(200) + + assert asyncio.run(scenario()) is None + + +def test_get_divisions_request_matches_the_sync_client(): + handler = _Handler(_json({"divisions": []})) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_divisions() + + assert asyncio.run(scenario()) == [] + assert_matches_sync(handler.request, "get_divisions") + + +def test_get_team_roster_requests_the_roster_endpoint_and_parses_the_result(): + handler = _Handler(_json(ROSTER_PLAYER_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_team_roster(133, rosterType="40Man") + + roster = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_team_roster", 133, rosterType="40Man") + assert roster == [EXPECTED_ROSTER_PLAYER] + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_team_roster_returns_empty_list_when_there_is_no_roster(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_team_roster(133) + + assert asyncio.run(scenario()) == [] + + +def test_get_team_coaches_requests_the_coaches_endpoint_and_parses_the_result(): + handler = _Handler(_json(ROSTER_COACH_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_team_coaches(133) + + coaches = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_team_coaches", 133) + assert coaches == [EXPECTED_ROSTER_COACH] + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_team_coaches_returns_empty_list_when_there_are_no_coaches(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_team_coaches(133) + + assert asyncio.run(scenario()) == [] + + # --------------------------------------------------------------------------- # Parity and concurrency # --------------------------------------------------------------------------- @@ -415,7 +625,21 @@ def test_public_signatures_match_the_sync_client(): """Argument names, kinds, and defaults must not drift from Mlb's.""" import inspect - for name in ("get_team", "get_teams", "get_person", "get_people", "get_schedule"): + for name in ( + "get_team", + "get_teams", + "get_team_roster", + "get_team_coaches", + "get_person", + "get_people", + "get_schedule", + "get_sport", + "get_sports", + "get_league", + "get_leagues", + "get_division", + "get_divisions", + ): sync_params = inspect.signature(getattr(Mlb, name)).parameters async_params = inspect.signature(getattr(AsyncMlb, name)).parameters diff --git a/tests/test_public_api.py b/tests/test_public_api.py index b76164dc..44b1bed6 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -238,12 +238,20 @@ def _normalize_signature(fn: Any) -> str: "__aexit__": "(exc_type, exc, traceback)", "get_team": "(team_id: int, **params)", "get_teams": "(sport_id: int=1, **params)", + "get_team_roster": "(team_id: int, **params)", + "get_team_coaches": "(team_id: int, **params)", "get_person": "(player_id: int, **params)", "get_people": "(sport_id: int=1, **params)", "get_schedule": ( "(date: str=None, start_date: str=None, end_date: str=None, " "sport_id: int=1, team_id: int=None, **params)" ), + "get_sport": "(sport_id: int, **params)", + "get_sports": "(**params)", + "get_league": "(league_id: int, **params)", + "get_leagues": "(**params)", + "get_division": "(division_id: int, **params)", + "get_divisions": "(**params)", } diff --git a/tests/test_sync_async_parity.py b/tests/test_sync_async_parity.py index 813e0507..ed19bd44 100644 --- a/tests/test_sync_async_parity.py +++ b/tests/test_sync_async_parity.py @@ -42,8 +42,11 @@ MlbTimeoutError, MlbTransportError, ) -from mlbstatsapi.models.people import Person # noqa: E402 +from mlbstatsapi.models.divisions import Division # noqa: E402 +from mlbstatsapi.models.leagues import League # noqa: E402 +from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 from mlbstatsapi.models.schedules import Schedule # noqa: E402 +from mlbstatsapi.models.sports import Sport # noqa: E402 from mlbstatsapi.models.teams import Team # noqa: E402 @@ -51,6 +54,38 @@ PERSON_PAYLOAD = { "people": [{"id": 660271, "link": "/api/v1/people/660271", "fullName": "Shohei Ohtani"}] } +SPORT_PAYLOAD = { + "sports": [{"id": 1, "link": "/api/v1/sports/1", "name": "Major League Baseball"}] +} +LEAGUE_PAYLOAD = { + "leagues": [{"id": 103, "link": "/api/v1/leagues/103", "name": "American League"}] +} +DIVISION_PAYLOAD = { + "divisions": [ + {"id": 200, "link": "/api/v1/divisions/200", "name": "American League West"} + ] +} +ROSTER_PLAYER_PAYLOAD = { + "roster": [ + { + "person": {"id": 675961, "fullName": "Alika Williams", "link": "/api/v1/people/675961"}, + "jerseyNumber": "12", + "status": {"code": "A", "description": "Active"}, + "parentTeamId": 133, + } + ] +} +ROSTER_COACH_PAYLOAD = { + "roster": [ + { + "person": {"id": 117276, "fullName": "Mark Kotsay", "link": "/api/v1/people/117276"}, + "jerseyNumber": "7", + "job": "Manager", + "jobId": "MNGR", + "title": "Manager", + } + ] +} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -315,6 +350,80 @@ def test_get_schedule_range_team_and_sport_request_parity(): ) +def test_get_sport_success_parity(): + """A successful sport response parses to the same Sport on both clients.""" + result = call_both("get_sport", 1, payload=SPORT_PAYLOAD) + + assert isinstance(result.sync, Sport), "sync get_sport did not return a Sport" + assert (result.sync.id, result.sync.link, result.sync.name) == ( + 1, + "/api/v1/sports/1", + "Major League Baseball", + ) + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/sports/1", {}) + + +def test_get_league_success_parity(): + """A successful league response parses to the same League on both clients.""" + result = call_both("get_league", 103, payload=LEAGUE_PAYLOAD) + + assert isinstance(result.sync, League), "sync get_league did not return a League" + assert (result.sync.id, result.sync.link, result.sync.name) == ( + 103, + "/api/v1/leagues/103", + "American League", + ) + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/leagues/103", {}) + + +def test_get_division_success_parity(): + """A successful division response parses to the same Division on both clients.""" + result = call_both("get_division", 200, payload=DIVISION_PAYLOAD) + + assert isinstance(result.sync, Division), "sync get_division did not return a Division" + assert (result.sync.id, result.sync.link, result.sync.name) == ( + 200, + "/api/v1/divisions/200", + "American League West", + ) + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/divisions/200", {}) + + +def test_get_team_roster_success_parity(): + """A successful roster response parses to the same Players on both clients.""" + result = call_both("get_team_roster", 133, payload=ROSTER_PLAYER_PAYLOAD) + + assert isinstance(result.sync, list) and isinstance(result.sync[0], Player), ( + "sync get_team_roster did not return a list of Player" + ) + assert (result.sync[0].id, result.sync[0].full_name, result.sync[0].jersey_number) == ( + 675961, + "Alika Williams", + "12", + ) + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/teams/133/roster", {}) + + +def test_get_team_coaches_success_parity(): + """A successful coaches response parses to the same Coaches on both clients.""" + result = call_both("get_team_coaches", 133, payload=ROSTER_COACH_PAYLOAD) + + assert isinstance(result.sync, list) and isinstance(result.sync[0], Coach), ( + "sync get_team_coaches did not return a list of Coach" + ) + assert (result.sync[0].id, result.sync[0].full_name, result.sync[0].job) == ( + 117276, + "Mark Kotsay", + "Manager", + ) + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/teams/133/coaches", {}) + + # --------------------------------------------------------------------------- # Nothing to return # --------------------------------------------------------------------------- @@ -355,6 +464,61 @@ def test_get_schedule_no_result_parity(label): ) +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_sport_no_result_parity(label): + """Every no-result response returns None on either client.""" + result = call_both("get_sport", 1, **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, f"sync get_sport returned {result.sync!r} for {label}" + assert result.asynchronous is None, ( + f"async get_sport returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_league_no_result_parity(label): + """Every no-result response returns None on either client.""" + result = call_both("get_league", 103, **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, f"sync get_league returned {result.sync!r} for {label}" + assert result.asynchronous is None, ( + f"async get_league returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_division_no_result_parity(label): + """Every no-result response returns None on either client.""" + result = call_both("get_division", 200, **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, f"sync get_division returned {result.sync!r} for {label}" + assert result.asynchronous is None, ( + f"async get_division returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_team_roster_no_result_parity(label): + """Every no-result response returns an empty list on either client.""" + result = call_both("get_team_roster", 133, **NO_RESULT_RESPONSES[label]) + + assert result.sync == [], f"sync get_team_roster returned {result.sync!r} for {label}" + assert result.asynchronous == [], ( + f"async get_team_roster returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_team_coaches_no_result_parity(label): + """Every no-result response returns an empty list on either client.""" + result = call_both("get_team_coaches", 133, **NO_RESULT_RESPONSES[label]) + + assert result.sync == [], f"sync get_team_coaches returned {result.sync!r} for {label}" + assert result.asynchronous == [], ( + f"async get_team_coaches returned {result.asynchronous!r} for {label}" + ) + + # --------------------------------------------------------------------------- # Representative public failure behavior (get_team) # --------------------------------------------------------------------------- From 19dc8a709198d2c37d675b373e5be4f021670a6d Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sat, 22 Aug 2026 11:41:14 -0700 Subject: [PATCH 42/81] feat(async): add get_season/get_seasons to AsyncMlb Continues issue #305's endpoint expansion. Adds a shared _parsers/seasons.py parser reused by both Mlb (refactored internally, no behavior change) and AsyncMlb, mirrors Mlb's docstrings onto the new AsyncMlb methods, and covers them with parser, endpoint, sync/async parity, and live external smoke tests. Renames the new tests/parsers/test_seasons.py to test_seasons_parser.py to avoid a pytest basename collision with the existing tests/external_tests/seasons/test_seasons.py (no test __init__.py packages exist yet, so basenames must be unique repo-wide); issue #322 tracks resolving this class of collision for good. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BtqT3jocrSJRouEew7Nnto --- docs/public-api.md | 2 + mlbstatsapi/_parsers/seasons.py | 21 ++++ mlbstatsapi/async_mlb.py | 115 ++++++++++++++++++ mlbstatsapi/mlb_api.py | 13 +- .../async_mlb/test_async_mlb_smoke.py | 12 ++ tests/parsers/test_seasons_parser.py | 32 +++++ tests/test_async_mlb.py | 40 ++++++ tests/test_public_api.py | 2 + tests/test_sync_async_parity.py | 23 ++++ 9 files changed, 250 insertions(+), 10 deletions(-) create mode 100644 mlbstatsapi/_parsers/seasons.py create mode 100644 tests/parsers/test_seasons_parser.py diff --git a/docs/public-api.md b/docs/public-api.md index a8ea0abb..cf190266 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -293,6 +293,8 @@ get_league(league_id: int, **params) get_leagues(**params) get_division(division_id: int, **params) get_divisions(**params) +get_season(season_id: str, sport_id: int = 1, **params) +get_seasons(sport_id: int = 1, **params) ``` Every other `Mlb` endpoint method not listed above is not yet supported on diff --git a/mlbstatsapi/_parsers/seasons.py b/mlbstatsapi/_parsers/seasons.py new file mode 100644 index 00000000..4bb1ca8e --- /dev/null +++ b/mlbstatsapi/_parsers/seasons.py @@ -0,0 +1,21 @@ +from mlbstatsapi.models.seasons import Season + + +def parse_seasons(data: dict) -> list[Season]: + """Parse Season models from an MLB /seasons response body. + + Expects the full response, e.g. ``{"seasons": [...]}``, not the inner list. + """ + if not data or not data.get("seasons"): + return [] + return [Season(**season) for season in data["seasons"]] + + +def parse_season(data: dict) -> Season | None: + """Parse a Season from a single season payload.""" + seasons = parse_seasons(data) + + if not seasons: + return None + + return seasons[0] diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index 3271d915..e9e66b9a 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -11,6 +11,7 @@ from ._parsers.people import parse_person, parse_people from ._parsers.roster import parse_roster_coaches, parse_roster_players from ._parsers.schedules import parse_schedule +from ._parsers.seasons import parse_season, parse_seasons from ._parsers.sports import parse_sport, parse_sports from ._parsers.teams import parse_team, parse_teams from .async_mlb_dataadapter import AsyncMlbDataAdapter @@ -19,6 +20,7 @@ from .models.leagues import League from .models.people import Coach, Person, Player from .models.schedules import Schedule +from .models.seasons import Season from .models.sports import Sport from .models.teams import Team @@ -903,3 +905,116 @@ async def get_divisions( return [] return parse_divisions(mlb_data.data) + + async def get_season( + self, + season_id: str, + sport_id: int = 1, + **params, + ) -> Season | None: + """ + return a season object for seasonid and sportid + + Async counterpart of ``Mlb.get_season``. + + Parameters + ---------- + sport_id : int + Insert a sportId to return a directory of seasons for a specific sport. + season_id : str + Insert year to return season information for a particular season. + + Other Parameters + ---------------- + withGameTypeDates : bool, optional + Insert a withGameTypeDates to return season information for all gameTypes. + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + Season + returns a season object + + See Also + -------- + AsyncMlb.get_seasons : return a list of seasons + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... season = await mlb.get_season(season_id="2021", sport_id=1) + Season + """ + if sport_id is not None: + params["sportId"] = sport_id + + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"seasons/{season_id}", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_season(mlb_data.data) + + async def get_seasons( + self, + sport_id: int = 1, + **params, + ) -> list[Season]: + """ + return a season object for sportid + + Async counterpart of ``Mlb.get_seasons``. + + Parameters + ---------- + sport_id : int + Insert a sportId to return a directory of seasons for a specific + sport. + + Other Parameters + ---------------- + divisionId : int, optional + Insert divisionId to return a directory of seasons for a specific + division. + leagueId : int, optional + Insert leagueId to return a directory of seasons in a specific + league. + withGameTypeDates : bool, optional + Insert a withGameTypeDates to return season information for all + gameTypes. + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + Season + returns a season object + + See Also + -------- + AsyncMlb.get_season : return a Season from season id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... seasons = await mlb.get_seasons(1) + [Season, Season, Season, Season] + """ + if sport_id is not None: + params["sportId"] = sport_id + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="seasons/all", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_seasons(mlb_data.data) diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index fa23befd..ed9354cf 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -27,6 +27,7 @@ from ._parsers.leagues import parse_leagues, parse_league from ._parsers.people import parse_people, parse_person from ._parsers.roster import parse_roster_coaches, parse_roster_players +from ._parsers.seasons import parse_seasons, parse_season from ._parsers.sports import parse_sports, parse_sport from ._parsers.teams import parse_teams, parse_team from ._parsers.schedules import parse_schedule @@ -1730,9 +1731,7 @@ def get_season(self, season_id: str, sport_id: int = 1, **params) -> Season: if 400 <= mlb_data.status_code <= 499: return None - if 'seasons' in mlb_data.data and mlb_data.data['seasons']: - for season in mlb_data.data['seasons']: - return Season(**season) + return parse_season(mlb_data.data) def get_seasons(self, sport_id: int = 1, **params) -> List[Season]: """ @@ -1787,13 +1786,7 @@ def get_seasons(self, sport_id: int = 1, **params) -> List[Season]: if 400 <= mlb_data.status_code <= 499: return [] - season_list = [] - - if 'seasons' in mlb_data.data and mlb_data.data['seasons']: - for season in mlb_data.data['seasons']: - season_list.append(Season(**season)) - - return season_list + return parse_seasons(mlb_data.data) def get_standings(self, league_id: int, season: str, **params): """ diff --git a/tests/external_tests/async_mlb/test_async_mlb_smoke.py b/tests/external_tests/async_mlb/test_async_mlb_smoke.py index 8d3f7238..f0143f47 100644 --- a/tests/external_tests/async_mlb/test_async_mlb_smoke.py +++ b/tests/external_tests/async_mlb/test_async_mlb_smoke.py @@ -5,6 +5,7 @@ from mlbstatsapi.models.leagues import League from mlbstatsapi.models.people import Coach, Person, Player from mlbstatsapi.models.schedules import Schedule +from mlbstatsapi.models.seasons import Season from mlbstatsapi.models.sports import Sport from mlbstatsapi.models.teams import Team @@ -95,3 +96,14 @@ async def scenario(): assert division.id == 200 asyncio.run(scenario()) + + +def test_async_get_season(): + async def scenario(): + async with AsyncMlb() as mlb: + season = await mlb.get_season("2021") + + assert isinstance(season, Season) + assert season.season_id == "2021" + + asyncio.run(scenario()) diff --git a/tests/parsers/test_seasons_parser.py b/tests/parsers/test_seasons_parser.py new file mode 100644 index 00000000..a9844e8d --- /dev/null +++ b/tests/parsers/test_seasons_parser.py @@ -0,0 +1,32 @@ +from mlbstatsapi._parsers.seasons import parse_season, parse_seasons +from mlbstatsapi.models.seasons import Season + + +def test_parse_seasons(): + """parse_seasons reads the MLB seasons envelope and returns Season models.""" + assert parse_seasons({}) == [] + assert parse_seasons({"seasons": []}) == [] + + seasons = parse_seasons( + { + "seasons": [ + {"seasonId": "2021", "hasWildcard": True}, + {"seasonId": "2022", "hasWildcard": True}, + ] + } + ) + + assert seasons == [ + Season(seasonId="2021", hasWildcard=True), + Season(seasonId="2022", hasWildcard=True), + ] + + +def test_parse_season(): + """parse_season builds a Season from one season payload.""" + assert parse_season({}) is None + + season = parse_season({"seasons": [{"seasonId": "2021", "hasWildcard": True}]}) + + assert isinstance(season, Season) + assert season == Season(seasonId="2021", hasWildcard=True) diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py index 788907e5..653982b7 100644 --- a/tests/test_async_mlb.py +++ b/tests/test_async_mlb.py @@ -42,6 +42,7 @@ from mlbstatsapi.models.leagues import League # noqa: E402 from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 from mlbstatsapi.models.schedules import Schedule # noqa: E402 +from mlbstatsapi.models.seasons import Season # noqa: E402 from mlbstatsapi.models.sports import Sport # noqa: E402 from mlbstatsapi.models.teams import Team # noqa: E402 @@ -82,6 +83,7 @@ } ] } +SEASON_PAYLOAD = {"seasons": [{"seasonId": "2021", "hasWildcard": True}]} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -125,6 +127,7 @@ job_id="MNGR", title="Manager", ) +EXPECTED_SEASON = Season(seasonId="2021", hasWildcard=True) # The two ways an endpoint legitimately comes back with nothing to parse. NO_RESULT_RESPONSES = { @@ -616,6 +619,41 @@ async def scenario(): assert asyncio.run(scenario()) == [] +def test_get_season_requests_the_season_endpoint_and_parses_the_result(): + handler = _Handler(_json(SEASON_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_season("2021") + + season = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_season", "2021") + assert season == EXPECTED_SEASON + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_season_returns_none_when_there_is_no_season(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_season("2021") + + assert asyncio.run(scenario()) is None + + +def test_get_seasons_request_matches_the_sync_client(): + handler = _Handler(_json({"seasons": []})) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_seasons(11) + + assert asyncio.run(scenario()) == [] + assert_matches_sync(handler.request, "get_seasons", 11) + + # --------------------------------------------------------------------------- # Parity and concurrency # --------------------------------------------------------------------------- @@ -639,6 +677,8 @@ def test_public_signatures_match_the_sync_client(): "get_leagues", "get_division", "get_divisions", + "get_season", + "get_seasons", ): sync_params = inspect.signature(getattr(Mlb, name)).parameters async_params = inspect.signature(getattr(AsyncMlb, name)).parameters diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 44b1bed6..ad32a50d 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -252,6 +252,8 @@ def _normalize_signature(fn: Any) -> str: "get_leagues": "(**params)", "get_division": "(division_id: int, **params)", "get_divisions": "(**params)", + "get_season": "(season_id: str, sport_id: int=1, **params)", + "get_seasons": "(sport_id: int=1, **params)", } diff --git a/tests/test_sync_async_parity.py b/tests/test_sync_async_parity.py index ed19bd44..c984e780 100644 --- a/tests/test_sync_async_parity.py +++ b/tests/test_sync_async_parity.py @@ -46,6 +46,7 @@ from mlbstatsapi.models.leagues import League # noqa: E402 from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 from mlbstatsapi.models.schedules import Schedule # noqa: E402 +from mlbstatsapi.models.seasons import Season # noqa: E402 from mlbstatsapi.models.sports import Sport # noqa: E402 from mlbstatsapi.models.teams import Team # noqa: E402 @@ -86,6 +87,7 @@ } ] } +SEASON_PAYLOAD = {"seasons": [{"seasonId": "2021", "hasWildcard": True}]} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -424,6 +426,16 @@ def test_get_team_coaches_success_parity(): assert result.request == ("GET", "/api/v1/teams/133/coaches", {}) +def test_get_season_success_parity(): + """A successful season response parses to the same Season on both clients.""" + result = call_both("get_season", "2021", payload=SEASON_PAYLOAD) + + assert isinstance(result.sync, Season), "sync get_season did not return a Season" + assert (result.sync.season_id, result.sync.has_wildcard) == ("2021", True) + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/seasons/2021", {"sportId": "1"}) + + # --------------------------------------------------------------------------- # Nothing to return # --------------------------------------------------------------------------- @@ -519,6 +531,17 @@ def test_get_team_coaches_no_result_parity(label): ) +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_season_no_result_parity(label): + """Every no-result response returns None on either client.""" + result = call_both("get_season", "2021", **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, f"sync get_season returned {result.sync!r} for {label}" + assert result.asynchronous is None, ( + f"async get_season returned {result.asynchronous!r} for {label}" + ) + + # --------------------------------------------------------------------------- # Representative public failure behavior (get_team) # --------------------------------------------------------------------------- From ee945e4803607dde9e1527d348f4a6557ecd3dec Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sat, 22 Aug 2026 12:16:43 -0700 Subject: [PATCH 43/81] feat(async): add get_venue/get_venues and get_standings/get_attendance to AsyncMlb Continues issue #305's endpoint expansion in two closely related groups that landed together: - get_venue/get_venues: new shared _parsers/venues.py, reused by Mlb (refactored internally, no behavior change) and AsyncMlb. get_venue faithfully preserves Mlb's documented quirk of returning [] (not None) on a 400-499 status while still falling through to None on an empty 200. Also fixes the shared assert_matches_sync test helper, which only handled scalar query params and silently mis-compared get_venue's list-valued hydrate param; it now correctly expands list values into repeated query pairs, matching how Requests/HTTPX actually serialize them. - get_standings/get_attendance: new shared _parsers/standings.py and _parsers/attendance.py. While porting get_attendance, found and fixed a real bug in Mlb: its "at least one of team_id/league_id/league_list_id" guard used any(required_args), which iterates dict keys (always truthy) instead of values, so the guard never fired and a bare get_attendance() call silently issued an unfiltered request. Fixed to any(required_args.values()) with a regression test proving the guard now short-circuits, and both clients port the corrected behavior identically. Each endpoint gets parser, AsyncMlb endpoint, sync/async parity, and live external smoke test coverage, plus the frozen ASYNC_MLB_PUBLIC_METHOD_MANIFEST and docs/public-api.md are extended accordingly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BtqT3jocrSJRouEew7Nnto --- docs/public-api.md | 20 ++ mlbstatsapi/_parsers/attendance.py | 8 + mlbstatsapi/_parsers/standings.py | 8 + mlbstatsapi/_parsers/venues.py | 21 ++ mlbstatsapi/async_mlb.py | 266 ++++++++++++++++++ mlbstatsapi/mlb_api.py | 31 +- .../async_mlb/test_async_mlb_smoke.py | 35 +++ tests/parsers/test_attendance_parser.py | 50 ++++ tests/parsers/test_standings_parser.py | 92 ++++++ tests/parsers/test_venues.py | 32 +++ tests/test_async_mlb.py | 252 ++++++++++++++++- tests/test_mlb_attendance.py | 88 ++++++ tests/test_public_api.py | 7 + tests/test_sync_async_parity.py | 212 ++++++++++++++ 14 files changed, 1099 insertions(+), 23 deletions(-) create mode 100644 mlbstatsapi/_parsers/attendance.py create mode 100644 mlbstatsapi/_parsers/standings.py create mode 100644 mlbstatsapi/_parsers/venues.py create mode 100644 tests/parsers/test_attendance_parser.py create mode 100644 tests/parsers/test_standings_parser.py create mode 100644 tests/parsers/test_venues.py create mode 100644 tests/test_mlb_attendance.py diff --git a/docs/public-api.md b/docs/public-api.md index cf190266..845ea70b 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -295,8 +295,22 @@ get_division(division_id: int, **params) get_divisions(**params) get_season(season_id: str, sport_id: int = 1, **params) get_seasons(sport_id: int = 1, **params) +get_venue(venue_id: int, **params) +get_venues(**params) +get_standings(league_id: int, season: str, **params) +get_attendance( + team_id: int = None, + league_id: int = None, + league_list_id: str = None, + **params, +) ``` +`get_venue` inherits the same documented quirk as `Mlb.get_venue`: it is +annotated `Venue | None` but returns `[]` (not `None`) on a 400–499 response, +matching the sync behavior noted above. This is preserved for parity, not +introduced by the async port. + Every other `Mlb` endpoint method not listed above is not yet supported on `AsyncMlb`; calling it there raises `AttributeError`. See issue #305 for the tracked expansion plan. @@ -537,6 +551,12 @@ Notes and known conflicts (documented, not redesigned by this contract): * `get_homerun_derby` currently executes a bare `None` expression on 400–499 instead of `return None`, so execution may continue. A focused bugfix is recommended. +* `get_attendance`'s "at least one of `team_id`/`league_id`/`league_list_id`" + guard previously used `any(required_args)`, which iterates dict keys + (always truthy) rather than values, so the guard never actually fired. This + was fixed to `any(required_args.values())` while porting the endpoint to + `AsyncMlb` (issue #305); calling either client with no identifier now + returns `None` without making a request, as already documented above. * Nested Pydantic model fields are not frozen by this contract. ## Return-contract boundaries diff --git a/mlbstatsapi/_parsers/attendance.py b/mlbstatsapi/_parsers/attendance.py new file mode 100644 index 00000000..275cb1b7 --- /dev/null +++ b/mlbstatsapi/_parsers/attendance.py @@ -0,0 +1,8 @@ +from mlbstatsapi.models.attendances import Attendance + + +def parse_attendance(data: dict) -> Attendance | None: + """Parse an Attendance from an MLB /attendance response body.""" + if not data or not data.get("records"): + return None + return Attendance(**data) diff --git a/mlbstatsapi/_parsers/standings.py b/mlbstatsapi/_parsers/standings.py new file mode 100644 index 00000000..ff22603c --- /dev/null +++ b/mlbstatsapi/_parsers/standings.py @@ -0,0 +1,8 @@ +from mlbstatsapi.models.standings import Standings + + +def parse_standings(data: dict) -> list[Standings]: + """Parse Standings models from an MLB /standings response body.""" + if not data or not data.get("records"): + return [] + return [Standings(**standing) for standing in data["records"]] diff --git a/mlbstatsapi/_parsers/venues.py b/mlbstatsapi/_parsers/venues.py new file mode 100644 index 00000000..7004b19c --- /dev/null +++ b/mlbstatsapi/_parsers/venues.py @@ -0,0 +1,21 @@ +from mlbstatsapi.models.venues import Venue + + +def parse_venues(data: dict) -> list[Venue]: + """Parse Venue models from an MLB /venues response body. + + Expects the full response, e.g. ``{"venues": [...]}``, not the inner list. + """ + if not data or not data.get("venues"): + return [] + return [Venue(**venue) for venue in data["venues"]] + + +def parse_venue(data: dict) -> Venue | None: + """Parse a Venue from a single venue payload.""" + venues = parse_venues(data) + + if not venues: + return None + + return venues[0] diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index e9e66b9a..1768fafd 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING from ._helpers.schedule import build_schedule_params +from ._parsers.attendance import parse_attendance from ._parsers.divisions import parse_division, parse_divisions from ._parsers.leagues import parse_league, parse_leagues from ._parsers.people import parse_person, parse_people @@ -13,16 +14,21 @@ from ._parsers.schedules import parse_schedule from ._parsers.seasons import parse_season, parse_seasons from ._parsers.sports import parse_sport, parse_sports +from ._parsers.standings import parse_standings from ._parsers.teams import parse_team, parse_teams +from ._parsers.venues import parse_venue, parse_venues from .async_mlb_dataadapter import AsyncMlbDataAdapter from .mlb_dataadapter import DEFAULT_TIMEOUT, TimeoutType +from .models.attendances import Attendance from .models.divisions import Division from .models.leagues import League from .models.people import Coach, Person, Player from .models.schedules import Schedule from .models.seasons import Season from .models.sports import Sport +from .models.standings import Standings from .models.teams import Team +from .models.venues import Venue if TYPE_CHECKING: import httpx @@ -1018,3 +1024,263 @@ async def get_seasons( return [] return parse_seasons(mlb_data.data) + + async def get_venue( + self, + venue_id: int, + **params, + ) -> Venue | None: + """ + returns venue directorial information for all available venues in the Stats API. + + Async counterpart of ``Mlb.get_venue``. + + Parameters + ---------- + venue_id : int + venueId to return venue directorial information based venueId. + + Other Parameters + ---------------- + fields : str + Comma delimited list of specific fields to be returned. + + Returns + ------- + Venue + + See Also + -------- + AsyncMlb.get_venues : return a list of Venues + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... venue = await mlb.get_venue(31) + Venue + """ + params["hydrate"] = ["location", "fieldInfo", "timezone"] + + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"venues/{venue_id}", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + # Documented quirk: this returns [] rather than None here, unlike + # every other single-resource endpoint, matching Mlb.get_venue. + # See docs/public-api.md. + return [] + + return parse_venue(mlb_data.data) + + async def get_venues( + self, + **params, + ) -> list[Venue]: + """ + return all venues + + Async counterpart of ``Mlb.get_venues``. + + Returns + ------- + list of Venues + returns a list of Venues + + Other Parameters + ---------------- + venueIds : int, List[int] + Insert venueId to return venue directorial information based + venueId. + sportIds : int, List[int] + Insert sportIds to return venue directorial information based a + given sport(s). For a list of all sports: + https://statsapi.mlb.com/api/v1/sports + season : int + Insert year to return venue directorial information for a given + season. + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + See Also + -------- + AsyncMlb.get_venue : return a Venue + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... venues = await mlb.get_venues() + [Venue, Venue, Venue] + """ + params["hydrate"] = ["location", "fieldInfo", "timezone"] + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="venues", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_venues(mlb_data.data) + + async def get_standings( + self, + league_id: int, + season: str, + **params, + ) -> list[Standings]: + """ + return a list of standings for league_id and season + + Async counterpart of ``Mlb.get_standings``. + + Parameters + ---------- + league_id : str + Insert leagueId to return all standings based on a particular + standingType for a specific league. + season : str + Insert year to return all standings based on a particular year. + + Other Parameters + ---------------- + standingsTypes : str + Insert standingType to return all standings based on a particular + year. + Description of all standingTypes: + regularSeason - Regular Season Standings + wildCard - Wild card standings + divisionLeaders - Division Leader standings + wildCardWithLeaders - Wild card standings with Division + Leaders firstHalf - First half standings. Only valid for + leagues with a split season + (Mexican League). + secondHalf - Second half standings. Only valid for leagues + with a split season (Mexican League). + springTraining - Spring Training Standings + postseason - Postseason Standings + byDivision - Standings by Division + byConference - Standings by Conference + byLeague - Standings by League + Find standingTypes at https://statsapi.mlb.com/api/v1/standingsTypes + date : str + Insert date to return standing information for on a particular + date. Format: MM/DD/YYYY + hydrate : str + Insert Hydration(s) to return data for any available standings + hydration. Format "team,league" + Available Hydrations: + team + league + division + sport + conference + record(conference) + record(division) + fields : str + Comma delimited list of specific fields to be returned. Format: topLevelNode, childNode, attribute + + Returns + ------- + list of Standings + returns a list of Standings + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... standings = await mlb.get_standings(103, "2022") + [Standings, Standings, Standings] + """ + if league_id is not None: + params["leagueId"] = league_id + + if season is not None: + params["season"] = season + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="standings", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_standings(mlb_data.data) + + async def get_attendance( + self, + team_id: int = None, + league_id: int = None, + league_list_id: str = None, + **params, + ) -> Attendance | None: + """ + returns attendance data based on teamId, leagueId, or leagueListId. + + Async counterpart of ``Mlb.get_attendance``. + + Required Parameters (at least one) + ---------- + team_id : int + Insert a teamId to return directory of attendnace for a given team + league_id : int + Insert leagueId(s) to return a directory of attendanace for a + specific league. Format '103,104' + league_list_id : str + Insert a unique League List Identifier to return a directory of + attendanace for a specific league listId. + Available values : milb_full, milb_short, milb_complex, milb_all, + milb_all_nomex, milb_all_domestic, milb_noncomp, + milb_noncomp_nomex, milb_domcomp, milb_intcomp, win_noabl, + win_caribbean, win_all, abl, mlb, mlb_hist, mlb_milb, + mlb_milb_hist, mlb_milb_win, baseball_all + + Parameters + ---------- + season : int + Insert year(s) to return a directory of attendance for a given + season. Season year number format yyyy + date : str 'yyyy-mm-dd' + Insert date to return information for attendance on a particular + date. Format: MM/DD/YYYY + gametype : str + Insert gameType(s) a directory of attendance for a given gameType. + For a list of all gameTypes: + https://statsapi.mlb.com/api/v1/gameTypes + + Returns + ------- + Attendance + + See Also + -------- + AsyncMlb.get_leagues : return a list of Leagues + AsyncMlb.get_venues : return a list of Venues + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... attendance = await mlb.get_attendance(team_id=133, season=2022) + Attendance + """ + required_args = {"teamId": team_id, "leagueId": league_id, "leagueListId": league_list_id} + + if not any(required_args.values()): + return None + + for arg_name, arg_value in required_args.items(): + if arg_value: + params[arg_name] = arg_value + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="attendance", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_attendance(mlb_data.data) diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index ed9354cf..4339bd81 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -23,14 +23,17 @@ from mlbstatsapi.models.standings import Standings +from ._parsers.attendance import parse_attendance from ._parsers.divisions import parse_divisions, parse_division from ._parsers.leagues import parse_leagues, parse_league from ._parsers.people import parse_people, parse_person from ._parsers.roster import parse_roster_coaches, parse_roster_players from ._parsers.seasons import parse_seasons, parse_season from ._parsers.sports import parse_sports, parse_sport +from ._parsers.standings import parse_standings from ._parsers.teams import parse_teams, parse_team from ._parsers.schedules import parse_schedule +from ._parsers.venues import parse_venues, parse_venue from .mlb_dataadapter import ( DEFAULT_TIMEOUT, @@ -1241,11 +1244,11 @@ def get_venue(self, venue_id: int, **params) -> Union[Venue, None]: mlb_data = self._mlb_adapter_v1.get(endpoint=f'venues/{venue_id}', ep_params=params) if 400 <= mlb_data.status_code <= 499: + # Documented quirk: this returns [] rather than None here, unlike + # every other single-resource endpoint. See docs/public-api.md. return [] - if 'venues' in mlb_data.data and mlb_data.data['venues']: - for venue in mlb_data.data['venues']: - return Venue(**venue) + return parse_venue(mlb_data.data) def get_venues(self, **params) -> List[Venue]: """ @@ -1289,12 +1292,7 @@ def get_venues(self, **params) -> List[Venue]: if 400 <= mlb_data.status_code <= 499: return [] - venues = [] - - if 'venues' in mlb_data.data and mlb_data.data['venues']: - venues = [Venue(**venue) for venue in mlb_data.data['venues']] - - return venues + return parse_venues(mlb_data.data) def get_venue_id(self, venue_name: str, search_key: str = 'name', **params) -> List[int]: @@ -1857,14 +1855,8 @@ def get_standings(self, league_id: int, season: str, **params): mlb_data = self._mlb_adapter_v1.get(endpoint=f'standings', ep_params=params) if 400 <= mlb_data.status_code <= 499: return [] - - standings_list = [] - if 'records' in mlb_data.data and mlb_data.data['records']: - for standing in mlb_data.data['records']: - standings_list.append(Standings(**standing)) - - return standings_list + return parse_standings(mlb_data.data) def get_attendance(self, team_id: int = None, league_id: int = None, @@ -1919,8 +1911,8 @@ def get_attendance(self, team_id: int = None, league_id: int = None, """ required_args = {'teamId': team_id, 'leagueId': league_id, 'leagueListId': league_list_id} - if not any(required_args): - return + if not any(required_args.values()): + return None # let's create a list of the args passed # this will filter out None @@ -1932,8 +1924,7 @@ def get_attendance(self, team_id: int = None, league_id: int = None, if 400 <= mlb_data.status_code <= 499: return None - if 'records' in mlb_data.data and mlb_data.data['records']: - return Attendance(**mlb_data.data) + return parse_attendance(mlb_data.data) def get_draft(self, year_id: int, **params) -> List[Round]: """ diff --git a/tests/external_tests/async_mlb/test_async_mlb_smoke.py b/tests/external_tests/async_mlb/test_async_mlb_smoke.py index f0143f47..e765c26e 100644 --- a/tests/external_tests/async_mlb/test_async_mlb_smoke.py +++ b/tests/external_tests/async_mlb/test_async_mlb_smoke.py @@ -1,13 +1,16 @@ import asyncio from mlbstatsapi import AsyncMlb +from mlbstatsapi.models.attendances import Attendance from mlbstatsapi.models.divisions import Division from mlbstatsapi.models.leagues import League from mlbstatsapi.models.people import Coach, Person, Player from mlbstatsapi.models.schedules import Schedule from mlbstatsapi.models.seasons import Season from mlbstatsapi.models.sports import Sport +from mlbstatsapi.models.standings import Standings from mlbstatsapi.models.teams import Team +from mlbstatsapi.models.venues import Venue def test_async_get_team(): @@ -107,3 +110,35 @@ async def scenario(): assert season.season_id == "2021" asyncio.run(scenario()) + + +def test_async_get_venue(): + async def scenario(): + async with AsyncMlb() as mlb: + venue = await mlb.get_venue(31) + + assert isinstance(venue, Venue) + assert venue.id == 31 + + asyncio.run(scenario()) + + +def test_async_get_standings(): + async def scenario(): + async with AsyncMlb() as mlb: + standings = await mlb.get_standings(103, "2022") + + assert standings + assert isinstance(standings[0], Standings) + + asyncio.run(scenario()) + + +def test_async_get_attendance(): + async def scenario(): + async with AsyncMlb() as mlb: + attendance = await mlb.get_attendance(team_id=133, season=2022) + + assert isinstance(attendance, Attendance) + + asyncio.run(scenario()) diff --git a/tests/parsers/test_attendance_parser.py b/tests/parsers/test_attendance_parser.py new file mode 100644 index 00000000..4b101d54 --- /dev/null +++ b/tests/parsers/test_attendance_parser.py @@ -0,0 +1,50 @@ +from mlbstatsapi._parsers.attendance import parse_attendance +from mlbstatsapi.models.attendances import Attendance + + +ATTENDANCE_PAYLOAD = { + "records": [ + { + "openingsTotal": 160, + "openingsTotalAway": 81, + "openingsTotalHome": 79, + "openingsTotalLost": 2, + "gamesTotal": 162, + "gamesAwayTotal": 82, + "gamesHomeTotal": 80, + "year": "2022", + "attendanceAverageYtd": 18103, + "attendanceHigh": 40065, + "attendanceHighDate": "2022-08-06T00:00:00", + "attendanceTotal": 2896460, + "attendanceTotalAway": 2108558, + "attendanceTotalHome": 787902, + "gameType": {"id": "R", "description": "Regular Season"}, + "team": {"id": 133, "name": "Oakland Athletics", "link": "/api/v1/teams/133"}, + } + ], + "aggregateTotals": { + "openingsTotalAway": 81, + "openingsTotalHome": 79, + "openingsTotalLost": 2, + "openingsTotalYtd": 0, + "attendanceAverageYtd": 18103, + "attendanceHigh": 40065, + "attendanceHighDate": "2022-08-06T00:00:00", + "attendanceTotal": 2896460, + "attendanceTotalAway": 2108558, + "attendanceTotalHome": 787902, + }, +} + + +def test_parse_attendance(): + """parse_attendance builds an Attendance when records is non-empty.""" + assert parse_attendance({}) is None + assert parse_attendance({"records": []}) is None + + attendance = parse_attendance(ATTENDANCE_PAYLOAD) + + assert isinstance(attendance, Attendance) + assert attendance.aggregate_totals.attendance_total == 2896460 + assert attendance.records[0].team.name == "Oakland Athletics" diff --git a/tests/parsers/test_standings_parser.py b/tests/parsers/test_standings_parser.py new file mode 100644 index 00000000..8e7af17a --- /dev/null +++ b/tests/parsers/test_standings_parser.py @@ -0,0 +1,92 @@ +from mlbstatsapi._parsers.standings import parse_standings +from mlbstatsapi.models.standings import Standings + + +STANDINGS_RECORD = { + "standingsType": "regularSeason", + "league": {"id": 103, "link": "/api/v1/league/103"}, + "division": {"id": 201, "link": "/api/v1/divisions/201"}, + "sport": {"id": 1, "link": "/api/v1/sports/1"}, + "roundRobin": {"status": "false"}, + "lastUpdated": "2025-10-16T23:15:55.082Z", + "teamRecords": [ + { + "team": {"id": 147, "name": "Yankees", "link": "/api/v1/teams/147"}, + "season": "2022", + "streak": {"streakCode": "L2", "streakType": "losses", "streakNumber": 2}, + "clinchIndicator": "y", + "divisionRank": "1", + "leagueRank": "2", + "sportRank": "5", + "gamesPlayed": 162, + "gamesBack": "-", + "wildCardGamesBack": "-", + "leagueGamesBack": "7.0", + "springLeagueGamesBack": "-", + "sportGamesBack": "7.0", + "divisionGamesBack": "-", + "conferenceGamesBack": "-", + "leagueRecord": {"wins": 99, "losses": 63, "ties": 0, "pct": ".611"}, + "lastUpdated": "2025-10-16T23:14:26Z", + "records": { + "splitRecords": [{"wins": 57, "losses": 24, "type": "home", "pct": ".704"}], + "divisionRecords": [ + { + "wins": 17, + "losses": 16, + "pct": ".515", + "division": { + "id": 200, + "name": "American League West", + "link": "/api/v1/divisions/200", + }, + } + ], + "overallRecords": [{"wins": 57, "losses": 24, "type": "home", "pct": ".704"}], + "leagueRecords": [ + { + "wins": 89, + "losses": 53, + "pct": ".627", + "league": { + "id": 103, + "name": "American League", + "link": "/api/v1/league/103", + }, + } + ], + "expectedRecords": [ + {"wins": 106, "losses": 56, "type": "xWinLoss", "pct": ".654"} + ], + }, + "runsAllowed": 567, + "runsScored": 807, + "divisionChamp": True, + "divisionLeader": True, + "hasWildcard": True, + "clinched": True, + "eliminationNumber": "-", + "eliminationNumberSport": "E", + "eliminationNumberLeague": "E", + "eliminationNumberDivision": "-", + "eliminationNumberConference": "E", + "wildCardEliminationNumber": "-", + "magicNumber": "-", + "wins": 99, + "losses": 63, + "runDifferential": 240, + "winningPercentage": ".611", + } + ], +} + + +def test_parse_standings(): + """parse_standings reads the MLB standings envelope and returns Standings models.""" + assert parse_standings({}) == [] + assert parse_standings({"records": []}) == [] + + standings = parse_standings({"records": [STANDINGS_RECORD]}) + + assert standings == [Standings(**STANDINGS_RECORD)] + assert standings[0].team_records[0].team.name == "Yankees" diff --git a/tests/parsers/test_venues.py b/tests/parsers/test_venues.py new file mode 100644 index 00000000..879a7fc8 --- /dev/null +++ b/tests/parsers/test_venues.py @@ -0,0 +1,32 @@ +from mlbstatsapi._parsers.venues import parse_venue, parse_venues +from mlbstatsapi.models.venues import Venue + + +def test_parse_venues(): + """parse_venues reads the MLB venues envelope and returns Venue models.""" + assert parse_venues({}) == [] + assert parse_venues({"venues": []}) == [] + + venues = parse_venues( + { + "venues": [ + {"id": 31, "link": "/api/v1/venues/31", "name": "PNC Park"}, + {"id": 1, "link": "/api/v1/venues/1", "name": "Angel Stadium"}, + ] + } + ) + + assert venues == [ + Venue(id=31, link="/api/v1/venues/31", name="PNC Park"), + Venue(id=1, link="/api/v1/venues/1", name="Angel Stadium"), + ] + + +def test_parse_venue(): + """parse_venue builds a Venue from one venue payload.""" + assert parse_venue({}) is None + + venue = parse_venue({"venues": [{"id": 31, "link": "/api/v1/venues/31", "name": "PNC Park"}]}) + + assert isinstance(venue, Venue) + assert venue == Venue(id=31, link="/api/v1/venues/31", name="PNC Park") diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py index 653982b7..4bcc74f8 100644 --- a/tests/test_async_mlb.py +++ b/tests/test_async_mlb.py @@ -38,13 +38,16 @@ from mlbstatsapi import Mlb # noqa: E402 from mlbstatsapi.async_mlb import AsyncMlb # noqa: E402 from mlbstatsapi.mlb_dataadapter import MlbResult # noqa: E402 +from mlbstatsapi.models.attendances import Attendance # noqa: E402 from mlbstatsapi.models.divisions import Division # noqa: E402 from mlbstatsapi.models.leagues import League # noqa: E402 from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 from mlbstatsapi.models.schedules import Schedule # noqa: E402 from mlbstatsapi.models.seasons import Season # noqa: E402 from mlbstatsapi.models.sports import Sport # noqa: E402 +from mlbstatsapi.models.standings import Standings # noqa: E402 from mlbstatsapi.models.teams import Team # noqa: E402 +from mlbstatsapi.models.venues import Venue # noqa: E402 TEAM_PAYLOAD = {"teams": [{"id": 133, "link": "/api/v1/teams/133", "name": "Athletics"}]} @@ -84,6 +87,119 @@ ] } SEASON_PAYLOAD = {"seasons": [{"seasonId": "2021", "hasWildcard": True}]} +VENUE_PAYLOAD = {"venues": [{"id": 31, "link": "/api/v1/venues/31", "name": "PNC Park"}]} +STANDINGS_RECORD = { + "standingsType": "regularSeason", + "league": {"id": 103, "link": "/api/v1/league/103"}, + "division": {"id": 201, "link": "/api/v1/divisions/201"}, + "sport": {"id": 1, "link": "/api/v1/sports/1"}, + "roundRobin": {"status": "false"}, + "lastUpdated": "2025-10-16T23:15:55.082Z", + "teamRecords": [ + { + "team": {"id": 147, "name": "Yankees", "link": "/api/v1/teams/147"}, + "season": "2022", + "streak": {"streakCode": "L2", "streakType": "losses", "streakNumber": 2}, + "clinchIndicator": "y", + "divisionRank": "1", + "leagueRank": "2", + "sportRank": "5", + "gamesPlayed": 162, + "gamesBack": "-", + "wildCardGamesBack": "-", + "leagueGamesBack": "7.0", + "springLeagueGamesBack": "-", + "sportGamesBack": "7.0", + "divisionGamesBack": "-", + "conferenceGamesBack": "-", + "leagueRecord": {"wins": 99, "losses": 63, "ties": 0, "pct": ".611"}, + "lastUpdated": "2025-10-16T23:14:26Z", + "records": { + "splitRecords": [{"wins": 57, "losses": 24, "type": "home", "pct": ".704"}], + "divisionRecords": [ + { + "wins": 17, + "losses": 16, + "pct": ".515", + "division": { + "id": 200, + "name": "American League West", + "link": "/api/v1/divisions/200", + }, + } + ], + "overallRecords": [{"wins": 57, "losses": 24, "type": "home", "pct": ".704"}], + "leagueRecords": [ + { + "wins": 89, + "losses": 53, + "pct": ".627", + "league": { + "id": 103, + "name": "American League", + "link": "/api/v1/league/103", + }, + } + ], + "expectedRecords": [ + {"wins": 106, "losses": 56, "type": "xWinLoss", "pct": ".654"} + ], + }, + "runsAllowed": 567, + "runsScored": 807, + "divisionChamp": True, + "divisionLeader": True, + "hasWildcard": True, + "clinched": True, + "eliminationNumber": "-", + "eliminationNumberSport": "E", + "eliminationNumberLeague": "E", + "eliminationNumberDivision": "-", + "eliminationNumberConference": "E", + "wildCardEliminationNumber": "-", + "magicNumber": "-", + "wins": 99, + "losses": 63, + "runDifferential": 240, + "winningPercentage": ".611", + } + ], +} +STANDINGS_PAYLOAD = {"records": [STANDINGS_RECORD]} +ATTENDANCE_PAYLOAD = { + "records": [ + { + "openingsTotal": 160, + "openingsTotalAway": 81, + "openingsTotalHome": 79, + "openingsTotalLost": 2, + "gamesTotal": 162, + "gamesAwayTotal": 82, + "gamesHomeTotal": 80, + "year": "2022", + "attendanceAverageYtd": 18103, + "attendanceHigh": 40065, + "attendanceHighDate": "2022-08-06T00:00:00", + "attendanceTotal": 2896460, + "attendanceTotalAway": 2108558, + "attendanceTotalHome": 787902, + "gameType": {"id": "R", "description": "Regular Season"}, + "team": {"id": 133, "name": "Oakland Athletics", "link": "/api/v1/teams/133"}, + } + ], + "aggregateTotals": { + "openingsTotalAway": 81, + "openingsTotalHome": 79, + "openingsTotalLost": 2, + "openingsTotalYtd": 0, + "attendanceAverageYtd": 18103, + "attendanceHigh": 40065, + "attendanceHighDate": "2022-08-06T00:00:00", + "attendanceTotal": 2896460, + "attendanceTotalAway": 2108558, + "attendanceTotalHome": 787902, + }, +} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -128,6 +244,7 @@ title="Manager", ) EXPECTED_SEASON = Season(seasonId="2021", hasWildcard=True) +EXPECTED_VENUE = Venue(id=31, link="/api/v1/venues/31", name="PNC Park") # The two ways an endpoint legitimately comes back with nothing to parse. NO_RESULT_RESPONSES = { @@ -208,7 +325,26 @@ def sync_request_for(method: str, *args, **kwargs) -> tuple[str, dict]: getattr(sync_mlb, method)(*args, **kwargs) call = sync_mlb._mlb_adapter_v1.get.call_args - return call.kwargs["endpoint"], call.kwargs["ep_params"] + # Most Mlb methods pass endpoint as a keyword; get_attendance passes it + # positionally, so fall back to the first positional argument. + endpoint = call.kwargs["endpoint"] if "endpoint" in call.kwargs else call.args[0] + return endpoint, call.kwargs["ep_params"] + + +def _flatten_params(params: dict) -> list[tuple[str, str]]: + """Expand a params dict into (key, str(value)) pairs, list values repeated. + + Mirrors how both Requests and HTTPX serialize a list-valued query + parameter: as the same key repeated once per item, e.g. + ``?hydrate=a&hydrate=b`` rather than a single comma-joined value. + """ + pairs: list[tuple[str, str]] = [] + for key, value in params.items(): + if isinstance(value, list): + pairs.extend((key, str(item)) for item in value) + else: + pairs.append((key, str(value))) + return sorted(pairs) def assert_matches_sync(request: httpx.Request, method: str, *args, **kwargs) -> None: @@ -216,8 +352,7 @@ def assert_matches_sync(request: httpx.Request, method: str, *args, **kwargs) -> endpoint, params = sync_request_for(method, *args, **kwargs) assert request.url.path == f"/api/v1/{endpoint}" - # Query values arrive as strings, whatever type the client passed in. - assert dict(request.url.params) == {k: str(v) for k, v in params.items()} + assert sorted(request.url.params.multi_items()) == _flatten_params(params) # --------------------------------------------------------------------------- @@ -654,6 +789,113 @@ async def scenario(): assert_matches_sync(handler.request, "get_seasons", 11) +def test_get_venue_requests_the_venue_endpoint_and_parses_the_result(): + handler = _Handler(_json(VENUE_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_venue(31) + + venue = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_venue", 31) + assert venue == EXPECTED_VENUE + + +def test_get_venue_returns_empty_list_on_404(): + """get_venue mirrors Mlb's documented quirk: [] rather than None on 4xx.""" + handler = _Handler(NO_RESULT_RESPONSES["404"]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_venue(1) + + assert asyncio.run(scenario()) == [] + + +def test_get_venue_returns_none_on_empty_200(): + """Unlike the 4xx quirk, an empty 200 falls through to the normal None.""" + handler = _Handler(NO_RESULT_RESPONSES["empty 200"]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_venue(1) + + assert asyncio.run(scenario()) is None + + +def test_get_venues_request_matches_the_sync_client(): + handler = _Handler(_json({"venues": []})) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_venues() + + assert asyncio.run(scenario()) == [] + assert_matches_sync(handler.request, "get_venues") + + +def test_get_standings_requests_the_standings_endpoint_and_parses_the_result(): + handler = _Handler(_json(STANDINGS_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_standings(103, "2022") + + standings = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_standings", 103, "2022") + assert standings == [Standings(**STANDINGS_RECORD)] + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_standings_returns_empty_list_when_there_are_no_standings(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_standings(103, "2022") + + assert asyncio.run(scenario()) == [] + + +def test_get_attendance_requests_the_attendance_endpoint_and_parses_the_result(): + handler = _Handler(_json(ATTENDANCE_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_attendance(team_id=133) + + attendance = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_attendance", team_id=133) + assert isinstance(attendance, Attendance) + assert attendance.aggregate_totals.attendance_total == 2896460 + + +def test_get_attendance_without_an_identifier_returns_none_without_requesting(): + """Regression coverage for the any(dict) vs any(dict.values()) guard bug.""" + handler = _Handler(_json(ATTENDANCE_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_attendance() + + assert asyncio.run(scenario()) is None + assert handler.requests == [] + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_attendance_returns_none_when_there_is_no_attendance(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_attendance(team_id=133) + + assert asyncio.run(scenario()) is None + + # --------------------------------------------------------------------------- # Parity and concurrency # --------------------------------------------------------------------------- @@ -679,6 +921,10 @@ def test_public_signatures_match_the_sync_client(): "get_divisions", "get_season", "get_seasons", + "get_venue", + "get_venues", + "get_standings", + "get_attendance", ): sync_params = inspect.signature(getattr(Mlb, name)).parameters async_params = inspect.signature(getattr(AsyncMlb, name)).parameters diff --git a/tests/test_mlb_attendance.py b/tests/test_mlb_attendance.py new file mode 100644 index 00000000..da9f1153 --- /dev/null +++ b/tests/test_mlb_attendance.py @@ -0,0 +1,88 @@ +"""Offline coverage for Mlb.get_attendance, including a regression test for a +guard bug found while porting this endpoint to AsyncMlb (issue #305): +``any(required_args)`` iterates dict keys (always truthy) instead of values, +so the documented "at least one of team_id/league_id/league_list_id" guard +never actually fired. Fixed to ``any(required_args.values())``. + +These tests must not contact the live MLB API. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from mlbstatsapi import Mlb +from mlbstatsapi.mlb_dataadapter import MlbResult +from mlbstatsapi.models.attendances import Attendance + + +ATTENDANCE_PAYLOAD = { + "records": [ + { + "openingsTotal": 160, + "openingsTotalAway": 81, + "openingsTotalHome": 79, + "openingsTotalLost": 2, + "gamesTotal": 162, + "gamesAwayTotal": 82, + "gamesHomeTotal": 80, + "year": "2022", + "attendanceAverageYtd": 18103, + "attendanceHigh": 40065, + "attendanceHighDate": "2022-08-06T00:00:00", + "attendanceTotal": 2896460, + "attendanceTotalAway": 2108558, + "attendanceTotalHome": 787902, + "gameType": {"id": "R", "description": "Regular Season"}, + "team": {"id": 133, "name": "Oakland Athletics", "link": "/api/v1/teams/133"}, + } + ], + "aggregateTotals": { + "openingsTotalAway": 81, + "openingsTotalHome": 79, + "openingsTotalLost": 2, + "openingsTotalYtd": 0, + "attendanceAverageYtd": 18103, + "attendanceHigh": 40065, + "attendanceHighDate": "2022-08-06T00:00:00", + "attendanceTotal": 2896460, + "attendanceTotalAway": 2108558, + "attendanceTotalHome": 787902, + }, +} + + +def test_get_attendance_with_no_identifier_does_not_request_and_returns_none(): + """Regression test: no team/league/league-list id must short-circuit.""" + with Mlb() as mlb: + mock = MagicMock() + mlb._mlb_adapter_v1.get = mock + + result = mlb.get_attendance() + + assert result is None + mock.assert_not_called() + + +def test_get_attendance_with_team_id_requests_and_parses_the_result(): + with Mlb() as mlb: + mlb._mlb_adapter_v1.get = MagicMock( + return_value=MlbResult(status_code=200, message=None, data=ATTENDANCE_PAYLOAD) + ) + + result = mlb.get_attendance(team_id=133) + + assert isinstance(result, Attendance) + assert result.aggregate_totals.attendance_total == 2896460 + mlb._mlb_adapter_v1.get.assert_called_once_with( + "attendance", ep_params={"teamId": 133} + ) + + +def test_get_attendance_returns_none_on_client_error(): + with Mlb() as mlb: + mlb._mlb_adapter_v1.get = MagicMock( + return_value=MlbResult(status_code=404, message=None, data={}) + ) + + assert mlb.get_attendance(team_id=133) is None diff --git a/tests/test_public_api.py b/tests/test_public_api.py index ad32a50d..5021baca 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -254,6 +254,13 @@ def _normalize_signature(fn: Any) -> str: "get_divisions": "(**params)", "get_season": "(season_id: str, sport_id: int=1, **params)", "get_seasons": "(sport_id: int=1, **params)", + "get_venue": "(venue_id: int, **params)", + "get_venues": "(**params)", + "get_standings": "(league_id: int, season: str, **params)", + "get_attendance": ( + "(team_id: int=None, league_id: int=None, " + "league_list_id: str=None, **params)" + ), } diff --git a/tests/test_sync_async_parity.py b/tests/test_sync_async_parity.py index c984e780..c9c65d8e 100644 --- a/tests/test_sync_async_parity.py +++ b/tests/test_sync_async_parity.py @@ -42,13 +42,16 @@ MlbTimeoutError, MlbTransportError, ) +from mlbstatsapi.models.attendances import Attendance # noqa: E402 from mlbstatsapi.models.divisions import Division # noqa: E402 from mlbstatsapi.models.leagues import League # noqa: E402 from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 from mlbstatsapi.models.schedules import Schedule # noqa: E402 from mlbstatsapi.models.seasons import Season # noqa: E402 from mlbstatsapi.models.sports import Sport # noqa: E402 +from mlbstatsapi.models.standings import Standings # noqa: E402 from mlbstatsapi.models.teams import Team # noqa: E402 +from mlbstatsapi.models.venues import Venue # noqa: E402 TEAM_PAYLOAD = {"teams": [{"id": 133, "link": "/api/v1/teams/133", "name": "Athletics"}]} @@ -88,6 +91,119 @@ ] } SEASON_PAYLOAD = {"seasons": [{"seasonId": "2021", "hasWildcard": True}]} +VENUE_PAYLOAD = {"venues": [{"id": 31, "link": "/api/v1/venues/31", "name": "PNC Park"}]} +STANDINGS_RECORD = { + "standingsType": "regularSeason", + "league": {"id": 103, "link": "/api/v1/league/103"}, + "division": {"id": 201, "link": "/api/v1/divisions/201"}, + "sport": {"id": 1, "link": "/api/v1/sports/1"}, + "roundRobin": {"status": "false"}, + "lastUpdated": "2025-10-16T23:15:55.082Z", + "teamRecords": [ + { + "team": {"id": 147, "name": "Yankees", "link": "/api/v1/teams/147"}, + "season": "2022", + "streak": {"streakCode": "L2", "streakType": "losses", "streakNumber": 2}, + "clinchIndicator": "y", + "divisionRank": "1", + "leagueRank": "2", + "sportRank": "5", + "gamesPlayed": 162, + "gamesBack": "-", + "wildCardGamesBack": "-", + "leagueGamesBack": "7.0", + "springLeagueGamesBack": "-", + "sportGamesBack": "7.0", + "divisionGamesBack": "-", + "conferenceGamesBack": "-", + "leagueRecord": {"wins": 99, "losses": 63, "ties": 0, "pct": ".611"}, + "lastUpdated": "2025-10-16T23:14:26Z", + "records": { + "splitRecords": [{"wins": 57, "losses": 24, "type": "home", "pct": ".704"}], + "divisionRecords": [ + { + "wins": 17, + "losses": 16, + "pct": ".515", + "division": { + "id": 200, + "name": "American League West", + "link": "/api/v1/divisions/200", + }, + } + ], + "overallRecords": [{"wins": 57, "losses": 24, "type": "home", "pct": ".704"}], + "leagueRecords": [ + { + "wins": 89, + "losses": 53, + "pct": ".627", + "league": { + "id": 103, + "name": "American League", + "link": "/api/v1/league/103", + }, + } + ], + "expectedRecords": [ + {"wins": 106, "losses": 56, "type": "xWinLoss", "pct": ".654"} + ], + }, + "runsAllowed": 567, + "runsScored": 807, + "divisionChamp": True, + "divisionLeader": True, + "hasWildcard": True, + "clinched": True, + "eliminationNumber": "-", + "eliminationNumberSport": "E", + "eliminationNumberLeague": "E", + "eliminationNumberDivision": "-", + "eliminationNumberConference": "E", + "wildCardEliminationNumber": "-", + "magicNumber": "-", + "wins": 99, + "losses": 63, + "runDifferential": 240, + "winningPercentage": ".611", + } + ], +} +STANDINGS_PAYLOAD = {"records": [STANDINGS_RECORD]} +ATTENDANCE_PAYLOAD = { + "records": [ + { + "openingsTotal": 160, + "openingsTotalAway": 81, + "openingsTotalHome": 79, + "openingsTotalLost": 2, + "gamesTotal": 162, + "gamesAwayTotal": 82, + "gamesHomeTotal": 80, + "year": "2022", + "attendanceAverageYtd": 18103, + "attendanceHigh": 40065, + "attendanceHighDate": "2022-08-06T00:00:00", + "attendanceTotal": 2896460, + "attendanceTotalAway": 2108558, + "attendanceTotalHome": 787902, + "gameType": {"id": "R", "description": "Regular Season"}, + "team": {"id": 133, "name": "Oakland Athletics", "link": "/api/v1/teams/133"}, + } + ], + "aggregateTotals": { + "openingsTotalAway": 81, + "openingsTotalHome": 79, + "openingsTotalLost": 2, + "openingsTotalYtd": 0, + "attendanceAverageYtd": 18103, + "attendanceHigh": 40065, + "attendanceHighDate": "2022-08-06T00:00:00", + "attendanceTotal": 2896460, + "attendanceTotalAway": 2108558, + "attendanceTotalHome": 787902, + }, +} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -436,6 +552,45 @@ def test_get_season_success_parity(): assert result.request == ("GET", "/api/v1/seasons/2021", {"sportId": "1"}) +def test_get_venue_success_parity(): + """A successful venue response parses to the same Venue on both clients.""" + result = call_both("get_venue", 31, payload=VENUE_PAYLOAD) + + assert isinstance(result.sync, Venue), "sync get_venue did not return a Venue" + assert (result.sync.id, result.sync.link, result.sync.name) == ( + 31, + "/api/v1/venues/31", + "PNC Park", + ) + assert result.asynchronous == result.sync + # hydrate is sent as a repeated query param (?hydrate=a&hydrate=b&...); + # request_signature's dict(parse_qsl(...)) keeps only the last value, so + # this only proves the two clients agree, not the full query string. + assert result.request == ("GET", "/api/v1/venues/31", {"hydrate": "timezone"}) + + +def test_get_standings_success_parity(): + """A successful standings response parses to the same Standings on both clients.""" + result = call_both("get_standings", 103, "2022", payload=STANDINGS_PAYLOAD) + + assert isinstance(result.sync, list) and isinstance(result.sync[0], Standings), ( + "sync get_standings did not return a list of Standings" + ) + assert result.sync[0].team_records[0].team.name == "Yankees" + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/standings", {"leagueId": "103", "season": "2022"}) + + +def test_get_attendance_success_parity(): + """A successful attendance response parses to the same Attendance on both clients.""" + result = call_both("get_attendance", team_id=133, payload=ATTENDANCE_PAYLOAD) + + assert isinstance(result.sync, Attendance), "sync get_attendance did not return an Attendance" + assert result.sync.aggregate_totals.attendance_total == 2896460 + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/attendance", {"teamId": "133"}) + + # --------------------------------------------------------------------------- # Nothing to return # --------------------------------------------------------------------------- @@ -542,6 +697,63 @@ def test_get_season_no_result_parity(label): ) +def test_get_venue_no_result_parity_404(): + """404 hits Mlb.get_venue's documented quirk: [] rather than None.""" + result = call_both("get_venue", 1, status=404, payload={}) + + assert result.sync == [], f"sync get_venue returned {result.sync!r} for 404" + assert result.asynchronous == [], ( + f"async get_venue returned {result.asynchronous!r} for 404" + ) + + +@pytest.mark.parametrize("label", ["empty 200", "empty body"]) +def test_get_venue_no_result_parity_non_4xx(label): + """Unlike the 404 quirk, a non-4xx empty response falls through to None.""" + result = call_both("get_venue", 1, **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, f"sync get_venue returned {result.sync!r} for {label}" + assert result.asynchronous is None, ( + f"async get_venue returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_standings_no_result_parity(label): + """Every no-result response returns an empty list on either client.""" + result = call_both("get_standings", 103, "2022", **NO_RESULT_RESPONSES[label]) + + assert result.sync == [], f"sync get_standings returned {result.sync!r} for {label}" + assert result.asynchronous == [], ( + f"async get_standings returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_attendance_no_result_parity(label): + """Every no-result response returns None on either client.""" + result = call_both("get_attendance", team_id=133, **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, f"sync get_attendance returned {result.sync!r} for {label}" + assert result.asynchronous is None, ( + f"async get_attendance returned {result.asynchronous!r} for {label}" + ) + + +def test_get_attendance_without_an_identifier_parity(): + """Regression coverage: the any(dict) vs any(dict.values()) guard bug fix.""" + sync_requests: list = [] + async_requests: list = [] + + sync_result = call_sync("get_attendance", observed=sync_requests) + async_result = call_async("get_attendance", observed=async_requests) + + assert sync_result is None + assert async_result is None + assert sync_requests == [] + assert async_requests == [] + + # --------------------------------------------------------------------------- # Representative public failure behavior (get_team) # --------------------------------------------------------------------------- From be7aca80fadbd9a537a4714b3bf8eefed47deeac Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sat, 22 Aug 2026 12:32:55 -0700 Subject: [PATCH 44/81] feat(async): add get_draft/get_awards to AsyncMlb Continues issue #305's endpoint expansion. Adds shared _parsers/draft.py and _parsers/awards.py, reused by Mlb (refactored internally, no behavior change) and AsyncMlb. get_awards preserves Mlb's endpoint string with a trailing "?" (awards/{id}/recipients?); both Requests and HTTPX treat it as a harmless empty query separator, so it's kept as-is for parity rather than "fixed". That trailing "?" exposed a gap in the shared assert_matches_sync test helper: it compared request.url.path against the raw endpoint string, but neither client's parsed path retains a trailing "?". Fixed with a .rstrip("?") normalization. Covers both endpoints with parser, AsyncMlb endpoint, sync/async parity, and live external smoke tests, and extends the frozen ASYNC_MLB_PUBLIC_METHOD_MANIFEST and docs/public-api.md accordingly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BtqT3jocrSJRouEew7Nnto --- docs/public-api.md | 2 + mlbstatsapi/_parsers/awards.py | 8 ++ mlbstatsapi/_parsers/draft.py | 16 +++ mlbstatsapi/async_mlb.py | 106 ++++++++++++++++++ mlbstatsapi/mlb_api.py | 18 +-- .../async_mlb/test_async_mlb_smoke.py | 24 ++++ tests/parsers/test_awards_parser.py | 23 ++++ tests/parsers/test_draft_parser.py | 13 +++ tests/test_async_mlb.py | 67 ++++++++++- tests/test_public_api.py | 2 + tests/test_sync_async_parity.py | 54 +++++++++ 11 files changed, 318 insertions(+), 15 deletions(-) create mode 100644 mlbstatsapi/_parsers/awards.py create mode 100644 mlbstatsapi/_parsers/draft.py create mode 100644 tests/parsers/test_awards_parser.py create mode 100644 tests/parsers/test_draft_parser.py diff --git a/docs/public-api.md b/docs/public-api.md index 845ea70b..cbe7095d 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -304,6 +304,8 @@ get_attendance( league_list_id: str = None, **params, ) +get_draft(year_id: int, **params) +get_awards(award_id: str, **params) ``` `get_venue` inherits the same documented quirk as `Mlb.get_venue`: it is diff --git a/mlbstatsapi/_parsers/awards.py b/mlbstatsapi/_parsers/awards.py new file mode 100644 index 00000000..f800b4ba --- /dev/null +++ b/mlbstatsapi/_parsers/awards.py @@ -0,0 +1,8 @@ +from mlbstatsapi.models.awards import Award + + +def parse_awards(data: dict) -> list[Award]: + """Parse Award models from an MLB /awards/{id}/recipients response body.""" + if not data or not data.get("awards"): + return [] + return [Award(**award) for award in data["awards"]] diff --git a/mlbstatsapi/_parsers/draft.py b/mlbstatsapi/_parsers/draft.py new file mode 100644 index 00000000..466f181e --- /dev/null +++ b/mlbstatsapi/_parsers/draft.py @@ -0,0 +1,16 @@ +from mlbstatsapi.models.drafts import Round + + +def parse_draft(data: dict) -> list[Round]: + """Parse Round models from an MLB /draft/{year} response body. + + Expects the full response, e.g. ``{"drafts": {"rounds": [...]}}``. + """ + if not data or not data.get("drafts"): + return [] + + rounds = data["drafts"].get("rounds") + if not rounds: + return [] + + return [Round(**round_data) for round_data in rounds] diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index 1768fafd..ace73e15 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -7,7 +7,9 @@ from ._helpers.schedule import build_schedule_params from ._parsers.attendance import parse_attendance +from ._parsers.awards import parse_awards from ._parsers.divisions import parse_division, parse_divisions +from ._parsers.draft import parse_draft from ._parsers.leagues import parse_league, parse_leagues from ._parsers.people import parse_person, parse_people from ._parsers.roster import parse_roster_coaches, parse_roster_players @@ -20,7 +22,9 @@ from .async_mlb_dataadapter import AsyncMlbDataAdapter from .mlb_dataadapter import DEFAULT_TIMEOUT, TimeoutType from .models.attendances import Attendance +from .models.awards import Award from .models.divisions import Division +from .models.drafts import Round from .models.leagues import League from .models.people import Coach, Person, Player from .models.schedules import Schedule @@ -1284,3 +1288,105 @@ async def get_attendance( return None return parse_attendance(mlb_data.data) + + async def get_draft( + self, + year_id: int, + **params, + ) -> list[Round]: + """ + return a draft object for year_id + + Async counterpart of ``Mlb.get_draft``. + + Parameters + ---------- + year_id : int + Insert a year_id to return a directory of seasons for a specific sport. + + Other Parameters + ---------------- + round : str + Insert a round to return biographical and financial data for a specific round in a Rule 4 draft. + name : str + Insert the first letter of a draftees last name to return their Rule 4 biographical and financial data. + school : str + Insert the first letter of a draftees school to return their Rule 4 biographical and financial data. + state : str + Insert state to return a list of Rule 4 draftees from that given state + country : str + Insert state to return a list of Rule 4 draftees from that given state + position : str + Insert the position to return Rule 4 biographical and financial data for a players drafted at that position. + teamId : int + Insert teamId to return Rule 4 biographical and financial data for all picks made by a specific team. + playerId : int + Insert MLB playerId to return a player's Rule 4 biographical and financial data a specific Rule 4 draft. + bisPlayerId : int + Insert bisPlayerId to return a player's Rule 4 biographical and financial data a specific Rule 4 draft. + + Returns + ------- + list of DraftPicks + returns a list of DraftPicks + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... rounds = await mlb.get_draft(2019) + [Round, Round, Round] + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"draft/{year_id}", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_draft(mlb_data.data) + + async def get_awards( + self, + award_id: str, + **params, + ) -> list[Award]: + """ + return a list of awards for award_id + + Async counterpart of ``Mlb.get_awards``. + + Parameters + ---------- + award_id : str + Insert a awardId to return a directory of players for a given award. + + Other Parameters + ---------------- + sportId : int + Insert a sportId to return a directory of players for a given award in a specific sport. + leagueId : int, List[int] + Insert leagueId(s) to return a directory of players for a given award in a specific league. Format '103,104' + season : int, List[int] + Insert year(s) to return a directory of players for a given award in a given season. Format '2016,2017' + + Returns + ------- + list of Awards + returns a list of awards + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... awards = await mlb.get_awards("ALMVP") + [Award, Award, Award] + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"awards/{award_id}/recipients?", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_awards(mlb_data.data) diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index 4339bd81..0ed9036d 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -24,7 +24,9 @@ from ._parsers.attendance import parse_attendance +from ._parsers.awards import parse_awards from ._parsers.divisions import parse_divisions, parse_division +from ._parsers.draft import parse_draft from ._parsers.leagues import parse_leagues, parse_league from ._parsers.people import parse_people, parse_person from ._parsers.roster import parse_roster_coaches, parse_roster_players @@ -1971,13 +1973,7 @@ def get_draft(self, year_id: int, **params) -> List[Round]: if 400 <= mlb_data.status_code <= 499: return [] - round_list = [] - - if 'drafts' in mlb_data.data and mlb_data.data['drafts']: - if mlb_data.data['drafts']['rounds']: - for round in mlb_data.data['drafts']['rounds']: - round_list.append(Round(**round)) - return round_list + return parse_draft(mlb_data.data) def get_awards(self, award_id: str, **params) -> List[Award]: """ @@ -2011,14 +2007,8 @@ def get_awards(self, award_id: str, **params) -> List[Award]: mlb_data = self._mlb_adapter_v1.get(endpoint=f'awards/{award_id}/recipients?', ep_params=params) if 400 <= mlb_data.status_code <= 499: return [] - - awards_list = [] - if 'awards' in mlb_data.data and mlb_data.data['awards']: - for award in mlb_data.data['awards']: - awards_list.append(Award(**award)) - - return awards_list + return parse_awards(mlb_data.data) def get_homerun_derby(self, game_id, **params) -> Union[HomeRunDerby, None]: """ diff --git a/tests/external_tests/async_mlb/test_async_mlb_smoke.py b/tests/external_tests/async_mlb/test_async_mlb_smoke.py index e765c26e..55525cd4 100644 --- a/tests/external_tests/async_mlb/test_async_mlb_smoke.py +++ b/tests/external_tests/async_mlb/test_async_mlb_smoke.py @@ -2,7 +2,9 @@ from mlbstatsapi import AsyncMlb from mlbstatsapi.models.attendances import Attendance +from mlbstatsapi.models.awards import Award from mlbstatsapi.models.divisions import Division +from mlbstatsapi.models.drafts import Round from mlbstatsapi.models.leagues import League from mlbstatsapi.models.people import Coach, Person, Player from mlbstatsapi.models.schedules import Schedule @@ -142,3 +144,25 @@ async def scenario(): assert isinstance(attendance, Attendance) asyncio.run(scenario()) + + +def test_async_get_draft(): + async def scenario(): + async with AsyncMlb() as mlb: + rounds = await mlb.get_draft(2019) + + assert rounds + assert isinstance(rounds[0], Round) + + asyncio.run(scenario()) + + +def test_async_get_awards(): + async def scenario(): + async with AsyncMlb() as mlb: + awards = await mlb.get_awards("ALMVP") + + assert awards + assert isinstance(awards[0], Award) + + asyncio.run(scenario()) diff --git a/tests/parsers/test_awards_parser.py b/tests/parsers/test_awards_parser.py new file mode 100644 index 00000000..0a3da9c4 --- /dev/null +++ b/tests/parsers/test_awards_parser.py @@ -0,0 +1,23 @@ +from mlbstatsapi._parsers.awards import parse_awards +from mlbstatsapi.models.awards import Award + + +AWARD_PAYLOAD = { + "id": "ALMVP", + "name": "AL Most Valuable Player", + "date": "2022-11-17", + "season": "2022", + "team": {"id": 147, "link": "/api/v1/teams/147", "name": "Yankees"}, + "player": {"id": 592450, "link": "/api/v1/people/592450", "fullName": "Aaron Judge"}, +} + + +def test_parse_awards(): + """parse_awards reads the MLB awards envelope and returns Award models.""" + assert parse_awards({}) == [] + assert parse_awards({"awards": []}) == [] + + awards = parse_awards({"awards": [AWARD_PAYLOAD]}) + + assert awards == [Award(**AWARD_PAYLOAD)] + assert awards[0].player.full_name == "Aaron Judge" diff --git a/tests/parsers/test_draft_parser.py b/tests/parsers/test_draft_parser.py new file mode 100644 index 00000000..eb96f333 --- /dev/null +++ b/tests/parsers/test_draft_parser.py @@ -0,0 +1,13 @@ +from mlbstatsapi._parsers.draft import parse_draft +from mlbstatsapi.models.drafts import Round + + +def test_parse_draft(): + """parse_draft reads the nested drafts.rounds envelope and returns Round models.""" + assert parse_draft({}) == [] + assert parse_draft({"drafts": {}}) == [] + assert parse_draft({"drafts": {"rounds": []}}) == [] + + rounds = parse_draft({"drafts": {"rounds": [{"round": "1"}, {"round": "1B"}]}}) + + assert rounds == [Round(round="1"), Round(round="1B")] diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py index 4bcc74f8..ea4c70f7 100644 --- a/tests/test_async_mlb.py +++ b/tests/test_async_mlb.py @@ -39,7 +39,9 @@ from mlbstatsapi.async_mlb import AsyncMlb # noqa: E402 from mlbstatsapi.mlb_dataadapter import MlbResult # noqa: E402 from mlbstatsapi.models.attendances import Attendance # noqa: E402 +from mlbstatsapi.models.awards import Award # noqa: E402 from mlbstatsapi.models.divisions import Division # noqa: E402 +from mlbstatsapi.models.drafts import Round # noqa: E402 from mlbstatsapi.models.leagues import League # noqa: E402 from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 from mlbstatsapi.models.schedules import Schedule # noqa: E402 @@ -200,6 +202,16 @@ "attendanceTotalHome": 787902, }, } +DRAFT_PAYLOAD = {"drafts": {"rounds": [{"round": "1"}]}} +AWARD_PAYLOAD = { + "id": "ALMVP", + "name": "AL Most Valuable Player", + "date": "2022-11-17", + "season": "2022", + "team": {"id": 147, "link": "/api/v1/teams/147", "name": "Yankees"}, + "player": {"id": 592450, "link": "/api/v1/people/592450", "fullName": "Aaron Judge"}, +} +AWARDS_PAYLOAD = {"awards": [AWARD_PAYLOAD]} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -351,7 +363,10 @@ def assert_matches_sync(request: httpx.Request, method: str, *args, **kwargs) -> """Assert an observed request is the one ``Mlb`` would have made.""" endpoint, params = sync_request_for(method, *args, **kwargs) - assert request.url.path == f"/api/v1/{endpoint}" + # get_awards's endpoint string has a trailing "?" (harmless legacy cruft + # both Requests and HTTPX strip as an empty query separator), which never + # shows up in url.path. + assert request.url.path == f"/api/v1/{endpoint}".rstrip("?") assert sorted(request.url.params.multi_items()) == _flatten_params(params) @@ -896,6 +911,54 @@ async def scenario(): assert asyncio.run(scenario()) is None +def test_get_draft_requests_the_draft_endpoint_and_parses_the_result(): + handler = _Handler(_json(DRAFT_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_draft(2019) + + rounds = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_draft", 2019) + assert rounds == [Round(round="1")] + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_draft_returns_empty_list_when_there_is_no_draft(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_draft(2019) + + assert asyncio.run(scenario()) == [] + + +def test_get_awards_requests_the_awards_endpoint_and_parses_the_result(): + handler = _Handler(_json(AWARDS_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_awards("ALMVP") + + awards = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_awards", "ALMVP") + assert awards == [Award(**AWARD_PAYLOAD)] + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_awards_returns_empty_list_when_there_are_no_awards(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_awards("ALMVP") + + assert asyncio.run(scenario()) == [] + + # --------------------------------------------------------------------------- # Parity and concurrency # --------------------------------------------------------------------------- @@ -925,6 +988,8 @@ def test_public_signatures_match_the_sync_client(): "get_venues", "get_standings", "get_attendance", + "get_draft", + "get_awards", ): sync_params = inspect.signature(getattr(Mlb, name)).parameters async_params = inspect.signature(getattr(AsyncMlb, name)).parameters diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 5021baca..7fd7ed7e 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -261,6 +261,8 @@ def _normalize_signature(fn: Any) -> str: "(team_id: int=None, league_id: int=None, " "league_list_id: str=None, **params)" ), + "get_draft": "(year_id: int, **params)", + "get_awards": "(award_id: str, **params)", } diff --git a/tests/test_sync_async_parity.py b/tests/test_sync_async_parity.py index c9c65d8e..e1533b6f 100644 --- a/tests/test_sync_async_parity.py +++ b/tests/test_sync_async_parity.py @@ -43,7 +43,9 @@ MlbTransportError, ) from mlbstatsapi.models.attendances import Attendance # noqa: E402 +from mlbstatsapi.models.awards import Award # noqa: E402 from mlbstatsapi.models.divisions import Division # noqa: E402 +from mlbstatsapi.models.drafts import Round # noqa: E402 from mlbstatsapi.models.leagues import League # noqa: E402 from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 from mlbstatsapi.models.schedules import Schedule # noqa: E402 @@ -204,6 +206,16 @@ "attendanceTotalHome": 787902, }, } +DRAFT_PAYLOAD = {"drafts": {"rounds": [{"round": "1"}]}} +AWARD_PAYLOAD = { + "id": "ALMVP", + "name": "AL Most Valuable Player", + "date": "2022-11-17", + "season": "2022", + "team": {"id": 147, "link": "/api/v1/teams/147", "name": "Yankees"}, + "player": {"id": 592450, "link": "/api/v1/people/592450", "fullName": "Aaron Judge"}, +} +AWARDS_PAYLOAD = {"awards": [AWARD_PAYLOAD]} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -591,6 +603,26 @@ def test_get_attendance_success_parity(): assert result.request == ("GET", "/api/v1/attendance", {"teamId": "133"}) +def test_get_draft_success_parity(): + """A successful draft response parses to the same Round list on both clients.""" + result = call_both("get_draft", 2019, payload=DRAFT_PAYLOAD) + + assert result.sync == [Round(round="1")], "sync get_draft did not return the round" + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/draft/2019", {}) + + +def test_get_awards_success_parity(): + """A successful awards response parses to the same Award list on both clients.""" + result = call_both("get_awards", "ALMVP", payload=AWARDS_PAYLOAD) + + assert result.sync == [Award(**AWARD_PAYLOAD)], "sync get_awards did not return the award" + assert result.asynchronous == result.sync + # The endpoint string has a trailing "?"; both clients strip it as an + # empty query separator, so it never appears in the request path. + assert result.request == ("GET", "/api/v1/awards/ALMVP/recipients", {}) + + # --------------------------------------------------------------------------- # Nothing to return # --------------------------------------------------------------------------- @@ -754,6 +786,28 @@ def test_get_attendance_without_an_identifier_parity(): assert async_requests == [] +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_draft_no_result_parity(label): + """Every no-result response returns an empty list on either client.""" + result = call_both("get_draft", 2019, **NO_RESULT_RESPONSES[label]) + + assert result.sync == [], f"sync get_draft returned {result.sync!r} for {label}" + assert result.asynchronous == [], ( + f"async get_draft returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_awards_no_result_parity(label): + """Every no-result response returns an empty list on either client.""" + result = call_both("get_awards", "ALMVP", **NO_RESULT_RESPONSES[label]) + + assert result.sync == [], f"sync get_awards returned {result.sync!r} for {label}" + assert result.asynchronous == [], ( + f"async get_awards returned {result.asynchronous!r} for {label}" + ) + + # --------------------------------------------------------------------------- # Representative public failure behavior (get_team) # --------------------------------------------------------------------------- From 99a88ed8de81b166cf04cd2bec1cd37aed64b054 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sat, 22 Aug 2026 12:44:58 -0700 Subject: [PATCH 45/81] feat(async): add get_homerun_derby to AsyncMlb Continues issue #305's endpoint expansion. Fixes a bug in Mlb.get_homerun_derby found while porting it: the 400-499 guard was a bare `None` expression instead of `return None`, so a 4xx response body containing a truthy "status" key would fall through into HomeRunDerby(**data) and raise ValidationError instead of returning None. Fixed to `return None`, following the same fix-then-port approach used for get_attendance. Adds a shared _parsers/homerunderby.py reused by both Mlb (refactored internally) and AsyncMlb, plus parser, endpoint, sync/async parity (including a dedicated regression test for the malformed-error-body case), and live external smoke test coverage. Extends the frozen ASYNC_MLB_PUBLIC_METHOD_MANIFEST and docs/public-api.md accordingly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BtqT3jocrSJRouEew7Nnto --- docs/public-api.md | 9 ++- mlbstatsapi/_parsers/homerunderby.py | 8 ++ mlbstatsapi/async_mlb.py | 46 +++++++++++ mlbstatsapi/mlb_api.py | 8 +- .../async_mlb/test_async_mlb_smoke.py | 11 +++ tests/parsers/test_homerunderby_parser.py | 40 ++++++++++ tests/test_async_mlb.py | 51 ++++++++++++ tests/test_mlb_homerun_derby.py | 79 +++++++++++++++++++ tests/test_public_api.py | 1 + tests/test_sync_async_parity.py | 62 +++++++++++++++ 10 files changed, 308 insertions(+), 7 deletions(-) create mode 100644 mlbstatsapi/_parsers/homerunderby.py create mode 100644 tests/parsers/test_homerunderby_parser.py create mode 100644 tests/test_mlb_homerun_derby.py diff --git a/docs/public-api.md b/docs/public-api.md index cbe7095d..8b550595 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -306,6 +306,7 @@ get_attendance( ) get_draft(year_id: int, **params) get_awards(award_id: str, **params) +get_homerun_derby(game_id, **params) ``` `get_venue` inherits the same documented quirk as `Mlb.get_venue`: it is @@ -550,9 +551,11 @@ Notes and known conflicts (documented, not redesigned by this contract): * `get_venue` is annotated to return `Venue | None` but currently returns `[]` on 400–499 statuses. Treat the implementation shape as the observed behavior until a focused fix lands. -* `get_homerun_derby` currently executes a bare `None` expression on 400–499 - instead of `return None`, so execution may continue. A focused bugfix is - recommended. +* `get_homerun_derby` previously executed a bare `None` expression on + 400–499 instead of `return None`, so a 4xx response whose body happened to + contain a truthy `status` key would have continued into + `HomeRunDerby(**data)` and raised instead of returning `None`. Fixed to + `return None` while porting the endpoint to `AsyncMlb` (issue #305). * `get_attendance`'s "at least one of `team_id`/`league_id`/`league_list_id`" guard previously used `any(required_args)`, which iterates dict keys (always truthy) rather than values, so the guard never actually fired. This diff --git a/mlbstatsapi/_parsers/homerunderby.py b/mlbstatsapi/_parsers/homerunderby.py new file mode 100644 index 00000000..2167a526 --- /dev/null +++ b/mlbstatsapi/_parsers/homerunderby.py @@ -0,0 +1,8 @@ +from mlbstatsapi.models.homerunderby import HomeRunDerby + + +def parse_homerun_derby(data: dict) -> HomeRunDerby | None: + """Parse a HomeRunDerby from an MLB /homeRunDerby/{gamePk} response body.""" + if not data or not data.get("status"): + return None + return HomeRunDerby(**data) diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index ace73e15..86a9ea8a 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -10,6 +10,7 @@ from ._parsers.awards import parse_awards from ._parsers.divisions import parse_division, parse_divisions from ._parsers.draft import parse_draft +from ._parsers.homerunderby import parse_homerun_derby from ._parsers.leagues import parse_league, parse_leagues from ._parsers.people import parse_person, parse_people from ._parsers.roster import parse_roster_coaches, parse_roster_players @@ -25,6 +26,7 @@ from .models.awards import Award from .models.divisions import Division from .models.drafts import Round +from .models.homerunderby import HomeRunDerby from .models.leagues import League from .models.people import Coach, Person, Player from .models.schedules import Schedule @@ -1390,3 +1392,47 @@ async def get_awards( return [] return parse_awards(mlb_data.data) + + async def get_homerun_derby( + self, + game_id, + **params, + ) -> HomeRunDerby | None: + """ + The homerun derby endpoint on the Stats API allows for users to + request information from the MLB database pertaining to the + homerun derby. This is endpoint contains Statcast trajectory, + launchSpeed, launchAngle, & hit coordinates data. Also a timeRemaning + string is added to track the progress of the derby in real time. + + Async counterpart of ``Mlb.get_homerun_derby``. + + Parameters + ---------- + game_id : int + Insert gamePk to return HomerunDerby data for a specific gamePk. + + Other Parameters + ---------------- + fields : str + Format: Comma delimited list of specific fields to be returned. Format: topLevelNode, childNode, attribute + + Returns + ------- + HomeRunDerby object + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... derby = await mlb.get_homerun_derby(511101) + HomeRunDerby + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"homeRunDerby/{game_id}", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_homerun_derby(mlb_data.data) diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index 0ed9036d..e01ab83c 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -27,6 +27,7 @@ from ._parsers.awards import parse_awards from ._parsers.divisions import parse_divisions, parse_division from ._parsers.draft import parse_draft +from ._parsers.homerunderby import parse_homerun_derby from ._parsers.leagues import parse_leagues, parse_league from ._parsers.people import parse_people, parse_person from ._parsers.roster import parse_roster_coaches, parse_roster_players @@ -2040,10 +2041,9 @@ def get_homerun_derby(self, game_id, **params) -> Union[HomeRunDerby, None]: """ mlb_data = self._mlb_adapter_v1.get(endpoint=f'homeRunDerby/{game_id}', ep_params=params) if 400 <= mlb_data.status_code <= 499: - None - - if 'status' in mlb_data.data and mlb_data.data['status']: - return HomeRunDerby(**mlb_data.data) + return None + + return parse_homerun_derby(mlb_data.data) def get_team_stats(self, team_id: int, stats: list, groups: list, **params) -> dict: diff --git a/tests/external_tests/async_mlb/test_async_mlb_smoke.py b/tests/external_tests/async_mlb/test_async_mlb_smoke.py index 55525cd4..43551090 100644 --- a/tests/external_tests/async_mlb/test_async_mlb_smoke.py +++ b/tests/external_tests/async_mlb/test_async_mlb_smoke.py @@ -5,6 +5,7 @@ from mlbstatsapi.models.awards import Award from mlbstatsapi.models.divisions import Division from mlbstatsapi.models.drafts import Round +from mlbstatsapi.models.homerunderby import HomeRunDerby from mlbstatsapi.models.leagues import League from mlbstatsapi.models.people import Coach, Person, Player from mlbstatsapi.models.schedules import Schedule @@ -166,3 +167,13 @@ async def scenario(): assert isinstance(awards[0], Award) asyncio.run(scenario()) + + +def test_async_get_homerun_derby(): + async def scenario(): + async with AsyncMlb() as mlb: + derby = await mlb.get_homerun_derby(511101) + + assert isinstance(derby, HomeRunDerby) + + asyncio.run(scenario()) diff --git a/tests/parsers/test_homerunderby_parser.py b/tests/parsers/test_homerunderby_parser.py new file mode 100644 index 00000000..86138dd9 --- /dev/null +++ b/tests/parsers/test_homerunderby_parser.py @@ -0,0 +1,40 @@ +from mlbstatsapi._parsers.homerunderby import parse_homerun_derby +from mlbstatsapi.models.homerunderby import HomeRunDerby + + +HOMERUN_DERBY_PAYLOAD = { + "info": { + "id": 511101, + "nonGameGuid": "test-guid", + "name": "Home Run Derby", + "eventType": {"code": "O", "name": "Other"}, + "eventDate": "2017-07-11T00:00:00Z", + "venue": {"id": 4169, "link": "/api/v1/venues/4169", "name": "Marlins Park"}, + "isMultiDay": False, + "isPrimaryCalendar": True, + "fileCode": "2017/07/10/mlb-112", + "eventNumber": 103, + "publicFacing": True, + }, + "status": { + "state": "Final", + "currentRound": 3, + "currentRoundTimeLeft": "0:00", + "inTieBreaker": False, + "tieBreakerNum": 0, + "clockStopped": True, + "bonusTime": False, + }, +} + + +def test_parse_homerun_derby(): + """parse_homerun_derby builds a HomeRunDerby when status is present.""" + assert parse_homerun_derby({}) is None + assert parse_homerun_derby({"status": {}}) is None + + derby = parse_homerun_derby(HOMERUN_DERBY_PAYLOAD) + + assert isinstance(derby, HomeRunDerby) + assert derby.status.state == "Final" + assert derby.info.name == "Home Run Derby" diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py index ea4c70f7..6a7965a4 100644 --- a/tests/test_async_mlb.py +++ b/tests/test_async_mlb.py @@ -42,6 +42,7 @@ from mlbstatsapi.models.awards import Award # noqa: E402 from mlbstatsapi.models.divisions import Division # noqa: E402 from mlbstatsapi.models.drafts import Round # noqa: E402 +from mlbstatsapi.models.homerunderby import HomeRunDerby # noqa: E402 from mlbstatsapi.models.leagues import League # noqa: E402 from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 from mlbstatsapi.models.schedules import Schedule # noqa: E402 @@ -212,6 +213,30 @@ "player": {"id": 592450, "link": "/api/v1/people/592450", "fullName": "Aaron Judge"}, } AWARDS_PAYLOAD = {"awards": [AWARD_PAYLOAD]} +HOMERUN_DERBY_PAYLOAD = { + "info": { + "id": 511101, + "nonGameGuid": "test-guid", + "name": "Home Run Derby", + "eventType": {"code": "O", "name": "Other"}, + "eventDate": "2017-07-11T00:00:00Z", + "venue": {"id": 4169, "link": "/api/v1/venues/4169", "name": "Marlins Park"}, + "isMultiDay": False, + "isPrimaryCalendar": True, + "fileCode": "2017/07/10/mlb-112", + "eventNumber": 103, + "publicFacing": True, + }, + "status": { + "state": "Final", + "currentRound": 3, + "currentRoundTimeLeft": "0:00", + "inTieBreaker": False, + "tieBreakerNum": 0, + "clockStopped": True, + "bonusTime": False, + }, +} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -959,6 +984,31 @@ async def scenario(): assert asyncio.run(scenario()) == [] +def test_get_homerun_derby_requests_the_homerunderby_endpoint_and_parses_the_result(): + handler = _Handler(_json(HOMERUN_DERBY_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_homerun_derby(511101) + + derby = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_homerun_derby", 511101) + assert isinstance(derby, HomeRunDerby) + assert derby.status.state == "Final" + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_homerun_derby_returns_none_when_there_is_no_derby(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_homerun_derby(1) + + assert asyncio.run(scenario()) is None + + # --------------------------------------------------------------------------- # Parity and concurrency # --------------------------------------------------------------------------- @@ -990,6 +1040,7 @@ def test_public_signatures_match_the_sync_client(): "get_attendance", "get_draft", "get_awards", + "get_homerun_derby", ): sync_params = inspect.signature(getattr(Mlb, name)).parameters async_params = inspect.signature(getattr(AsyncMlb, name)).parameters diff --git a/tests/test_mlb_homerun_derby.py b/tests/test_mlb_homerun_derby.py new file mode 100644 index 00000000..89150da3 --- /dev/null +++ b/tests/test_mlb_homerun_derby.py @@ -0,0 +1,79 @@ +"""Offline coverage for Mlb.get_homerun_derby, including a regression test for +a bug found while porting this endpoint to AsyncMlb (issue #305): the 400-499 +branch executed a bare ``None`` expression instead of ``return None``, so +execution fell through to the parsing logic below. In the common case that +logic still landed on None (an error response rarely has a truthy "status" +key), but a 404 or compatibility-mode 4xx response that happened to include +one would have raised a ValidationError instead of cleanly returning None. +Fixed to ``return None``. + +These tests must not contact the live MLB API. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from mlbstatsapi import Mlb +from mlbstatsapi.mlb_dataadapter import MlbResult +from mlbstatsapi.models.homerunderby import HomeRunDerby + + +HOMERUN_DERBY_PAYLOAD = { + "info": { + "id": 511101, + "nonGameGuid": "test-guid", + "name": "Home Run Derby", + "eventType": {"code": "O", "name": "Other"}, + "eventDate": "2017-07-11T00:00:00Z", + "venue": {"id": 4169, "link": "/api/v1/venues/4169", "name": "Marlins Park"}, + "isMultiDay": False, + "isPrimaryCalendar": True, + "fileCode": "2017/07/10/mlb-112", + "eventNumber": 103, + "publicFacing": True, + }, + "status": { + "state": "Final", + "currentRound": 3, + "currentRoundTimeLeft": "0:00", + "inTieBreaker": False, + "tieBreakerNum": 0, + "clockStopped": True, + "bonusTime": False, + }, +} + + +def test_get_homerun_derby_requests_and_parses_the_result(): + with Mlb() as mlb: + mlb._mlb_adapter_v1.get = MagicMock( + return_value=MlbResult(status_code=200, message=None, data=HOMERUN_DERBY_PAYLOAD) + ) + + result = mlb.get_homerun_derby(511101) + + assert isinstance(result, HomeRunDerby) + assert result.status.state == "Final" + + +def test_get_homerun_derby_returns_none_on_client_error(): + with Mlb() as mlb: + mlb._mlb_adapter_v1.get = MagicMock( + return_value=MlbResult(status_code=404, message=None, data={}) + ) + + assert mlb.get_homerun_derby(1) is None + + +def test_get_homerun_derby_returns_none_without_raising_on_a_malformed_error_body(): + """Regression test: a 4xx body with a truthy "status" key must not reach + HomeRunDerby(**data) and raise, now that the guard actually returns.""" + with Mlb() as mlb: + mlb._mlb_adapter_v1.get = MagicMock( + return_value=MlbResult( + status_code=404, message=None, data={"status": "error"} + ) + ) + + assert mlb.get_homerun_derby(1) is None diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 7fd7ed7e..58d71e52 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -263,6 +263,7 @@ def _normalize_signature(fn: Any) -> str: ), "get_draft": "(year_id: int, **params)", "get_awards": "(award_id: str, **params)", + "get_homerun_derby": "(game_id, **params)", } diff --git a/tests/test_sync_async_parity.py b/tests/test_sync_async_parity.py index e1533b6f..b38346f1 100644 --- a/tests/test_sync_async_parity.py +++ b/tests/test_sync_async_parity.py @@ -46,6 +46,7 @@ from mlbstatsapi.models.awards import Award # noqa: E402 from mlbstatsapi.models.divisions import Division # noqa: E402 from mlbstatsapi.models.drafts import Round # noqa: E402 +from mlbstatsapi.models.homerunderby import HomeRunDerby # noqa: E402 from mlbstatsapi.models.leagues import League # noqa: E402 from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 from mlbstatsapi.models.schedules import Schedule # noqa: E402 @@ -216,6 +217,30 @@ "player": {"id": 592450, "link": "/api/v1/people/592450", "fullName": "Aaron Judge"}, } AWARDS_PAYLOAD = {"awards": [AWARD_PAYLOAD]} +HOMERUN_DERBY_PAYLOAD = { + "info": { + "id": 511101, + "nonGameGuid": "test-guid", + "name": "Home Run Derby", + "eventType": {"code": "O", "name": "Other"}, + "eventDate": "2017-07-11T00:00:00Z", + "venue": {"id": 4169, "link": "/api/v1/venues/4169", "name": "Marlins Park"}, + "isMultiDay": False, + "isPrimaryCalendar": True, + "fileCode": "2017/07/10/mlb-112", + "eventNumber": 103, + "publicFacing": True, + }, + "status": { + "state": "Final", + "currentRound": 3, + "currentRoundTimeLeft": "0:00", + "inTieBreaker": False, + "tieBreakerNum": 0, + "clockStopped": True, + "bonusTime": False, + }, +} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -623,6 +648,18 @@ def test_get_awards_success_parity(): assert result.request == ("GET", "/api/v1/awards/ALMVP/recipients", {}) +def test_get_homerun_derby_success_parity(): + """A successful homerun derby response parses to the same object on both clients.""" + result = call_both("get_homerun_derby", 511101, payload=HOMERUN_DERBY_PAYLOAD) + + assert isinstance(result.sync, HomeRunDerby), ( + "sync get_homerun_derby did not return a HomeRunDerby" + ) + assert result.sync.status.state == "Final" + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/homeRunDerby/511101", {}) + + # --------------------------------------------------------------------------- # Nothing to return # --------------------------------------------------------------------------- @@ -808,6 +845,31 @@ def test_get_awards_no_result_parity(label): ) +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_homerun_derby_no_result_parity(label): + """Every no-result response returns None on either client.""" + result = call_both("get_homerun_derby", 1, **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, f"sync get_homerun_derby returned {result.sync!r} for {label}" + assert result.asynchronous is None, ( + f"async get_homerun_derby returned {result.asynchronous!r} for {label}" + ) + + +def test_get_homerun_derby_malformed_error_body_parity(): + """Regression coverage: the bare-None-instead-of-return-None bug fix. + + A 4xx body with a truthy "status" key must not reach HomeRunDerby(**data) + and raise on either client, now that the guard actually returns. + """ + result = call_both( + "get_homerun_derby", 1, status=404, payload={"status": "error"} + ) + + assert result.sync is None + assert result.asynchronous is None + + # --------------------------------------------------------------------------- # Representative public failure behavior (get_team) # --------------------------------------------------------------------------- From 673f68fee5b686bf2a4e9bb51d4dc78021dc6c2d Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sat, 22 Aug 2026 13:07:20 -0700 Subject: [PATCH 46/81] feat(async): add get_*_id name-lookup helpers to AsyncMlb Continues issue #305's endpoint expansion. Adds get_team_id, get_people_id, get_sport_id, get_league_id, get_division_id, and get_venue_id to AsyncMlb. Extracts the identical filter logic these six Mlb methods each duplicated (case-insensitive search_key match, collect id, skip KeyError) into a single shared helper, _helpers/id_lookup.py::find_ids_by_key, and refactors Mlb onto it (no behavior change, verified against the live API). Each AsyncMlb method mirrors its sync counterpart's docstring and preserves its individual quirks (which methods set a trimming `fields` param, and that get_venue_id does not set `hydrate` the way get_venue/get_venues do). Adds a new tests/helpers/ directory (mirroring tests/parsers/) for the shared helper's unit tests, plus endpoint, sync/async parity, and live external smoke test coverage for all six methods, and extends the frozen ASYNC_MLB_PUBLIC_METHOD_MANIFEST and docs/public-api.md accordingly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BtqT3jocrSJRouEew7Nnto --- docs/public-api.md | 11 + mlbstatsapi/_helpers/id_lookup.py | 15 + mlbstatsapi/async_mlb.py | 280 ++++++++++++++++++ mlbstatsapi/mlb_api.py | 70 +---- .../async_mlb/test_async_mlb_smoke.py | 60 ++++ tests/helpers/test_id_lookup.py | 35 +++ tests/test_async_mlb.py | 82 +++++ tests/test_public_api.py | 8 + tests/test_sync_async_parity.py | 78 +++++ 9 files changed, 577 insertions(+), 62 deletions(-) create mode 100644 mlbstatsapi/_helpers/id_lookup.py create mode 100644 tests/helpers/test_id_lookup.py diff --git a/docs/public-api.md b/docs/public-api.md index 8b550595..70c314d8 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -307,6 +307,17 @@ get_attendance( get_draft(year_id: int, **params) get_awards(award_id: str, **params) get_homerun_derby(game_id, **params) +get_team_id(team_name: str, search_key: str = 'name', **params) +get_people_id( + fullname: str, + sport_id: int = 1, + search_key: str = 'fullName', + **params, +) +get_sport_id(sport_name: str, search_key: str = 'name', **params) +get_league_id(league_name: str, search_key: str = 'name', **params) +get_division_id(division_name: str, search_key: str = 'name', **params) +get_venue_id(venue_name: str, search_key: str = 'name', **params) ``` `get_venue` inherits the same documented quirk as `Mlb.get_venue`: it is diff --git a/mlbstatsapi/_helpers/id_lookup.py b/mlbstatsapi/_helpers/id_lookup.py new file mode 100644 index 00000000..c663c3f4 --- /dev/null +++ b/mlbstatsapi/_helpers/id_lookup.py @@ -0,0 +1,15 @@ +def find_ids_by_key(items: list[dict], search_key: str, value: str) -> list[int]: + """Return the ids of items whose ``search_key`` value case-insensitively matches ``value``. + + Shared by every ``Mlb``/``AsyncMlb`` ``get_*_id`` name-lookup helper. An + item missing ``search_key`` or ``id`` is silently skipped, matching the + historical per-endpoint behavior. + """ + ids = [] + for item in items: + try: + if item[search_key].lower() == value.lower(): + ids.append(item["id"]) + except KeyError: + continue + return ids diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index 86a9ea8a..d977738c 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -5,6 +5,7 @@ import logging from typing import TYPE_CHECKING +from ._helpers.id_lookup import find_ids_by_key from ._helpers.schedule import build_schedule_params from ._parsers.attendance import parse_attendance from ._parsers.awards import parse_awards @@ -242,6 +243,58 @@ async def get_teams( return parse_teams(mlb_data.data) + async def get_team_id( + self, + team_name: str, + search_key: str = "name", + **params, + ) -> list[int]: + """ + return a team Id + + Async counterpart of ``Mlb.get_team_id``. + + Parameters + ---------- + team_name : str + Teams name + + search_key : str + search key search json for matching team_name + + Other Parameters + ---------------- + sportId : int + sport id number for team search + + Returns + ------- + list of ints + returns a list of matching team ids + + See Also + -------- + AsyncMlb.get_teams : Return a list of Teams from sport id. + AsyncMlb.get_team : Return a Team from id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... ids = await mlb.get_team_id("Athletics") + [133] + """ + params["fields"] = "teams,id,name" + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="teams", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return find_ids_by_key(mlb_data.data.get("teams") or [], search_key, team_name) + async def get_team_roster( self, team_id: int, @@ -464,6 +517,60 @@ async def get_people( return parse_people(mlb_data.data) + async def get_people_id( + self, + fullname: str, + sport_id: int = 1, + search_key: str = "fullName", + **params, + ) -> list[int]: + """ + Returns specific player information based on players fullname + + Async counterpart of ``Mlb.get_people_id``. + + Parameters + ---------- + fullname : str + Person full name + sport_id : int + Insert sportId to return player information for particular sport. + + Other Parameters + ---------------- + season : int + Insert year to return player information for a particular season. + gameType : str + Insert gameType to return player information for a particular + gameType. + + Returns + ------- + list of int + Returns a list of person ids + + See Also + -------- + AsyncMlb.get_people : Return a list of People from sport id. + AsyncMlb.get_person : Return Person from id. + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... ids = await mlb.get_people_id("Ty France") + [664034] + """ + params["fields"] = "people,id,fullName" + + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"sports/{sport_id}/players", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return find_ids_by_key(mlb_data.data.get("people") or [], search_key, fullname) async def get_schedule( self, @@ -739,6 +846,50 @@ async def get_sports( return parse_sports(mlb_data.data) + async def get_sport_id( + self, + sport_name: str, + search_key: str = "name", + **params, + ) -> list[int]: + """ + return sport id + + Async counterpart of ``Mlb.get_sport_id``. + + Parameters + ---------- + sport_name : str + Sport name + search_key : str + search key name + + Returns + ------- + list of ints + returns a list of sport ids + + See Also + -------- + AsyncMlb.get_sports : return a list of sports + AsyncMlb.get_sport : return a sport from id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... ids = await mlb.get_sport_id("Major League Baseball") + [1] + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint="sports", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return find_ids_by_key(mlb_data.data.get("sports") or [], search_key, sport_name) + async def get_league( self, league_id: int, @@ -832,6 +983,49 @@ async def get_leagues( return parse_leagues(mlb_data.data) + async def get_league_id( + self, + league_name: str, + search_key: str = "name", + **params, + ) -> list[int]: + """ + return league id + + Async counterpart of ``Mlb.get_league_id``. + + Parameters + ---------- + league_name : str + League name + + Returns + ------- + list of ints + + See Also + -------- + AsyncMlb.get_league : return a League from league id + AsyncMlb.get_leagues : return a list of Leagues + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... ids = await mlb.get_league_id('American League') + [103] + """ + params["fields"] = "leagues,id,name" + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="leagues", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return find_ids_by_key(mlb_data.data.get("leagues") or [], search_key, league_name) + async def get_division( self, division_id: int, @@ -918,6 +1112,50 @@ async def get_divisions( return parse_divisions(mlb_data.data) + async def get_division_id( + self, + division_name: str, + search_key: str = "name", + **params, + ) -> list[int]: + """ + return division id + + Async counterpart of ``Mlb.get_division_id``. + + Parameters + ---------- + division_name : str + Division name + search_key : str + search key name + + Returns + ------- + list of ints + returns a matching list of division ids + + See Also + -------- + AsyncMlb.get_division : return a Division from id + AsyncMlb.get_divisions : return a list of Divisions + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... ids = await mlb.get_division_id('American League West') + [200] + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint="divisions", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return find_ids_by_key(mlb_data.data.get("divisions") or [], search_key, division_name) + async def get_season( self, season_id: str, @@ -1132,6 +1370,48 @@ async def get_venues( return parse_venues(mlb_data.data) + async def get_venue_id( + self, + venue_name: str, + search_key: str = "name", + **params, + ) -> list[int]: + """ + return venue id + + Async counterpart of ``Mlb.get_venue_id``. + + Parameters + ---------- + venue_name : str + venue name + + Returns + ------- + list of ints + returns a list of matching venue ints + + See Also + -------- + AsyncMlb.get_venue : return a Venue + AsyncMlb.get_venues : return a list of Venues + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... ids = await mlb.get_venue_id('PNC Park') + [31] + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint="venues", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return find_ids_by_key(mlb_data.data.get("venues") or [], search_key, venue_name) + async def get_standings( self, league_id: int, diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index e01ab83c..2b620120 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -23,6 +23,7 @@ from mlbstatsapi.models.standings import Standings +from ._helpers.id_lookup import find_ids_by_key from ._parsers.attendance import parse_attendance from ._parsers.awards import parse_awards from ._parsers.divisions import parse_divisions, parse_division @@ -302,16 +303,7 @@ def get_people_id(self, fullname: str, sport_id: int = 1, if 400 <= mlb_data.status_code <= 499: return [] - player_ids = [] - - if 'people' in mlb_data.data and mlb_data.data['people']: - for person in mlb_data.data['people']: - try: - if person[search_key].lower() == fullname.lower(): - player_ids.append(person['id']) - except KeyError: - continue - return player_ids + return find_ids_by_key(mlb_data.data.get('people') or [], search_key, fullname) def get_teams(self, sport_id: int = 1, **params) -> List[Team]: """ @@ -499,16 +491,7 @@ def get_team_id(self, team_name: str, if 400 <= mlb_data.status_code <= 499: return [] - team_ids = [] - - if 'teams' in mlb_data.data and mlb_data.data['teams']: - for team in mlb_data.data['teams']: - try: - if team[search_key].lower() == team_name.lower(): - team_ids.append(team['id']) - except (KeyError): - continue - return team_ids + return find_ids_by_key(mlb_data.data.get('teams') or [], search_key, team_name) def get_team_roster(self, team_id: int, **params) -> List[Player]: """ @@ -1327,16 +1310,7 @@ def get_venue_id(self, venue_name: str, if 400 <= mlb_data.status_code <= 499: return [] - venue_ids = [] - - if 'venues' in mlb_data.data and mlb_data.data['venues']: - for venue in mlb_data.data['venues']: - try: - if venue[search_key].lower() == venue_name.lower(): - venue_ids.append(venue['id']) - except KeyError: - continue - return venue_ids + return find_ids_by_key(mlb_data.data.get('venues') or [], search_key, venue_name) def get_sport(self, sport_id: int, **params) -> Union[Sport, None]: """ @@ -1443,17 +1417,7 @@ def get_sport_id(self, sport_name: str, if 400 <= mlb_data.status_code <= 499: return [] - sport_ids = [] - - if 'sports' in mlb_data.data and mlb_data.data['sports']: - for sport in mlb_data.data['sports']: - try: - if sport[search_key].lower() == sport_name.lower(): - sport_ids.append(sport['id']) - except KeyError: - continue - - return sport_ids + return find_ids_by_key(mlb_data.data.get('sports') or [], search_key, sport_name) def get_league(self, league_id: int, **params) -> Union[League, None]: """ @@ -1565,16 +1529,7 @@ def get_league_id(self, league_name: str, if 400 <= mlb_data.status_code <= 499: return [] - league_ids = [] - - if 'leagues' in mlb_data.data and mlb_data.data['leagues']: - for league in mlb_data.data['leagues']: - try: - if league[search_key].lower() == league_name.lower(): - league_ids.append(league['id']) - except KeyError: - continue - return league_ids + return find_ids_by_key(mlb_data.data.get('leagues') or [], search_key, league_name) def get_division(self, division_id: int, **params) -> Union[Division, None]: """ @@ -1679,17 +1634,8 @@ def get_division_id(self, division_name: str, mlb_data = self._mlb_adapter_v1.get(endpoint='divisions', ep_params=params) if 400 <= mlb_data.status_code <= 499: return [] - - division_ids = [] - - if 'divisions' in mlb_data.data and mlb_data.data['divisions']: - for division in mlb_data.data['divisions']: - try: - if division[search_key].lower() == division_name.lower(): - division_ids.append(division['id']) - except KeyError: - continue - return division_ids + + return find_ids_by_key(mlb_data.data.get('divisions') or [], search_key, division_name) def get_season(self, season_id: str, sport_id: int = 1, **params) -> Season: """ diff --git a/tests/external_tests/async_mlb/test_async_mlb_smoke.py b/tests/external_tests/async_mlb/test_async_mlb_smoke.py index 43551090..e6cb148b 100644 --- a/tests/external_tests/async_mlb/test_async_mlb_smoke.py +++ b/tests/external_tests/async_mlb/test_async_mlb_smoke.py @@ -177,3 +177,63 @@ async def scenario(): assert isinstance(derby, HomeRunDerby) asyncio.run(scenario()) + + +def test_async_get_team_id(): + async def scenario(): + async with AsyncMlb() as mlb: + ids = await mlb.get_team_id("Athletics") + + assert ids == [133] + + asyncio.run(scenario()) + + +def test_async_get_people_id(): + async def scenario(): + async with AsyncMlb() as mlb: + ids = await mlb.get_people_id("Ty France") + + assert ids == [664034] + + asyncio.run(scenario()) + + +def test_async_get_sport_id(): + async def scenario(): + async with AsyncMlb() as mlb: + ids = await mlb.get_sport_id("Major League Baseball") + + assert ids == [1] + + asyncio.run(scenario()) + + +def test_async_get_league_id(): + async def scenario(): + async with AsyncMlb() as mlb: + ids = await mlb.get_league_id("American League") + + assert ids == [103] + + asyncio.run(scenario()) + + +def test_async_get_division_id(): + async def scenario(): + async with AsyncMlb() as mlb: + ids = await mlb.get_division_id("American League West") + + assert ids == [200] + + asyncio.run(scenario()) + + +def test_async_get_venue_id(): + async def scenario(): + async with AsyncMlb() as mlb: + ids = await mlb.get_venue_id("PNC Park") + + assert ids == [31] + + asyncio.run(scenario()) diff --git a/tests/helpers/test_id_lookup.py b/tests/helpers/test_id_lookup.py new file mode 100644 index 00000000..85ce9bab --- /dev/null +++ b/tests/helpers/test_id_lookup.py @@ -0,0 +1,35 @@ +from mlbstatsapi._helpers.id_lookup import find_ids_by_key + + +def test_find_ids_by_key_matches_case_insensitively(): + items = [ + {"id": 133, "name": "Athletics"}, + {"id": 147, "name": "Yankees"}, + ] + + assert find_ids_by_key(items, "name", "athletics") == [133] + + +def test_find_ids_by_key_returns_every_match(): + items = [ + {"id": 1, "name": "Duplicate"}, + {"id": 2, "name": "Duplicate"}, + {"id": 3, "name": "Other"}, + ] + + assert find_ids_by_key(items, "name", "Duplicate") == [1, 2] + + +def test_find_ids_by_key_returns_empty_list_for_no_match(): + assert find_ids_by_key([{"id": 1, "name": "Athletics"}], "name", "Yankees") == [] + assert find_ids_by_key([], "name", "Athletics") == [] + + +def test_find_ids_by_key_skips_items_missing_the_search_key_or_id(): + items = [ + {"id": 1}, + {"name": "Athletics"}, + {"id": 2, "name": "Athletics"}, + ] + + assert find_ids_by_key(items, "name", "Athletics") == [2] diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py index 6a7965a4..1613acfb 100644 --- a/tests/test_async_mlb.py +++ b/tests/test_async_mlb.py @@ -1009,6 +1009,82 @@ async def scenario(): assert asyncio.run(scenario()) is None +def test_get_team_id_request_matches_the_sync_client(): + handler = _Handler(_json({"teams": [{"id": 133, "name": "Athletics"}]})) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_team_id("Athletics") + + assert asyncio.run(scenario()) == [133] + assert_matches_sync(handler.request, "get_team_id", "Athletics") + + +def test_get_team_id_returns_empty_list_when_there_is_no_match(): + handler = _Handler(_json({"teams": []})) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_team_id("Nonexistent") + + assert asyncio.run(scenario()) == [] + + +def test_get_people_id_request_matches_the_sync_client(): + handler = _Handler(_json({"people": [{"id": 664034, "fullName": "Ty France"}]})) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_people_id("Ty France") + + assert asyncio.run(scenario()) == [664034] + assert_matches_sync(handler.request, "get_people_id", "Ty France") + + +def test_get_sport_id_request_matches_the_sync_client(): + handler = _Handler(_json({"sports": [{"id": 1, "name": "Major League Baseball"}]})) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_sport_id("Major League Baseball") + + assert asyncio.run(scenario()) == [1] + assert_matches_sync(handler.request, "get_sport_id", "Major League Baseball") + + +def test_get_league_id_request_matches_the_sync_client(): + handler = _Handler(_json({"leagues": [{"id": 103, "name": "American League"}]})) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_league_id("American League") + + assert asyncio.run(scenario()) == [103] + assert_matches_sync(handler.request, "get_league_id", "American League") + + +def test_get_division_id_request_matches_the_sync_client(): + handler = _Handler(_json({"divisions": [{"id": 200, "name": "American League West"}]})) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_division_id("American League West") + + assert asyncio.run(scenario()) == [200] + assert_matches_sync(handler.request, "get_division_id", "American League West") + + +def test_get_venue_id_request_matches_the_sync_client(): + handler = _Handler(_json({"venues": [{"id": 31, "name": "PNC Park"}]})) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_venue_id("PNC Park") + + assert asyncio.run(scenario()) == [31] + assert_matches_sync(handler.request, "get_venue_id", "PNC Park") + + # --------------------------------------------------------------------------- # Parity and concurrency # --------------------------------------------------------------------------- @@ -1021,21 +1097,27 @@ def test_public_signatures_match_the_sync_client(): for name in ( "get_team", "get_teams", + "get_team_id", "get_team_roster", "get_team_coaches", "get_person", "get_people", + "get_people_id", "get_schedule", "get_sport", "get_sports", + "get_sport_id", "get_league", "get_leagues", + "get_league_id", "get_division", "get_divisions", + "get_division_id", "get_season", "get_seasons", "get_venue", "get_venues", + "get_venue_id", "get_standings", "get_attendance", "get_draft", diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 58d71e52..6e7d16bc 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -264,6 +264,14 @@ def _normalize_signature(fn: Any) -> str: "get_draft": "(year_id: int, **params)", "get_awards": "(award_id: str, **params)", "get_homerun_derby": "(game_id, **params)", + "get_team_id": "(team_name: str, search_key: str='name', **params)", + "get_people_id": ( + "(fullname: str, sport_id: int=1, search_key: str='fullName', **params)" + ), + "get_sport_id": "(sport_name: str, search_key: str='name', **params)", + "get_league_id": "(league_name: str, search_key: str='name', **params)", + "get_division_id": "(division_name: str, search_key: str='name', **params)", + "get_venue_id": "(venue_name: str, search_key: str='name', **params)", } diff --git a/tests/test_sync_async_parity.py b/tests/test_sync_async_parity.py index b38346f1..edab2b34 100644 --- a/tests/test_sync_async_parity.py +++ b/tests/test_sync_async_parity.py @@ -660,6 +660,84 @@ def test_get_homerun_derby_success_parity(): assert result.request == ("GET", "/api/v1/homeRunDerby/511101", {}) +def test_get_team_id_success_parity(): + """A matching name is resolved to the same id list on both clients.""" + result = call_both( + "get_team_id", "Athletics", payload={"teams": [{"id": 133, "name": "Athletics"}]} + ) + + assert result.sync == [133] + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/teams", {"fields": "teams,id,name"}) + + +def test_get_people_id_success_parity(): + """A matching name is resolved to the same id list on both clients.""" + result = call_both( + "get_people_id", + "Ty France", + payload={"people": [{"id": 664034, "fullName": "Ty France"}]}, + ) + + assert result.sync == [664034] + assert result.asynchronous == result.sync + assert result.request == ( + "GET", + "/api/v1/sports/1/players", + {"fields": "people,id,fullName"}, + ) + + +def test_get_sport_id_success_parity(): + """A matching name is resolved to the same id list on both clients.""" + result = call_both( + "get_sport_id", + "Major League Baseball", + payload={"sports": [{"id": 1, "name": "Major League Baseball"}]}, + ) + + assert result.sync == [1] + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/sports", {}) + + +def test_get_league_id_success_parity(): + """A matching name is resolved to the same id list on both clients.""" + result = call_both( + "get_league_id", + "American League", + payload={"leagues": [{"id": 103, "name": "American League"}]}, + ) + + assert result.sync == [103] + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/leagues", {"fields": "leagues,id,name"}) + + +def test_get_division_id_success_parity(): + """A matching name is resolved to the same id list on both clients.""" + result = call_both( + "get_division_id", + "American League West", + payload={"divisions": [{"id": 200, "name": "American League West"}]}, + ) + + assert result.sync == [200] + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/divisions", {}) + + +def test_get_venue_id_success_parity(): + """A matching name is resolved to the same id list on both clients.""" + result = call_both( + "get_venue_id", "PNC Park", payload={"venues": [{"id": 31, "name": "PNC Park"}]} + ) + + assert result.sync == [31] + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/venues", {}) + + # --------------------------------------------------------------------------- # Nothing to return # --------------------------------------------------------------------------- From b1b11d30223e0c7543658a232b5a1ad538f48c01 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sat, 22 Aug 2026 16:32:40 -0700 Subject: [PATCH 47/81] feat(async): add v1.1 adapter support and the game endpoint group to AsyncMlb Continues issue #305's endpoint expansion with its largest batch: the game group (get_game, get_game_play_by_play, get_game_line_score, get_game_box_score, get_game_ids). get_game uses the v1.1 live feed endpoint, which AsyncMlb previously had no support for. AsyncMlb now constructs a second AsyncMlbDataAdapter for v1.1 that shares one HTTPX client with the v1 adapter, mirroring Mlb's shared-Session pattern: the v1 adapter resolves and owns the client (library-created when the caller passes none), and v1.1 borrows it without ever closing it directly. Sharing the client this way exposed a retry-budget bug in AsyncMlbDataAdapter: retry eligibility was tied 1:1 to "does this adapter own its client", so a naive v1.1 adapter borrowing v1's client would never retry, even when the underlying client is library-owned. Mlb doesn't have this problem because its retry policy is mounted once on the shared Session, not per adapter version. Fixed by adding a retries_enabled parameter to AsyncMlbDataAdapter, decoupling close-ownership from retry eligibility, defaulting to prior behavior for standalone use. Verified directly and with dedicated regression tests that both adapters now retry exactly when the shared client is library-owned, and neither retries with a caller-injected client. Adds a shared _parsers/games.py reused by both Mlb (refactored internally) and AsyncMlb, faithfully preserving get_game_line_score's documented missing 400-499 guard. Also generalizes the shared sync_request_for/assert_matches_sync test helpers, which only knew about the v1 adapter and hardcoded the /api/v1/ prefix, to check both adapters and use whichever one actually fired. Full parser, endpoint, sync/async parity, and live external smoke test coverage for all five endpoints, plus the frozen ASYNC_MLB_PUBLIC_METHOD_MANIFEST and docs/public-api.md (including a new "API versions used by AsyncMlb" section paralleling Mlb's) are extended accordingly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BtqT3jocrSJRouEew7Nnto --- docs/public-api.md | 28 ++ mlbstatsapi/_parsers/games.py | 40 +++ mlbstatsapi/async_mlb.py | 295 ++++++++++++++++++ mlbstatsapi/async_mlb_dataadapter.py | 21 +- mlbstatsapi/mlb_api.py | 22 +- .../async_mlb/test_async_mlb_smoke.py | 53 ++++ tests/parsers/test_games.py | 127 ++++++++ tests/test_async_mlb.py | 269 +++++++++++++++- tests/test_public_api.py | 8 + tests/test_sync_async_parity.py | 186 +++++++++++ 10 files changed, 1019 insertions(+), 30 deletions(-) create mode 100644 mlbstatsapi/_parsers/games.py create mode 100644 tests/parsers/test_games.py diff --git a/docs/public-api.md b/docs/public-api.md index 70c314d8..93bbdfde 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -268,6 +268,18 @@ One `AsyncMlb` instance supports concurrent in-flight requests on the same event loop. Concurrency is caller-controlled. Cross-event-loop use is not promised. +### API versions used by `AsyncMlb` + +`AsyncMlb` constructs internal adapters for both `v1` and `v1.1` that share +one HTTPX client, mirroring `Mlb`'s shared-Session pattern. Most endpoint +methods use `v1`. `get_game` uses the `v1.1` live feed endpoint. The `v1` +adapter resolves and owns the shared client (library-created when the caller +passes none to `AsyncMlb`, otherwise the caller's own); the `v1.1` adapter +borrows that same client and never closes it directly. Retry eligibility +follows the shared client's ownership on both adapters, not which adapter +version issues a given request, matching `Mlb`'s single retry policy mounted +on the shared `Session`. + ### Endpoint methods The currently supported awaitable endpoint methods are: @@ -318,6 +330,17 @@ get_sport_id(sport_name: str, search_key: str = 'name', **params) get_league_id(league_name: str, search_key: str = 'name', **params) get_division_id(division_name: str, search_key: str = 'name', **params) get_venue_id(venue_name: str, search_key: str = 'name', **params) +get_game(game_id: int, **params) +get_game_play_by_play(game_id: int, **params) +get_game_line_score(game_id: int, **params) +get_game_box_score(game_id: int, **params) +get_game_ids( + date: str = None, + start_date: str = None, + end_date: str = None, + sport_id: int = 1, + **params, +) ``` `get_venue` inherits the same documented quirk as `Mlb.get_venue`: it is @@ -325,6 +348,11 @@ annotated `Venue | None` but returns `[]` (not `None`) on a 400–499 response, matching the sync behavior noted above. This is preserved for parity, not introduced by the async port. +`get_game_line_score` inherits the same documented quirk as +`Mlb.get_game_line_score`: it does not short-circuit on a 400–499 status the +way its sibling game helpers do; missing linescore data falls through to an +implicit `None`. + Every other `Mlb` endpoint method not listed above is not yet supported on `AsyncMlb`; calling it there raises `AttributeError`. See issue #305 for the tracked expansion plan. diff --git a/mlbstatsapi/_parsers/games.py b/mlbstatsapi/_parsers/games.py new file mode 100644 index 00000000..de22e912 --- /dev/null +++ b/mlbstatsapi/_parsers/games.py @@ -0,0 +1,40 @@ +from mlbstatsapi.models.game import BoxScore, Game, Linescore, Plays + + +def parse_game(data: dict, game_id: int) -> Game | None: + """Parse a Game from an MLB /game/{id}/feed/live response body.""" + if not data or data.get("gamePk") != game_id: + return None + return Game(**data) + + +def parse_plays(data: dict) -> Plays | None: + """Parse Plays from an MLB /game/{id}/playByPlay response body.""" + if not data or not data.get("allPlays"): + return None + return Plays(**data) + + +def parse_linescore(data: dict) -> Linescore | None: + """Parse a Linescore from an MLB /game/{id}/linescore response body.""" + if not data or not data.get("teams"): + return None + return Linescore(**data) + + +def parse_boxscore(data: dict) -> BoxScore | None: + """Parse a BoxScore from an MLB /game/{id}/boxscore response body.""" + if not data or not data.get("teams"): + return None + return BoxScore(**data) + + +def parse_game_ids(data: dict) -> list[int]: + """Parse gamePks out of an MLB /schedule response body.""" + if not data or not data.get("dates"): + return [] + return [ + game["gamePk"] + for date in data["dates"] + for game in date["games"] + ] diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index d977738c..5e044c61 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -11,6 +11,13 @@ from ._parsers.awards import parse_awards from ._parsers.divisions import parse_division, parse_divisions from ._parsers.draft import parse_draft +from ._parsers.games import ( + parse_boxscore, + parse_game, + parse_game_ids, + parse_linescore, + parse_plays, +) from ._parsers.homerunderby import parse_homerun_derby from ._parsers.leagues import parse_league, parse_leagues from ._parsers.people import parse_person, parse_people @@ -27,6 +34,7 @@ from .models.awards import Award from .models.divisions import Division from .models.drafts import Round +from .models.game import BoxScore, Game, Linescore, Plays from .models.homerunderby import HomeRunDerby from .models.leagues import League from .models.people import Coach, Person, Player @@ -55,6 +63,11 @@ def __init__( ): self._logger = logger or logging.getLogger(__name__) + # One client is shared by the v1 and v1.1 adapters, mirroring Mlb's + # shared-Session pattern. The v1 adapter resolves and owns the client + # (library-created when the caller passes none); the v1.1 adapter + # borrows that same client and never closes it itself, but still + # retries exactly when the shared client is library-owned. self._mlb_adapter_v1 = AsyncMlbDataAdapter( hostname=hostname, ver="v1", @@ -63,6 +76,15 @@ def __init__( client=client, strict_http=strict_http, ) + self._mlb_adapter_v1_1 = AsyncMlbDataAdapter( + hostname=hostname, + ver="v1.1", + logger=self._logger, + timeout=timeout, + client=self._mlb_adapter_v1._client, + strict_http=strict_http, + retries_enabled=self._mlb_adapter_v1._owns_client, + ) async def aclose(self) -> None: """Close library-owned async resources.""" @@ -760,6 +782,279 @@ async def get_schedule( return parse_schedule(mlb_data.data) + async def get_game( + self, + game_id: int, + **params, + ) -> Game | None: + """ + Return the game for a specific game id + Gumbo Live Feed for a specific gamePk. + + Async counterpart of ``Mlb.get_game``. Uses the ``v1.1`` live feed + endpoint, like the sync client. + + Parameters + ---------- + game_id : int + Insert gamePk to return the GUMBO live feed for a specific game. + + Other Parameters + ---------------- + timecode : str + Use this parameter to return a snapshot of the data at the + specified time. Format: YYYYMMDD_HHMMSS. + Return timecodes from timecodes endpoint + https://statsapi.mlb.com/api/v1.1/game/534196/feed/live/timestamps + hydrate : str + Insert hydration(s) to return putout credits or defensive + positioning data for all plays in a particular game. + Format 'credits,alignment,flags' + Available Hydrations: + credits + alignment + flags + officials + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + Game + + See Also + -------- + AsyncMlb.get_game_play_by_play : return play by play data for a game + AsyncMlb.get_game_line_score : return a linescore for a game + AsyncMlb.get_game_box_score : return a boxscore for a game + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... game = await mlb.get_game(662242) + Game + """ + mlb_data = await self._mlb_adapter_v1_1.get( + endpoint=f"game/{game_id}/feed/live", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_game(mlb_data.data, game_id) + + async def get_game_play_by_play( + self, + game_id: int, + **params, + ) -> Plays | None: + """ + return the playbyplay of a game for a specific game id + + Async counterpart of ``Mlb.get_game_play_by_play``. + + Parameters + ---------- + game_id : int + Game id number + + Other Parameters + ---------------- + timecode : int + Use this parameter to return a snapshot of the data at the + specified time. Format: YYYYMMDD_HHMMSS + fields : + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + Plays + + See Also + -------- + AsyncMlb.get_game_line_score : return a linescore for a game + AsyncMlb.get_game_box_score : return a boxscore for a game + AsyncMlb.get_game : return a specific game from game id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... plays = await mlb.get_game_play_by_play(662242) + Plays + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"game/{game_id}/playByPlay", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_plays(mlb_data.data) + + async def get_game_line_score( + self, + game_id: int, + **params, + ) -> Linescore | None: + """ + return the Linescore of a game for a specific game id + + Async counterpart of ``Mlb.get_game_line_score``. + + Parameters + ---------- + game_id : int + Game id number + + Other Parameters + ---------------- + timecode : int + Use this parameter to return a snapshot of the data at the + specified time. Format: YYYYMMDD_HHMMSS + fields : + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + Linescore + + See Also + -------- + AsyncMlb.get_game_play_by_play : return play by play data for a game + AsyncMlb.get_game_box_score : return a boxscore for a game + AsyncMlb.get_game : return a specific game from game id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... linescore = await mlb.get_game_line_score(662242) + Linescore + """ + # Documented quirk: unlike its sibling game helpers, this does not + # short-circuit on a 400-499 status; missing linescore data falls + # through to an implicit None below. See docs/public-api.md. + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"game/{game_id}/linescore", + ep_params=params, + ) + + return parse_linescore(mlb_data.data) + + async def get_game_box_score( + self, + game_id: int, + **params, + ) -> BoxScore | None: + """ + return the boxscore of a game for a specific game id + + Async counterpart of ``Mlb.get_game_box_score``. + + Parameters + ---------- + game_id : int + Game id number + + Other Parameters + ---------------- + timecode : int + Use this parameter to return a snapshot of the data at the + specified time. Format: YYYYMMDD_HHMMSS + fields : + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + BoxScore + + See Also + -------- + AsyncMlb.get_game_play_by_play : return play by play data for a game + AsyncMlb.get_game_line_score : return a linescore for a game + AsyncMlb.get_game : return a specific game from game id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... boxscore = await mlb.get_game_box_score(662242) + BoxScore + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"game/{game_id}/boxscore", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_boxscore(mlb_data.data) + + async def get_game_ids( + self, + date: str = None, + start_date: str = None, + end_date: str = None, + sport_id: int = 1, + **params, + ) -> list[int]: + """ + return game ids for a specific date and game status + + Async counterpart of ``Mlb.get_game_ids``. + + Parameters + ---------- + date : str + date, 'yyyy-mm-dd' + start_date : str + start date, 'yyyy-mm-dd' + end_date : str + end date, 'yyyy-mm-dd' + spord_id : int + spord id of schedule defaults to 1 + + Returns + ------- + list of ints + returns a list of matching game ids + + See Also + -------- + AsyncMlb.get_game_play_by_play : return play by play data for a game + AsyncMlb.get_game_line_score : return a linescore for a game + AsyncMlb.get_game : return a specific game from game id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... ids = await mlb.get_game_ids(date="2022-09-26") + """ + if start_date and end_date: + params["startDate"] = start_date + params["endDate"] = end_date + elif date and not (start_date or end_date): + params["date"] = date + else: + return None + + params["sportId"] = sport_id + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="schedule", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_game_ids(mlb_data.data) + async def get_sport( self, sport_id: int, diff --git a/mlbstatsapi/async_mlb_dataadapter.py b/mlbstatsapi/async_mlb_dataadapter.py index 65f29749..c91109c0 100644 --- a/mlbstatsapi/async_mlb_dataadapter.py +++ b/mlbstatsapi/async_mlb_dataadapter.py @@ -43,12 +43,21 @@ def __init__( client: httpx.AsyncClient | None = None, *, strict_http: bool = True, + retries_enabled: bool | None = None, ): self.url = f"https://{hostname}/api/{ver}/" self._logger = logger or logging.getLogger(__name__) self._timeout = timeout self._strict_http = strict_http self._owns_client = client is None + # Retry eligibility normally follows client ownership, like the sync + # adapter (retries are mounted on the Session, not per MlbDataAdapter + # version). AsyncMlb overrides this for its v1.1 adapter, which + # borrows the v1 adapter's client rather than owning it directly, so + # both adapters retry exactly when the shared client is library-owned. + self._retries_enabled = ( + self._owns_client if retries_enabled is None else retries_enabled + ) self._retry_policy = create_retry_policy() if client is None: @@ -208,7 +217,7 @@ async def _request_with_retries( ) except httpx.ReadTimeout as exc: - max_attempts = policy.read + 1 if self._owns_client else 1 + max_attempts = policy.read + 1 if self._retries_enabled else 1 if attempt >= max_attempts: self._logger.error(msg=str(exc)) @@ -221,7 +230,7 @@ async def _request_with_retries( # Caught before httpx.TimeoutException: a connect timeout is a # timeout for the caller, but it spends the connect budget so # the retry accounting matches the sync policy. - max_attempts = policy.connect + 1 if self._owns_client else 1 + max_attempts = policy.connect + 1 if self._retries_enabled else 1 if attempt >= max_attempts: self._logger.error(msg=str(exc)) @@ -231,7 +240,7 @@ async def _request_with_retries( continue except httpx.ConnectError as exc: - max_attempts = policy.connect + 1 if self._owns_client else 1 + max_attempts = policy.connect + 1 if self._retries_enabled else 1 if attempt >= max_attempts: self._logger.error(msg=str(exc)) @@ -244,7 +253,7 @@ async def _request_with_retries( continue except httpx.TimeoutException as exc: - max_attempts = policy.total + 1 if self._owns_client else 1 + max_attempts = policy.total + 1 if self._retries_enabled else 1 if attempt >= max_attempts: raise MlbTimeoutError("Request failed") from exc @@ -256,7 +265,7 @@ async def _request_with_retries( continue except httpx.RequestError as exc: - max_attempts = policy.total + 1 if self._owns_client else 1 + max_attempts = policy.total + 1 if self._retries_enabled else 1 if attempt >= max_attempts: self._logger.error(msg=str(exc)) @@ -265,7 +274,7 @@ async def _request_with_retries( await self._sleep_before_retry(attempt=attempt, response=None) continue - max_attempts = policy.status + 1 if self._owns_client else 1 + max_attempts = policy.status + 1 if self._retries_enabled else 1 if response.status_code not in policy.status_forcelist or attempt >= max_attempts: return response diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index 2b620120..b19c5308 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -28,6 +28,7 @@ from ._parsers.awards import parse_awards from ._parsers.divisions import parse_divisions, parse_division from ._parsers.draft import parse_draft +from ._parsers.games import parse_boxscore, parse_game, parse_game_ids, parse_linescore, parse_plays from ._parsers.homerunderby import parse_homerun_derby from ._parsers.leagues import parse_leagues, parse_league from ._parsers.people import parse_people, parse_person @@ -934,8 +935,7 @@ def get_game(self, game_id: int, **params) -> Union[Game, None]: if 400 <= mlb_data.status_code <= 499: return None - if 'gamePk' in mlb_data.data and mlb_data.data['gamePk'] == game_id: - return Game(**mlb_data.data) + return parse_game(mlb_data.data, game_id) def get_game_play_by_play(self, game_id: int, **params) -> Union[Plays, None]: """ @@ -979,8 +979,7 @@ def get_game_play_by_play(self, game_id: int, **params) -> Union[Plays, None]: if 400 <= mlb_data.status_code <= 499: return None - if 'allPlays' in mlb_data.data and mlb_data.data['allPlays']: - return Plays(**mlb_data.data) + return parse_plays(mlb_data.data) def get_game_line_score(self, game_id: int, **params) -> Union[Linescore, None]: """ @@ -1022,8 +1021,7 @@ def get_game_line_score(self, game_id: int, **params) -> Union[Linescore, None]: mlb_data = self._mlb_adapter_v1.get(endpoint=f'game/{game_id}/linescore', ep_params=params) - if 'teams' in mlb_data.data and mlb_data.data['teams']: - return Linescore(**mlb_data.data) + return parse_linescore(mlb_data.data) def get_game_box_score(self, game_id: int, **params) -> Union[BoxScore, None]: """ @@ -1067,8 +1065,7 @@ def get_game_box_score(self, game_id: int, **params) -> Union[BoxScore, None]: if 400 <= mlb_data.status_code <= 499: return None - if 'teams' in mlb_data.data and mlb_data.data['teams']: - return BoxScore(**mlb_data.data) + return parse_boxscore(mlb_data.data) def get_game_ids(self, date: str = None, @@ -1121,14 +1118,7 @@ def get_game_ids(self, date: str = None, if 400 <= mlb_data.status_code <= 499: return [] - game_ids = [] - - if 'dates' in mlb_data.data and mlb_data.data['dates']: - for date in mlb_data.data['dates']: - for game in date['games']: - game_ids.append(game['gamePk']) - - return game_ids + return parse_game_ids(mlb_data.data) def get_gamepace(self, season: str, sport_id=1, **params) -> Union[GamePace, None]: """ diff --git a/tests/external_tests/async_mlb/test_async_mlb_smoke.py b/tests/external_tests/async_mlb/test_async_mlb_smoke.py index e6cb148b..949b9919 100644 --- a/tests/external_tests/async_mlb/test_async_mlb_smoke.py +++ b/tests/external_tests/async_mlb/test_async_mlb_smoke.py @@ -5,6 +5,7 @@ from mlbstatsapi.models.awards import Award from mlbstatsapi.models.divisions import Division from mlbstatsapi.models.drafts import Round +from mlbstatsapi.models.game import BoxScore, Game, Linescore, Plays from mlbstatsapi.models.homerunderby import HomeRunDerby from mlbstatsapi.models.leagues import League from mlbstatsapi.models.people import Coach, Person, Player @@ -237,3 +238,55 @@ async def scenario(): assert ids == [31] asyncio.run(scenario()) + + +def test_async_get_game(): + async def scenario(): + async with AsyncMlb() as mlb: + game = await mlb.get_game(717911) + + assert isinstance(game, Game) + assert game.id == 717911 + + asyncio.run(scenario()) + + +def test_async_get_game_play_by_play(): + async def scenario(): + async with AsyncMlb() as mlb: + plays = await mlb.get_game_play_by_play(717911) + + assert isinstance(plays, Plays) + assert plays.all_plays + + asyncio.run(scenario()) + + +def test_async_get_game_line_score(): + async def scenario(): + async with AsyncMlb() as mlb: + linescore = await mlb.get_game_line_score(717911) + + assert isinstance(linescore, Linescore) + + asyncio.run(scenario()) + + +def test_async_get_game_box_score(): + async def scenario(): + async with AsyncMlb() as mlb: + boxscore = await mlb.get_game_box_score(717911) + + assert isinstance(boxscore, BoxScore) + + asyncio.run(scenario()) + + +def test_async_get_game_ids(): + async def scenario(): + async with AsyncMlb() as mlb: + ids = await mlb.get_game_ids(date="2023-06-03") + + assert 717911 in ids + + asyncio.run(scenario()) diff --git a/tests/parsers/test_games.py b/tests/parsers/test_games.py new file mode 100644 index 00000000..6fa9a538 --- /dev/null +++ b/tests/parsers/test_games.py @@ -0,0 +1,127 @@ +from mlbstatsapi._parsers.games import ( + parse_boxscore, + parse_game, + parse_game_ids, + parse_linescore, + parse_plays, +) +from mlbstatsapi.models.game import BoxScore, Game, Linescore, Plays + + +GAME_PAYLOAD = {"gamePk": 717911, "link": "/api/v1.1/game/717911/feed/live"} + +PLAY_PAYLOAD = { + "result": { + "type": "atBat", + "event": "Single", + "eventType": "single", + "description": "x", + "rbi": 0, + "awayScore": 0, + "homeScore": 0, + }, + "about": { + "atBatIndex": 0, + "halfInning": "top", + "isTopInning": True, + "inning": 1, + "isComplete": True, + "isScoringPlay": False, + "hasOut": True, + "captivatingIndex": 0, + }, + "count": {"balls": 0, "outs": 1, "strikes": 0}, + "matchup": { + "batter": {"id": 1, "link": "/api/v1/people/1", "fullName": "x"}, + "batSide": {"code": "R", "description": "Right"}, + "pitcher": {"id": 2, "link": "/api/v1/people/2", "fullName": "y"}, + "pitchHand": {"code": "R", "description": "Right"}, + "batterHotColdZones": [], + "pitcherHotColdZones": [], + "splits": {"batter": "vs_RHP", "pitcher": "vs_RHB", "menOnBase": "Empty"}, + }, + "pitchIndex": [], + "actionIndex": [], + "runnerIndex": [], + "atBatIndex": 0, +} +PLAYS_PAYLOAD = {"scoringPlays": [], "allPlays": [PLAY_PAYLOAD]} + +TEAM_PAYLOAD = {"id": 133, "link": "/api/v1/teams/133", "name": "Athletics"} +LINESCORE_PAYLOAD = { + "scheduledInnings": 9, + "teams": {"home": {}, "away": {}}, + "defense": {"team": TEAM_PAYLOAD}, + "offense": {"team": TEAM_PAYLOAD}, +} + +BOXSCORE_SIDE = { + "team": TEAM_PAYLOAD, + "teamStats": {}, + "players": {}, + "batters": [], + "pitchers": [], + "bench": [], + "bullpen": [], + "battingOrder": [], + "info": [], +} +BOXSCORE_PAYLOAD = {"teams": {"home": BOXSCORE_SIDE, "away": BOXSCORE_SIDE}} + +SCHEDULE_WITH_GAMES_PAYLOAD = { + "dates": [ + {"games": [{"gamePk": 1}, {"gamePk": 2}]}, + {"games": [{"gamePk": 3}]}, + ] +} + + +def test_parse_game(): + """parse_game only accepts a payload whose gamePk matches the requested id.""" + assert parse_game({}, 717911) is None + assert parse_game({"gamePk": 1, "link": "x"}, 717911) is None + + game = parse_game(GAME_PAYLOAD, 717911) + + assert isinstance(game, Game) + assert game.id == 717911 + + +def test_parse_plays(): + """parse_plays requires a non-empty allPlays list.""" + assert parse_plays({}) is None + assert parse_plays({"allPlays": []}) is None + + plays = parse_plays(PLAYS_PAYLOAD) + + assert isinstance(plays, Plays) + assert len(plays.all_plays) == 1 + + +def test_parse_linescore(): + """parse_linescore requires a non-empty teams object.""" + assert parse_linescore({}) is None + assert parse_linescore({"teams": {}}) is None + + linescore = parse_linescore(LINESCORE_PAYLOAD) + + assert isinstance(linescore, Linescore) + assert linescore.scheduled_innings == 9 + + +def test_parse_boxscore(): + """parse_boxscore requires a non-empty teams object.""" + assert parse_boxscore({}) is None + assert parse_boxscore({"teams": {}}) is None + + boxscore = parse_boxscore(BOXSCORE_PAYLOAD) + + assert isinstance(boxscore, BoxScore) + + +def test_parse_game_ids(): + """parse_game_ids flattens dates -> games -> gamePk.""" + assert parse_game_ids({}) == [] + assert parse_game_ids({"dates": []}) == [] + + assert parse_game_ids(SCHEDULE_WITH_GAMES_PAYLOAD) == [1, 2, 3] diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py index 1613acfb..08a98d2e 100644 --- a/tests/test_async_mlb.py +++ b/tests/test_async_mlb.py @@ -42,6 +42,7 @@ from mlbstatsapi.models.awards import Award # noqa: E402 from mlbstatsapi.models.divisions import Division # noqa: E402 from mlbstatsapi.models.drafts import Round # noqa: E402 +from mlbstatsapi.models.game import BoxScore, Game, Linescore, Plays # noqa: E402 from mlbstatsapi.models.homerunderby import HomeRunDerby # noqa: E402 from mlbstatsapi.models.leagues import League # noqa: E402 from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 @@ -237,6 +238,68 @@ "bonusTime": False, }, } +GAME_FEED_PAYLOAD = {"gamePk": 717911, "link": "/api/v1.1/game/717911/feed/live"} +PLAY_PAYLOAD = { + "result": { + "type": "atBat", + "event": "Single", + "eventType": "single", + "description": "x", + "rbi": 0, + "awayScore": 0, + "homeScore": 0, + }, + "about": { + "atBatIndex": 0, + "halfInning": "top", + "isTopInning": True, + "inning": 1, + "isComplete": True, + "isScoringPlay": False, + "hasOut": True, + "captivatingIndex": 0, + }, + "count": {"balls": 0, "outs": 1, "strikes": 0}, + "matchup": { + "batter": {"id": 1, "link": "/api/v1/people/1", "fullName": "x"}, + "batSide": {"code": "R", "description": "Right"}, + "pitcher": {"id": 2, "link": "/api/v1/people/2", "fullName": "y"}, + "pitchHand": {"code": "R", "description": "Right"}, + "batterHotColdZones": [], + "pitcherHotColdZones": [], + "splits": {"batter": "vs_RHP", "pitcher": "vs_RHB", "menOnBase": "Empty"}, + }, + "pitchIndex": [], + "actionIndex": [], + "runnerIndex": [], + "atBatIndex": 0, +} +PLAYS_PAYLOAD = {"scoringPlays": [], "allPlays": [PLAY_PAYLOAD]} +GAME_TEAM_PAYLOAD = {"id": 133, "link": "/api/v1/teams/133", "name": "Athletics"} +LINESCORE_PAYLOAD = { + "scheduledInnings": 9, + "teams": {"home": {}, "away": {}}, + "defense": {"team": GAME_TEAM_PAYLOAD}, + "offense": {"team": GAME_TEAM_PAYLOAD}, +} +BOXSCORE_SIDE = { + "team": GAME_TEAM_PAYLOAD, + "teamStats": {}, + "players": {}, + "batters": [], + "pitchers": [], + "bench": [], + "bullpen": [], + "battingOrder": [], + "info": [], +} +BOXSCORE_PAYLOAD = {"teams": {"home": BOXSCORE_SIDE, "away": BOXSCORE_SIDE}} +SCHEDULE_WITH_GAMES_PAYLOAD = { + "dates": [ + {"games": [{"gamePk": 1}, {"gamePk": 2}]}, + {"games": [{"gamePk": 3}]}, + ] +} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -349,23 +412,31 @@ def mock_transport_client(**client_kwargs) -> httpx.AsyncClient: await mlb._mlb_adapter_v1._client.aclose() -def sync_request_for(method: str, *args, **kwargs) -> tuple[str, dict]: - """Return the endpoint and params ``Mlb`` builds for a call. +def sync_request_for(method: str, *args, **kwargs) -> tuple[str, dict, str]: + """Return the endpoint, params, and API version ``Mlb`` builds for a call. - The adapter is stubbed, so this reaches no network; it just reads back what - the synchronous client asked for. + The adapters are stubbed, so this reaches no network; it just reads back + what the synchronous client asked for. Most methods call the v1 adapter; + get_game calls v1.1, so both are stubbed and whichever one was actually + called wins. """ with Mlb() as sync_mlb: sync_mlb._mlb_adapter_v1.get = MagicMock( return_value=MlbResult(status_code=200, message=None, data={}) ) + sync_mlb._mlb_adapter_v1_1.get = MagicMock( + return_value=MlbResult(status_code=200, message=None, data={}) + ) getattr(sync_mlb, method)(*args, **kwargs) - call = sync_mlb._mlb_adapter_v1.get.call_args + if sync_mlb._mlb_adapter_v1.get.called: + call, ver = sync_mlb._mlb_adapter_v1.get.call_args, "v1" + else: + call, ver = sync_mlb._mlb_adapter_v1_1.get.call_args, "v1.1" # Most Mlb methods pass endpoint as a keyword; get_attendance passes it # positionally, so fall back to the first positional argument. endpoint = call.kwargs["endpoint"] if "endpoint" in call.kwargs else call.args[0] - return endpoint, call.kwargs["ep_params"] + return endpoint, call.kwargs["ep_params"], ver def _flatten_params(params: dict) -> list[tuple[str, str]]: @@ -386,12 +457,12 @@ def _flatten_params(params: dict) -> list[tuple[str, str]]: def assert_matches_sync(request: httpx.Request, method: str, *args, **kwargs) -> None: """Assert an observed request is the one ``Mlb`` would have made.""" - endpoint, params = sync_request_for(method, *args, **kwargs) + endpoint, params, ver = sync_request_for(method, *args, **kwargs) # get_awards's endpoint string has a trailing "?" (harmless legacy cruft # both Requests and HTTPX strip as an empty query separator), which never # shows up in url.path. - assert request.url.path == f"/api/v1/{endpoint}".rstrip("?") + assert request.url.path == f"/api/{ver}/{endpoint}".rstrip("?") assert sorted(request.url.params.multi_items()) == _flatten_params(params) @@ -512,6 +583,48 @@ async def scenario(): asyncio.run(scenario()) +def test_v1_and_v1_1_adapters_share_one_client(): + """One client is shared by both adapters, mirroring Mlb's shared Session.""" + + async def scenario(): + async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: + assert mlb._mlb_adapter_v1._client is mlb._mlb_adapter_v1_1._client + # Only v1 tracks close-ownership of the shared client; v1.1 must + # never double-close it. + assert mlb._mlb_adapter_v1._owns_client is True + assert mlb._mlb_adapter_v1_1._owns_client is False + + asyncio.run(scenario()) + + +def test_v1_1_adapter_retries_when_the_shared_client_is_library_owned(): + """Retry eligibility follows the shared client's ownership, not which + adapter version issues the request (matching Mlb, which configures + retries once on the shared Session).""" + + async def scenario(): + async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: + assert mlb._mlb_adapter_v1._retries_enabled is True + assert mlb._mlb_adapter_v1_1._retries_enabled is True + + asyncio.run(scenario()) + + +def test_v1_1_adapter_does_not_retry_with_a_caller_injected_client(): + handler = _Handler(_json(TEAM_PAYLOAD)) + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + + async def scenario(): + try: + async with AsyncMlb(client=client) as mlb: + assert mlb._mlb_adapter_v1._retries_enabled is False + assert mlb._mlb_adapter_v1_1._retries_enabled is False + finally: + await client.aclose() + + asyncio.run(scenario()) + + def test_aclose_is_idempotent(): """Closing more than once, however the caller mixes the forms, is safe.""" @@ -1085,6 +1198,141 @@ async def scenario(): assert_matches_sync(handler.request, "get_venue_id", "PNC Park") +def test_get_game_requests_the_v1_1_feed_endpoint_and_parses_the_result(): + handler = _Handler(_json(GAME_FEED_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_game(717911) + + game = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_game", 717911) + assert isinstance(game, Game) + assert game.id == 717911 + assert handler.request.url.path == "/api/v1.1/game/717911/feed/live" + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_game_returns_none_when_there_is_no_game(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_game(1) + + assert asyncio.run(scenario()) is None + + +def test_get_game_play_by_play_requests_the_playbyplay_endpoint_and_parses_the_result(): + handler = _Handler(_json(PLAYS_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_game_play_by_play(717911) + + plays = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_game_play_by_play", 717911) + assert isinstance(plays, Plays) + assert len(plays.all_plays) == 1 + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_game_play_by_play_returns_none_when_there_are_no_plays(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_game_play_by_play(1) + + assert asyncio.run(scenario()) is None + + +def test_get_game_line_score_requests_the_linescore_endpoint_and_parses_the_result(): + handler = _Handler(_json(LINESCORE_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_game_line_score(717911) + + linescore = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_game_line_score", 717911) + assert isinstance(linescore, Linescore) + assert linescore.scheduled_innings == 9 + + +def test_get_game_line_score_returns_none_on_an_empty_200_without_a_status_guard(): + """get_game_line_score has no 400-499 guard; documented in public-api.md.""" + handler = _Handler(NO_RESULT_RESPONSES["empty 200"]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_game_line_score(1) + + assert asyncio.run(scenario()) is None + + +def test_get_game_box_score_requests_the_boxscore_endpoint_and_parses_the_result(): + handler = _Handler(_json(BOXSCORE_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_game_box_score(717911) + + boxscore = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_game_box_score", 717911) + assert isinstance(boxscore, BoxScore) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_game_box_score_returns_none_when_there_is_no_boxscore(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_game_box_score(1) + + assert asyncio.run(scenario()) is None + + +def test_get_game_ids_requests_the_schedule_endpoint_and_parses_the_result(): + handler = _Handler(_json(SCHEDULE_WITH_GAMES_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_game_ids(date="2022-09-26") + + game_ids = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_game_ids", date="2022-09-26") + assert game_ids == [1, 2, 3] + + +def test_get_game_ids_without_a_selector_returns_none_without_requesting(): + handler = _Handler(_json(SCHEDULE_WITH_GAMES_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_game_ids() + + assert asyncio.run(scenario()) is None + assert handler.requests == [] + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_game_ids_returns_empty_list_when_there_are_no_games(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_game_ids(date="2022-09-26") + + assert asyncio.run(scenario()) == [] + + # --------------------------------------------------------------------------- # Parity and concurrency # --------------------------------------------------------------------------- @@ -1123,6 +1371,11 @@ def test_public_signatures_match_the_sync_client(): "get_draft", "get_awards", "get_homerun_derby", + "get_game", + "get_game_play_by_play", + "get_game_line_score", + "get_game_box_score", + "get_game_ids", ): sync_params = inspect.signature(getattr(Mlb, name)).parameters async_params = inspect.signature(getattr(AsyncMlb, name)).parameters diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 6e7d16bc..b3cae605 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -272,6 +272,14 @@ def _normalize_signature(fn: Any) -> str: "get_league_id": "(league_name: str, search_key: str='name', **params)", "get_division_id": "(division_name: str, search_key: str='name', **params)", "get_venue_id": "(venue_name: str, search_key: str='name', **params)", + "get_game": "(game_id: int, **params)", + "get_game_play_by_play": "(game_id: int, **params)", + "get_game_line_score": "(game_id: int, **params)", + "get_game_box_score": "(game_id: int, **params)", + "get_game_ids": ( + "(date: str=None, start_date: str=None, end_date: str=None, " + "sport_id: int=1, **params)" + ), } diff --git a/tests/test_sync_async_parity.py b/tests/test_sync_async_parity.py index edab2b34..17461364 100644 --- a/tests/test_sync_async_parity.py +++ b/tests/test_sync_async_parity.py @@ -46,6 +46,7 @@ from mlbstatsapi.models.awards import Award # noqa: E402 from mlbstatsapi.models.divisions import Division # noqa: E402 from mlbstatsapi.models.drafts import Round # noqa: E402 +from mlbstatsapi.models.game import BoxScore, Game, Linescore, Plays # noqa: E402 from mlbstatsapi.models.homerunderby import HomeRunDerby # noqa: E402 from mlbstatsapi.models.leagues import League # noqa: E402 from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 @@ -241,6 +242,68 @@ "bonusTime": False, }, } +GAME_FEED_PAYLOAD = {"gamePk": 717911, "link": "/api/v1.1/game/717911/feed/live"} +PLAY_PAYLOAD = { + "result": { + "type": "atBat", + "event": "Single", + "eventType": "single", + "description": "x", + "rbi": 0, + "awayScore": 0, + "homeScore": 0, + }, + "about": { + "atBatIndex": 0, + "halfInning": "top", + "isTopInning": True, + "inning": 1, + "isComplete": True, + "isScoringPlay": False, + "hasOut": True, + "captivatingIndex": 0, + }, + "count": {"balls": 0, "outs": 1, "strikes": 0}, + "matchup": { + "batter": {"id": 1, "link": "/api/v1/people/1", "fullName": "x"}, + "batSide": {"code": "R", "description": "Right"}, + "pitcher": {"id": 2, "link": "/api/v1/people/2", "fullName": "y"}, + "pitchHand": {"code": "R", "description": "Right"}, + "batterHotColdZones": [], + "pitcherHotColdZones": [], + "splits": {"batter": "vs_RHP", "pitcher": "vs_RHB", "menOnBase": "Empty"}, + }, + "pitchIndex": [], + "actionIndex": [], + "runnerIndex": [], + "atBatIndex": 0, +} +PLAYS_PAYLOAD = {"scoringPlays": [], "allPlays": [PLAY_PAYLOAD]} +GAME_TEAM_PAYLOAD = {"id": 133, "link": "/api/v1/teams/133", "name": "Athletics"} +LINESCORE_PAYLOAD = { + "scheduledInnings": 9, + "teams": {"home": {}, "away": {}}, + "defense": {"team": GAME_TEAM_PAYLOAD}, + "offense": {"team": GAME_TEAM_PAYLOAD}, +} +BOXSCORE_SIDE = { + "team": GAME_TEAM_PAYLOAD, + "teamStats": {}, + "players": {}, + "batters": [], + "pitchers": [], + "bench": [], + "bullpen": [], + "battingOrder": [], + "info": [], +} +BOXSCORE_PAYLOAD = {"teams": {"home": BOXSCORE_SIDE, "away": BOXSCORE_SIDE}} +SCHEDULE_WITH_GAMES_PAYLOAD = { + "dates": [ + {"games": [{"gamePk": 1}, {"gamePk": 2}]}, + {"games": [{"gamePk": 3}]}, + ] +} SCHEDULE_PAYLOAD = { "totalItems": 1, "totalEvents": 0, @@ -738,6 +801,65 @@ def test_get_venue_id_success_parity(): assert result.request == ("GET", "/api/v1/venues", {}) +def test_get_game_success_parity(): + """A successful game feed response parses to the same Game on both clients, + hitting the v1.1 endpoint on both.""" + result = call_both("get_game", 717911, payload=GAME_FEED_PAYLOAD) + + assert isinstance(result.sync, Game), "sync get_game did not return a Game" + assert result.sync.id == 717911 + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1.1/game/717911/feed/live", {}) + + +def test_get_game_play_by_play_success_parity(): + """A successful play-by-play response parses to the same Plays on both clients.""" + result = call_both("get_game_play_by_play", 717911, payload=PLAYS_PAYLOAD) + + assert isinstance(result.sync, Plays), ( + "sync get_game_play_by_play did not return a Plays" + ) + assert len(result.sync.all_plays) == 1 + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/game/717911/playByPlay", {}) + + +def test_get_game_line_score_success_parity(): + """A successful linescore response parses to the same Linescore on both clients.""" + result = call_both("get_game_line_score", 717911, payload=LINESCORE_PAYLOAD) + + assert isinstance(result.sync, Linescore), ( + "sync get_game_line_score did not return a Linescore" + ) + assert result.sync.scheduled_innings == 9 + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/game/717911/linescore", {}) + + +def test_get_game_box_score_success_parity(): + """A successful boxscore response parses to the same BoxScore on both clients.""" + result = call_both("get_game_box_score", 717911, payload=BOXSCORE_PAYLOAD) + + assert isinstance(result.sync, BoxScore), ( + "sync get_game_box_score did not return a BoxScore" + ) + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/game/717911/boxscore", {}) + + +def test_get_game_ids_success_parity(): + """A successful schedule response resolves to the same gamePk list on both clients.""" + result = call_both("get_game_ids", date="2022-09-26", payload=SCHEDULE_WITH_GAMES_PAYLOAD) + + assert result.sync == [1, 2, 3] + assert result.asynchronous == result.sync + assert result.request == ( + "GET", + "/api/v1/schedule", + {"date": "2022-09-26", "sportId": "1"}, + ) + + # --------------------------------------------------------------------------- # Nothing to return # --------------------------------------------------------------------------- @@ -948,6 +1070,70 @@ def test_get_homerun_derby_malformed_error_body_parity(): assert result.asynchronous is None +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_game_no_result_parity(label): + """Every no-result response returns None on either client (v1.1 endpoint).""" + result = call_both("get_game", 1, **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, f"sync get_game returned {result.sync!r} for {label}" + assert result.asynchronous is None, ( + f"async get_game returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_game_play_by_play_no_result_parity(label): + """Every no-result response returns None on either client.""" + result = call_both("get_game_play_by_play", 1, **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, ( + f"sync get_game_play_by_play returned {result.sync!r} for {label}" + ) + assert result.asynchronous is None, ( + f"async get_game_play_by_play returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_game_line_score_no_result_parity(label): + """Every no-result response returns None on either client, even without + get_game_line_score's missing 400-499 guard (documented quirk).""" + result = call_both("get_game_line_score", 1, **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, ( + f"sync get_game_line_score returned {result.sync!r} for {label}" + ) + assert result.asynchronous is None, ( + f"async get_game_line_score returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_game_box_score_no_result_parity(label): + """Every no-result response returns None on either client.""" + result = call_both("get_game_box_score", 1, **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, ( + f"sync get_game_box_score returned {result.sync!r} for {label}" + ) + assert result.asynchronous is None, ( + f"async get_game_box_score returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_game_ids_no_result_parity(label): + """Every no-result response returns an empty list on either client.""" + result = call_both( + "get_game_ids", date="2022-09-26", **NO_RESULT_RESPONSES[label] + ) + + assert result.sync == [], f"sync get_game_ids returned {result.sync!r} for {label}" + assert result.asynchronous == [], ( + f"async get_game_ids returned {result.asynchronous!r} for {label}" + ) + + # --------------------------------------------------------------------------- # Representative public failure behavior (get_team) # --------------------------------------------------------------------------- From abfc10d00cc7066e1131972f49b8c7f1bd6f18ce Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sat, 22 Aug 2026 17:08:03 -0700 Subject: [PATCH 48/81] refactor(async): replace the AsyncMlbDataAdapter retries_enabled constructor param with a private setter Follow-up to the v1.1 adapter sharing added for get_game (issue #305). The previous fix added retries_enabled: bool | None to AsyncMlbDataAdapter.__init__ so AsyncMlb could tell its borrowed v1.1 adapter that the shared client is library-owned, even though v1.1 itself received a non-None client and would otherwise conclude it's using a caller-injected client and disable retries. That parameter looked like public, supported configuration on a documented class's constructor, when it was really an internal coordination detail: only AsyncMlb, which actually owns the shared transport, has the information to make that call, and no standalone AsyncMlbDataAdapter caller should reach for it. Removes the constructor parameter entirely (its public signature is back to exactly what it was before the v1.1 work) and replaces it with a private _set_retries_enabled() method, explicitly documented as an internal coordination hook rather than public API. AsyncMlb.__init__ now calls that method instead of reassigning the private _retries_enabled attribute directly, so the coordination point is discoverable and testable in isolation rather than an undeclared attribute poke. Adds two unit tests directly on the new setter: one proving it only affects retry eligibility and never grants close-ownership over a client the adapter didn't create, and one proving the override changes actual retry behavior (a scripted transient failure is retried), not just a stored flag. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BtqT3jocrSJRouEew7Nnto --- mlbstatsapi/async_mlb.py | 7 ++++++- mlbstatsapi/async_mlb_dataadapter.py | 28 +++++++++++++++++-------- tests/test_async_mlb_dataadapter.py | 31 ++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index 5e044c61..5ff49738 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -83,8 +83,13 @@ def __init__( timeout=timeout, client=self._mlb_adapter_v1._client, strict_http=strict_http, - retries_enabled=self._mlb_adapter_v1._owns_client, ) + # AsyncMlb, not either adapter, actually owns this shared transport, + # so it is the one that knows whether the client is library-owned. + # The v1.1 adapter received a non-None client above, so it would + # otherwise conclude it's using a caller-injected client and disable + # retries even when the client is really library-owned via v1. + self._mlb_adapter_v1_1._set_retries_enabled(self._mlb_adapter_v1._owns_client) async def aclose(self) -> None: """Close library-owned async resources.""" diff --git a/mlbstatsapi/async_mlb_dataadapter.py b/mlbstatsapi/async_mlb_dataadapter.py index c91109c0..8e180657 100644 --- a/mlbstatsapi/async_mlb_dataadapter.py +++ b/mlbstatsapi/async_mlb_dataadapter.py @@ -43,21 +43,20 @@ def __init__( client: httpx.AsyncClient | None = None, *, strict_http: bool = True, - retries_enabled: bool | None = None, ): self.url = f"https://{hostname}/api/{ver}/" self._logger = logger or logging.getLogger(__name__) self._timeout = timeout self._strict_http = strict_http self._owns_client = client is None - # Retry eligibility normally follows client ownership, like the sync - # adapter (retries are mounted on the Session, not per MlbDataAdapter - # version). AsyncMlb overrides this for its v1.1 adapter, which - # borrows the v1 adapter's client rather than owning it directly, so - # both adapters retry exactly when the shared client is library-owned. - self._retries_enabled = ( - self._owns_client if retries_enabled is None else retries_enabled - ) + # Retry eligibility follows client ownership by default, like the + # sync adapter (retries are mounted on the Session, not per + # MlbDataAdapter version). This is not a constructor knob: a caller + # that owns this adapter's transport (AsyncMlb, for its v1.1 adapter + # sharing v1's client) may call _set_retries_enabled() after + # construction, since it — not this adapter — is the one that knows + # whether the shared client is actually library-owned. + self._retries_enabled = self._owns_client self._retry_policy = create_retry_policy() if client is None: @@ -323,3 +322,14 @@ async def aclose(self) -> None: if self._owns_client and not self._closed: await self._client.aclose() self._closed = True + + def _set_retries_enabled(self, enabled: bool) -> None: + """Override retry eligibility for a borrowed, non-owned client. + + Internal coordination hook, not public API: only a caller that + actually owns this adapter's transport (AsyncMlb, wiring up its v1.1 + adapter to share the v1 adapter's client) should call this. Standalone + use never needs it; retry eligibility already follows client + ownership by default. + """ + self._retries_enabled = enabled diff --git a/tests/test_async_mlb_dataadapter.py b/tests/test_async_mlb_dataadapter.py index 28605b87..f2ef0f58 100644 --- a/tests/test_async_mlb_dataadapter.py +++ b/tests/test_async_mlb_dataadapter.py @@ -148,6 +148,37 @@ def test_retry_policy_matches_library_default(): assert_library_retry_policy(adapter._retry_policy) +def test_set_retries_enabled_does_not_change_client_ownership(): + """The private coordination hook AsyncMlb uses for its v1.1 adapter only + overrides retry eligibility; it must never grant close-ownership over a + client this adapter did not create.""" + handler = _ScriptedHandler(_response(200)) + adapter = _injected_adapter(handler) + assert adapter._retries_enabled is False + + adapter._set_retries_enabled(True) + + assert adapter._retries_enabled is True + assert adapter._owns_client is False + + +def test_set_retries_enabled_true_makes_an_injected_client_retry(): + """An injected client normally gets zero retries; overriding the flag + must actually change retry behavior, not just the stored value.""" + handler = _ScriptedHandler(_response(503), _response(200)) + + async def scenario(): + adapter = _injected_adapter(handler) + adapter._set_retries_enabled(True) + + with patch(SLEEP_TARGET, new_callable=AsyncMock): + return await adapter.get(endpoint="sports") + + result = run_async(scenario()) + assert result.status_code == 200 + assert handler.call_count == 2 + + def test_200_succeeds_with_no_retry(): handler = _ScriptedHandler(_response(200)) From 458d86813fe7948bc98e27cf88e336b0843f1adc Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sat, 22 Aug 2026 18:20:58 -0700 Subject: [PATCH 49/81] refactor(async): move retries and client ownership onto a shared transport AsyncMlb's ownership pointed the wrong way: __init__ built the v1 adapter with client=client, let that adapter create the shared httpx.AsyncClient, then reached back into self._mlb_adapter_v1._client to construct the v1.1 adapter. aclose() delegated the parent's shutdown to one of its children, with the other child required not to close the thing it shares. Underneath that, AsyncMlbDataAdapter._owns_client answered two different questions at once ("who closes this client" and "am I allowed to retry"), which is why _set_retries_enabled() existed at all: to undo the wrong conclusion the v1.1 adapter reached about retry eligibility after receiving a non-None client. Mlb does not have this problem because it does not implement retries -- it configures them, once, by mounting an HTTPAdapter carrying the retry policy onto the Session it creates. HTTPX has the same extension seam: AsyncClient(transport=...) accepts any AsyncBaseTransport, the position HTTPAdapter occupies in Requests. Moving the retry loop there makes retries a property of the client, so two adapters sharing one client share one policy by construction and cannot disagree about it. Adds a private mlbstatsapi/_async_transport.py: - MlbAsyncRetryTransport wraps an inner transport (default httpx.AsyncHTTPTransport()) with the existing create_retry_policy() budget and backoff accounting, moved verbatim from AsyncMlbDataAdapter._request_with_retries /_sleep_before_retry. On an exhausted budget it re-raises the underlying httpx exception rather than translating it -- transports are contractually expected to raise httpx errors, translation stays with the adapter. - create_library_async_client() is the async counterpart of _configure_library_session(): builds a client with the package User-Agent and MlbAsyncRetryTransport() mounted. AsyncMlb.__init__ now owns the client exactly as Mlb.__init__ owns the Session (self._owns_client, self._client, self._closed, created via create_library_async_client() when the caller passes none) and hands that one client to both adapters. aclose() closes self._client directly instead of delegating to the v1 adapter. AsyncMlbDataAdapter's public constructor is unchanged. get() now calls self._client.get() directly, wrapped in the same two except clauses the sync adapter's error path mirrors (TimeoutException -> MlbTimeoutError, RequestError -> MlbTransportError; ConnectTimeout/ConnectError fall into the right one because ConnectTimeout is a TimeoutException subclass). Deleted _set_retries_enabled, _retries_enabled, _retry_policy, _request_with_retries, and _sleep_before_retry. Retargets the test seam from patching httpx.AsyncClient to patching httpx.AsyncHTTPTransport inside _async_transport, so tests exercise the real client, the real retry transport, and the real headers with a MockTransport at the bottom. _owned_adapter/_injected_adapter keep their names; a new _retry_policy_of() accessor reads the policy from the client's transport for tests that mutate it. Replaces the two _set_retries_enabled tests with three that assert the new shape: a library-created client mounts MlbAsyncRetryTransport, an injected client's transport is left exactly as supplied, and mounting MlbAsyncRetryTransport on an injected client makes it retry (the caller-facing opt-in). In test_async_mlb.py, ownership assertions move from the adapters to AsyncMlb itself, and the cleanup-failure test mocks mlb.aclose rather than an adapter's. Testing: - poetry run pytest tests/ --ignore=tests/external_tests: 973 passed before this change (checked out from the unmodified branch tip), 974 after (net +1: two _set_retries_enabled tests removed, three new transport-ownership tests added). - tests/test_sync_async_parity.py: 96 passed, unchanged -- AsyncMlb's observable behavior did not move. - tests/external_tests/async_mlb/: 26 passed against the live API. Risk: internal-only change to a private transport layer behind AsyncMlb's and AsyncMlbDataAdapter's unchanged public constructors. Retry budgets, backoff timing, exception mapping, 404/strict_http behavior, the User-Agent, and caller-injected-client ownership rules are all covered by tests and were not changed on purpose. Intentionally left out: MlbAsyncRetryTransport stays private. Exporting it would turn "an injected client gets no library retries" from a limitation into a documented opt-in, which is a public-API decision -- see the PR description. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BtqT3jocrSJRouEew7Nnto --- docs/public-api.md | 18 +-- mlbstatsapi/_async_transport.py | 165 +++++++++++++++++++++++++ mlbstatsapi/async_mlb.py | 36 +++--- mlbstatsapi/async_mlb_dataadapter.py | 177 ++++----------------------- tests/test_async_mlb.py | 68 +++++----- tests/test_async_mlb_dataadapter.py | 96 ++++++++------- 6 files changed, 307 insertions(+), 253 deletions(-) create mode 100644 mlbstatsapi/_async_transport.py diff --git a/docs/public-api.md b/docs/public-api.md index 93bbdfde..6a3d6a75 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -272,13 +272,17 @@ promised. `AsyncMlb` constructs internal adapters for both `v1` and `v1.1` that share one HTTPX client, mirroring `Mlb`'s shared-Session pattern. Most endpoint -methods use `v1`. `get_game` uses the `v1.1` live feed endpoint. The `v1` -adapter resolves and owns the shared client (library-created when the caller -passes none to `AsyncMlb`, otherwise the caller's own); the `v1.1` adapter -borrows that same client and never closes it directly. Retry eligibility -follows the shared client's ownership on both adapters, not which adapter -version issues a given request, matching `Mlb`'s single retry policy mounted -on the shared `Session`. +methods use `v1`. `get_game` uses the `v1.1` live feed endpoint. `AsyncMlb` +owns the shared client, exactly as `Mlb` owns the shared `Session`: it creates +one when the caller passes none, closes only a client it created, and hands +the same client to both adapters. + +Retries are a property of that client, not of either adapter. A +library-created client is built with the library retry transport mounted on +it, the way a library-created `Session` is built with the library retry +adapters mounted on it, so both API versions retry identically without either +adapter holding retry state. A caller-injected client keeps whatever transport +its caller mounted. ### Endpoint methods diff --git a/mlbstatsapi/_async_transport.py b/mlbstatsapi/_async_transport.py new file mode 100644 index 00000000..816e34e4 --- /dev/null +++ b/mlbstatsapi/_async_transport.py @@ -0,0 +1,165 @@ +"""Retry-aware HTTPX transport for the async client. + +The synchronous side does not implement retries. It *configures* them: ``Mlb`` +mounts an ``HTTPAdapter`` carrying the library ``Retry`` policy onto the +Session it creates, and from that point on every ``session.get()`` retries +without any caller — ``MlbDataAdapter`` included — knowing retries exist. + +HTTPX has the same seam. ``AsyncClient(transport=...)`` accepts any +``AsyncBaseTransport``, which is the position ``HTTPAdapter`` occupies in +Requests. Putting the retry loop there instead of inside +``AsyncMlbDataAdapter`` gives the async side the sync structure: + +* Adapters call ``client.get()`` and are unaware of retries. +* The retry policy travels with the client, so two adapters sharing one client + share one policy by construction. Neither adapter holds retry state, so + neither can disagree with the other about it. +* A caller-injected client keeps whatever transport its caller mounted, so + "the library does not touch an injected client" needs no flag to enforce. + +A caller who wants library retry behavior on a client they own mounts this +transport themselves, mirroring the documented sync recipe for +``create_retry_policy()``. +""" + +import asyncio + +from ._async_support import import_httpx +from .mlb_dataadapter import _build_user_agent, create_retry_policy + +httpx = import_httpx() + + +class MlbAsyncRetryTransport(httpx.AsyncBaseTransport): + """Wrap an HTTPX transport with the library's bounded retry policy. + + Failures spend the same retry budget the sync policy spends: + + ReadTimeout -> read budget + ConnectTimeout -> connect budget + ConnectError -> connect budget + other TimeoutException -> total budget + other RequestError -> total budget + retryable HTTP status -> status budget + + Exhausting a budget re-raises the underlying HTTPX exception. Translating + those into the library's public exception types stays with the adapter, so + this class satisfies the transport contract HTTPX documents: transports + raise HTTPX errors. + """ + + def __init__( + self, + inner: httpx.AsyncBaseTransport | None = None, + *, + retry_policy=None, + ): + self._inner = inner if inner is not None else httpx.AsyncHTTPTransport() + self._retry_policy = ( + retry_policy if retry_policy is not None else create_retry_policy() + ) + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + policy = self._retry_policy + + attempt = 0 + while True: + attempt += 1 + try: + response = await self._inner.handle_async_request(request) + + except httpx.ReadTimeout: + if attempt > policy.read: + raise + await self._backoff(attempt=attempt, response=None) + continue + + except httpx.ConnectTimeout: + # Caught before httpx.TimeoutException: a connect timeout is a + # timeout for the caller, but it spends the connect budget so + # the retry accounting matches the sync policy. + if attempt > policy.connect: + raise + await self._backoff(attempt=attempt, response=None) + continue + + except httpx.ConnectError: + if attempt > policy.connect: + raise + await self._backoff(attempt=attempt, response=None) + continue + + except httpx.TimeoutException: + if attempt > policy.total: + raise + await self._backoff(attempt=attempt, response=None) + continue + + except httpx.RequestError: + if attempt > policy.total: + raise + await self._backoff(attempt=attempt, response=None) + continue + + if ( + response.status_code not in policy.status_forcelist + or attempt > policy.status + ): + return response + + # The response is discarded, so release it before another attempt + # rather than leaving a connection checked out of the pool. + delay = self._delay_for(attempt=attempt, response=response) + await response.aclose() + if delay > 0: + await asyncio.sleep(delay) + + async def _backoff( + self, + *, + attempt: int, + response: httpx.Response | None, + ) -> None: + delay = self._delay_for(attempt=attempt, response=response) + if delay > 0: + await asyncio.sleep(delay) + + def _delay_for( + self, + *, + attempt: int, + response: httpx.Response | None, + ) -> float: + policy = self._retry_policy + + if policy.respect_retry_after_header and response is not None: + retry_after = policy.get_retry_after(response) + if retry_after: + return retry_after + + # Mirrors urllib3's Retry.get_backoff_time(): no delay before the + # first retry, exponential thereafter, capped at backoff_max. + if attempt <= 1: + return 0.0 + + return min( + policy.backoff_factor * (2 ** (attempt - 1)), + policy.backoff_max, + ) + + async def aclose(self) -> None: + await self._inner.aclose() + + +def create_library_async_client() -> httpx.AsyncClient: + """Build the async client the library creates and owns. + + The counterpart of ``_configure_library_session()`` on the sync side: + library defaults are applied here, at creation, and only to clients the + library creates. Passing headers to the constructor replaces just the + User-Agent, so HTTPX's other default headers survive. + """ + return httpx.AsyncClient( + headers={"User-Agent": _build_user_agent()}, + transport=MlbAsyncRetryTransport(), + ) diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index 5ff49738..94a479e5 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -5,6 +5,7 @@ import logging from typing import TYPE_CHECKING +from ._async_transport import create_library_async_client from ._helpers.id_lookup import find_ids_by_key from ._helpers.schedule import build_schedule_params from ._parsers.attendance import parse_attendance @@ -63,17 +64,23 @@ def __init__( ): self._logger = logger or logging.getLogger(__name__) - # One client is shared by the v1 and v1.1 adapters, mirroring Mlb's - # shared-Session pattern. The v1 adapter resolves and owns the client - # (library-created when the caller passes none); the v1.1 adapter - # borrows that same client and never closes it itself, but still - # retries exactly when the shared client is library-owned. + # One client is shared by the v1 and v1.1 adapters, and this client + # owns it, mirroring Mlb's shared-Session pattern. The library closes + # only clients it creates; caller-injected clients remain caller-owned. + # The versioned User-Agent and the retry transport are applied only to + # library-created clients. + self._owns_client = client is None + if client is None: + self._client = create_library_async_client() + else: + self._client = client + self._closed = False self._mlb_adapter_v1 = AsyncMlbDataAdapter( hostname=hostname, ver="v1", logger=self._logger, timeout=timeout, - client=client, + client=self._client, strict_http=strict_http, ) self._mlb_adapter_v1_1 = AsyncMlbDataAdapter( @@ -81,19 +88,18 @@ def __init__( ver="v1.1", logger=self._logger, timeout=timeout, - client=self._mlb_adapter_v1._client, + client=self._client, strict_http=strict_http, ) - # AsyncMlb, not either adapter, actually owns this shared transport, - # so it is the one that knows whether the client is library-owned. - # The v1.1 adapter received a non-None client above, so it would - # otherwise conclude it's using a caller-injected client and disable - # retries even when the client is really library-owned via v1. - self._mlb_adapter_v1_1._set_retries_enabled(self._mlb_adapter_v1._owns_client) async def aclose(self) -> None: - """Close library-owned async resources.""" - await self._mlb_adapter_v1.aclose() + """Close the HTTP client when this client owns it. + + Safe to call more than once. Caller-injected clients are left alone. + """ + if self._owns_client and not self._closed: + await self._client.aclose() + self._closed = True async def __aenter__(self) -> "AsyncMlb": return self diff --git a/mlbstatsapi/async_mlb_dataadapter.py b/mlbstatsapi/async_mlb_dataadapter.py index 8e180657..ec676316 100644 --- a/mlbstatsapi/async_mlb_dataadapter.py +++ b/mlbstatsapi/async_mlb_dataadapter.py @@ -1,9 +1,9 @@ -import asyncio import logging from typing import Dict from ._async_support import import_httpx +from ._async_transport import create_library_async_client from .exceptions import ( MlbDecodeError, MlbTimeoutError, @@ -13,8 +13,6 @@ DEFAULT_TIMEOUT, MlbResult, TimeoutType, - _build_user_agent, - create_retry_policy, ) from ._http import ( @@ -49,25 +47,16 @@ def __init__( self._timeout = timeout self._strict_http = strict_http self._owns_client = client is None - # Retry eligibility follows client ownership by default, like the - # sync adapter (retries are mounted on the Session, not per - # MlbDataAdapter version). This is not a constructor knob: a caller - # that owns this adapter's transport (AsyncMlb, for its v1.1 adapter - # sharing v1's client) may call _set_retries_enabled() after - # construction, since it — not this adapter — is the one that knows - # whether the shared client is actually library-owned. - self._retries_enabled = self._owns_client - self._retry_policy = create_retry_policy() if client is None: - # Only a library-owned client gets the package User-Agent. Passing - # it to the constructor replaces just that header, so httpx's other - # default headers (Accept, Accept-Encoding, Connection) survive. - self._client = httpx.AsyncClient( - headers={"User-Agent": _build_user_agent()}, - ) + # A library-created client carries the package User-Agent and the + # library retry transport. Retries are a property of the client, + # not of this adapter, exactly as they are a property of the + # Session on the sync side. + self._client = create_library_async_client() else: - # An injected client stays exactly as the caller configured it. + # An injected client stays exactly as the caller configured it, + # retry transport included or not. self._client = client self._closed = False @@ -100,8 +89,20 @@ async def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> ) ) - self._logger.debug(logline_post) - response = await self._request_with_retries(full_url, ep_params) + try: + self._logger.debug(logline_post) + response = await self._client.get( + url=full_url, + params=ep_params, + timeout=self._translate_timeout(self._timeout), + ) + + except httpx.TimeoutException as exc: + self._logger.error(msg=(str(exc))) + raise MlbTimeoutError("Request failed") from exc + except httpx.RequestError as exc: + self._logger.error(msg=(str(exc))) + raise MlbTransportError("Request failed") from exc status_code = response.status_code @@ -181,129 +182,6 @@ async def get(self, endpoint: str, ep_params: Dict = None, data: Dict = None) -> data=response_data, ) - async def _request_with_retries( - self, - full_url: str, - ep_params: Dict, - ) -> httpx.Response: - """Issue the GET call, retrying with bounded backoff when this - adapter owns its httpx.AsyncClient. - - An injected client is called exactly once; its retry behavior stays - under caller control, matching the sync adapter's session-ownership - rule. - - Failures spend the retry budget the sync policy would spend, and - surface the public exception the sync adapter raises: - - ReadTimeout -> read budget -> MlbTimeoutError - ConnectTimeout -> connect budget -> MlbTimeoutError - ConnectError -> connect budget -> MlbTransportError - other TimeoutException -> total budget -> MlbTimeoutError - other RequestError -> total budget -> MlbTransportError - retryable HTTP status -> status budget - """ - policy = self._retry_policy - - attempt = 0 - while True: - attempt += 1 - try: - response = await self._client.get( - url=full_url, - params=ep_params, - timeout=self._translate_timeout(self._timeout), - ) - - except httpx.ReadTimeout as exc: - max_attempts = policy.read + 1 if self._retries_enabled else 1 - - if attempt >= max_attempts: - self._logger.error(msg=str(exc)) - raise MlbTimeoutError("Request failed") from exc - - await self._sleep_before_retry(attempt=attempt, response=None) - continue - - except httpx.ConnectTimeout as exc: - # Caught before httpx.TimeoutException: a connect timeout is a - # timeout for the caller, but it spends the connect budget so - # the retry accounting matches the sync policy. - max_attempts = policy.connect + 1 if self._retries_enabled else 1 - - if attempt >= max_attempts: - self._logger.error(msg=str(exc)) - raise MlbTimeoutError("Request failed") from exc - - await self._sleep_before_retry(attempt=attempt, response=None) - continue - - except httpx.ConnectError as exc: - max_attempts = policy.connect + 1 if self._retries_enabled else 1 - - if attempt >= max_attempts: - self._logger.error(msg=str(exc)) - raise MlbTransportError("Request failed") from exc - - await self._sleep_before_retry( - attempt=attempt, - response=None, - ) - continue - - except httpx.TimeoutException as exc: - max_attempts = policy.total + 1 if self._retries_enabled else 1 - - if attempt >= max_attempts: - raise MlbTimeoutError("Request failed") from exc - - await self._sleep_before_retry( - attempt=attempt, - response=None, - ) - continue - - except httpx.RequestError as exc: - max_attempts = policy.total + 1 if self._retries_enabled else 1 - - if attempt >= max_attempts: - self._logger.error(msg=str(exc)) - raise MlbTransportError("Request failed") from exc - - await self._sleep_before_retry(attempt=attempt, response=None) - continue - - max_attempts = policy.status + 1 if self._retries_enabled else 1 - - if response.status_code not in policy.status_forcelist or attempt >= max_attempts: - return response - - await self._sleep_before_retry(attempt=attempt, response=response) - - async def _sleep_before_retry( - self, - *, - attempt: int, - response: httpx.Response | None, - ) -> None: - policy = self._retry_policy - - if policy.respect_retry_after_header and response is not None: - retry_after = policy.get_retry_after(response) - if retry_after: - await asyncio.sleep(retry_after) - return - - # Mirrors urllib3's Retry.get_backoff_time(): no delay before the - # first retry, exponential thereafter, capped at backoff_max. - delay = 0.0 if attempt <= 1 else min( - policy.backoff_factor * (2 ** (attempt - 1)), - policy.backoff_max, - ) - - if delay > 0: - await asyncio.sleep(delay) - @staticmethod def _translate_timeout(timeout: TimeoutType) -> httpx.Timeout: if isinstance(timeout, tuple): @@ -322,14 +200,3 @@ async def aclose(self) -> None: if self._owns_client and not self._closed: await self._client.aclose() self._closed = True - - def _set_retries_enabled(self, enabled: bool) -> None: - """Override retry eligibility for a borrowed, non-owned client. - - Internal coordination hook, not public API: only a caller that - actually owns this adapter's transport (AsyncMlb, wiring up its v1.1 - adapter to share the v1 adapter's client) should call this. Standalone - use never needs it; retry eligibility already follows client - ownership by default. - """ - self._retries_enabled = enabled diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py index 08a98d2e..afa87feb 100644 --- a/tests/test_async_mlb.py +++ b/tests/test_async_mlb.py @@ -36,6 +36,7 @@ httpx = pytest.importorskip("httpx", reason="requires the async extra (HTTPX)") from mlbstatsapi import Mlb # noqa: E402 +from mlbstatsapi._async_transport import MlbAsyncRetryTransport # noqa: E402 from mlbstatsapi.async_mlb import AsyncMlb # noqa: E402 from mlbstatsapi.mlb_dataadapter import MlbResult # noqa: E402 from mlbstatsapi.models.attendances import Attendance # noqa: E402 @@ -387,29 +388,23 @@ def request(self) -> httpx.Request: async def async_mlb(handler: _Handler): """Yield an AsyncMlb whose own client talks to ``handler``, then close it. - AsyncMlb builds its adapter and client through the production path; only - the transport is swapped. Teardown closes the adapter's client directly - rather than calling AsyncMlb.aclose(), so the lifecycle tests that replace - aclose with a mock still get their real client closed. + AsyncMlb builds its client, its retry transport and its adapters through + the production path; only the innermost network transport is swapped. + Teardown closes the client directly rather than calling AsyncMlb.aclose(), + so the lifecycle tests that replace aclose with a mock still get their real + client closed. """ - real_async_client = httpx.AsyncClient - - def mock_transport_client(**client_kwargs) -> httpx.AsyncClient: - return real_async_client( - transport=httpx.MockTransport(handler), **client_kwargs - ) - with pytest.MonkeyPatch.context() as monkeypatch: monkeypatch.setattr( - "mlbstatsapi.async_mlb_dataadapter.httpx.AsyncClient", - mock_transport_client, + "mlbstatsapi._async_transport.httpx.AsyncHTTPTransport", + lambda **kwargs: httpx.MockTransport(handler), ) mlb = AsyncMlb() try: yield mlb finally: - await mlb._mlb_adapter_v1._client.aclose() + await mlb._client.aclose() def sync_request_for(method: str, *args, **kwargs) -> tuple[str, dict, str]: @@ -495,7 +490,7 @@ async def scenario(): def test_context_exit_closes_the_owned_client(): async def scenario(): async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: - client = mlb._mlb_adapter_v1._client + client = mlb._client async with mlb: await mlb.get_team(133) @@ -508,7 +503,7 @@ async def scenario(): def test_context_exit_closes_the_owned_client_when_the_body_raises(): async def scenario(): async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: - client = mlb._mlb_adapter_v1._client + client = mlb._client with pytest.raises(ValueError, match="boom"): async with mlb: @@ -528,7 +523,7 @@ def test_cleanup_failure_does_not_replace_the_original_exception(): async def scenario(): async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: - mlb._mlb_adapter_v1.aclose = AsyncMock( + mlb.aclose = AsyncMock( side_effect=RuntimeError("cleanup failed") ) @@ -548,7 +543,7 @@ def test_cancellation_is_preserved_through_cleanup(): async def scenario(): async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: - client = mlb._mlb_adapter_v1._client + client = mlb._client async def worker(): async with mlb: @@ -583,42 +578,47 @@ async def scenario(): asyncio.run(scenario()) -def test_v1_and_v1_1_adapters_share_one_client(): - """One client is shared by both adapters, mirroring Mlb's shared Session.""" +def test_v1_and_v1_1_adapters_share_the_client_this_client_owns(): + """One client is shared by both adapters and owned by AsyncMlb itself, + mirroring Mlb's shared Session.""" async def scenario(): async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: - assert mlb._mlb_adapter_v1._client is mlb._mlb_adapter_v1_1._client - # Only v1 tracks close-ownership of the shared client; v1.1 must - # never double-close it. - assert mlb._mlb_adapter_v1._owns_client is True + assert mlb._mlb_adapter_v1._client is mlb._client + assert mlb._mlb_adapter_v1_1._client is mlb._client + # Close-ownership lives on AsyncMlb, so neither adapter can close + # the shared client out from under the other. + assert mlb._owns_client is True + assert mlb._mlb_adapter_v1._owns_client is False assert mlb._mlb_adapter_v1_1._owns_client is False asyncio.run(scenario()) -def test_v1_1_adapter_retries_when_the_shared_client_is_library_owned(): - """Retry eligibility follows the shared client's ownership, not which - adapter version issues the request (matching Mlb, which configures - retries once on the shared Session).""" +def test_both_api_versions_retry_because_the_shared_client_carries_the_policy(): + """Retries belong to the shared client's transport, not to an adapter, so + the two versions cannot disagree about them (matching Mlb, which mounts + one retry policy on the shared Session).""" async def scenario(): async with async_mlb(_Handler(_json(TEAM_PAYLOAD))) as mlb: - assert mlb._mlb_adapter_v1._retries_enabled is True - assert mlb._mlb_adapter_v1_1._retries_enabled is True + assert isinstance(mlb._client._transport, MlbAsyncRetryTransport) asyncio.run(scenario()) -def test_v1_1_adapter_does_not_retry_with_a_caller_injected_client(): +def test_caller_injected_client_keeps_its_own_transport(): + """The library mounts nothing on a client it did not create, so an + injected client retries exactly as much as its caller configured.""" handler = _Handler(_json(TEAM_PAYLOAD)) - client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + transport = httpx.MockTransport(handler) + client = httpx.AsyncClient(transport=transport) async def scenario(): try: async with AsyncMlb(client=client) as mlb: - assert mlb._mlb_adapter_v1._retries_enabled is False - assert mlb._mlb_adapter_v1_1._retries_enabled is False + assert mlb._client is client + assert mlb._client._transport is transport finally: await client.aclose() diff --git a/tests/test_async_mlb_dataadapter.py b/tests/test_async_mlb_dataadapter.py index f2ef0f58..90737323 100644 --- a/tests/test_async_mlb_dataadapter.py +++ b/tests/test_async_mlb_dataadapter.py @@ -41,6 +41,10 @@ MlbTimeoutError, MlbTransportError, ) +from mlbstatsapi._async_transport import ( # noqa: E402 + MlbAsyncRetryTransport, + create_library_async_client, +) from mlbstatsapi.async_mlb_dataadapter import AsyncMlbDataAdapter # noqa: E402 from mlbstatsapi.mlb_dataadapter import PACKAGE_DISTRIBUTION_NAME # noqa: E402 @@ -54,11 +58,13 @@ BASE_URL = "https://statsapi.mlb.com/api/v1/" -SLEEP_TARGET = "mlbstatsapi.async_mlb_dataadapter.asyncio.sleep" +SLEEP_TARGET = "mlbstatsapi._async_transport.asyncio.sleep" # Patched only while a test adapter is constructed, so the adapter creates its -# own library-owned client the way production does, over a MockTransport. -CLIENT_TARGET = "mlbstatsapi.async_mlb_dataadapter.httpx.AsyncClient" +# own library-owned client the way production does. Only the innermost network +# transport is swapped, so the library retry transport under test is the real +# one, wrapping a MockTransport instead of a socket. +INNER_TRANSPORT_TARGET = "mlbstatsapi._async_transport.httpx.AsyncHTTPTransport" # Matches tests/test_mlb_session.py, so both adapters assert the same contract. MOCKED_PACKAGE_VERSION = "9.8.7" @@ -117,26 +123,23 @@ def _owned_adapter(handler, **kwargs) -> AsyncMlbDataAdapter: """Build an adapter that owns its client, so retries are active. The adapter still builds its own client through the production path — only - the transport is swapped for a MockTransport — so ownership, headers, and - retry behavior are exactly what the library does at runtime, and no client - is constructed and then discarded. Call this from inside a run_async() - scenario; run_async() closes what it creates. + the innermost network transport is swapped for a MockTransport — so + ownership, headers, and retry behavior are exactly what the library does at + runtime, and no client is constructed and then discarded. Call this from + inside a run_async() scenario; run_async() closes what it creates. """ - real_async_client = httpx.AsyncClient - - def mock_transport_client(**client_kwargs) -> httpx.AsyncClient: - return real_async_client( - transport=httpx.MockTransport(handler), - **client_kwargs, - ) - - with patch(CLIENT_TARGET, mock_transport_client): + with patch(INNER_TRANSPORT_TARGET, lambda **kwargs: httpx.MockTransport(handler)): adapter = AsyncMlbDataAdapter(**kwargs) _ADAPTERS_TO_CLOSE.append(adapter) return adapter +def _retry_policy_of(adapter: AsyncMlbDataAdapter): + """Read the retry policy the adapter's client actually uses.""" + return adapter._client._transport._retry_policy + + def _injected_adapter(handler, **kwargs) -> AsyncMlbDataAdapter: """Build an adapter with a caller-supplied client, so retries are bypassed.""" client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) @@ -145,34 +148,43 @@ def _injected_adapter(handler, **kwargs) -> AsyncMlbDataAdapter: def test_retry_policy_matches_library_default(): adapter = AsyncMlbDataAdapter() - assert_library_retry_policy(adapter._retry_policy) + assert_library_retry_policy(_retry_policy_of(adapter)) -def test_set_retries_enabled_does_not_change_client_ownership(): - """The private coordination hook AsyncMlb uses for its v1.1 adapter only - overrides retry eligibility; it must never grant close-ownership over a - client this adapter did not create.""" - handler = _ScriptedHandler(_response(200)) - adapter = _injected_adapter(handler) - assert adapter._retries_enabled is False +def test_library_created_client_mounts_the_retry_transport(): + """Retries are configured onto the client at creation, the way the sync + side mounts them onto a library-created Session.""" + client = create_library_async_client() + + assert isinstance(client._transport, MlbAsyncRetryTransport) - adapter._set_retries_enabled(True) - assert adapter._retries_enabled is True +def test_injected_client_transport_is_left_alone(): + """The library mounts nothing on a client it did not create, so an + injected client keeps exactly the retry behavior its caller gave it.""" + transport = httpx.MockTransport(_ScriptedHandler(_response(200))) + client = httpx.AsyncClient(transport=transport) + adapter = AsyncMlbDataAdapter(client=client) + + assert adapter._client._transport is transport assert adapter._owns_client is False -def test_set_retries_enabled_true_makes_an_injected_client_retry(): - """An injected client normally gets zero retries; overriding the flag - must actually change retry behavior, not just the stored value.""" +def test_mounting_the_retry_transport_makes_an_injected_client_retry(): + """The supported way for a caller to opt their own client into library + retry behavior, mirroring the sync create_retry_policy() recipe.""" handler = _ScriptedHandler(_response(503), _response(200)) async def scenario(): - adapter = _injected_adapter(handler) - adapter._set_retries_enabled(True) - - with patch(SLEEP_TARGET, new_callable=AsyncMock): - return await adapter.get(endpoint="sports") + client = httpx.AsyncClient( + transport=MlbAsyncRetryTransport(httpx.MockTransport(handler)), + ) + adapter = AsyncMlbDataAdapter(client=client) + try: + with patch(SLEEP_TARGET, new_callable=AsyncMock): + return await adapter.get(endpoint="sports") + finally: + await client.aclose() result = run_async(scenario()) assert result.status_code == 200 @@ -765,7 +777,7 @@ def test_connect_timeout_spends_the_connect_retry_budget(): async def scenario(): adapter = _owned_adapter(handler) - adapter._retry_policy.connect = 1 + _retry_policy_of(adapter).connect = 1 with patch(SLEEP_TARGET, new_callable=AsyncMock): with pytest.raises(MlbTimeoutError): await adapter.get(endpoint="sports") @@ -825,7 +837,7 @@ def test_generic_failures_spend_the_total_retry_budget(failure, expected_excepti async def scenario(): adapter = _owned_adapter(handler) - adapter._retry_policy.total = 1 + _retry_policy_of(adapter).total = 1 with patch(SLEEP_TARGET, new_callable=AsyncMock): with pytest.raises(expected_exception) as exc_info: await adapter.get(endpoint="sports") @@ -849,7 +861,7 @@ def test_connect_error_spends_the_connect_retry_budget(): async def scenario(): adapter = _owned_adapter(handler) - adapter._retry_policy.connect = 1 + _retry_policy_of(adapter).connect = 1 with patch(SLEEP_TARGET, new_callable=AsyncMock): with pytest.raises(MlbTransportError): await adapter.get(endpoint="sports") @@ -864,7 +876,7 @@ def test_retryable_status_spends_the_status_retry_budget(): async def scenario(): adapter = _owned_adapter(handler) - adapter._retry_policy.status = 1 + _retry_policy_of(adapter).status = 1 with patch(SLEEP_TARGET, new_callable=AsyncMock): with pytest.raises(MlbHttpError) as exc_info: await adapter.get(endpoint="sports") @@ -921,9 +933,9 @@ async def scenario(): def test_retry_sleep_is_async_and_non_blocking(): """A real (unmocked) backoff wait must yield the event loop. - If _sleep_before_retry ever used a blocking call (e.g. time.sleep) - instead of `await asyncio.sleep(...)`, the whole event loop would - freeze for the wait's duration and the concurrently running marker + If the retry transport's backoff ever used a blocking call (e.g. + time.sleep) instead of `await asyncio.sleep(...)`, the whole event loop + would freeze for the wait's duration and the concurrently running marker task below would make zero progress during it. """ handler = _ScriptedHandler(_response(500), _response(500), _response(200)) @@ -931,7 +943,7 @@ def test_retry_sleep_is_async_and_non_blocking(): async def scenario(): adapter = _owned_adapter(handler) # Small but real backoff so the test stays fast without mocking sleep. - adapter._retry_policy.backoff_factor = 0.05 + _retry_policy_of(adapter).backoff_factor = 0.05 marker_ticks = 0 From ab019e76daae1feb403170065179934a3a402339 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sun, 23 Aug 2026 12:35:47 -0700 Subject: [PATCH 50/81] docs: tighten README and add async examples --- README.md | 869 +++++++++++++----------------------------------------- 1 file changed, 205 insertions(+), 664 deletions(-) diff --git a/README.md b/README.md index 141379d5..6be3f0a2 100644 --- a/README.md +++ b/README.md @@ -9,481 +9,344 @@ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/python-mlb-statsapi) ![GitHub](https://img.shields.io/github/license/zero-sum-seattle/python-mlb-statsapi) -
+
-### *Copyright Notice* -This package and its authors are not affiliated with MLB or any MLB team. This API wrapper interfaces with MLB's Stats API. Use of MLB data is subject to the notice posted at http://gdx.mlb.com/components/copyright.txt. +### *Copyright Notice* -###### This is an educational project - Not for commercial use. +This package and its authors are not affiliated with MLB or any MLB team. This API wrapper interfaces with MLB's Stats API. Use of MLB data is subject to the notice posted at http://gdx.mlb.com/components/copyright.txt. +###### This is an educational project - Not for commercial use. ![MLB Stats API](https://user-images.githubusercontent.com/2068393/203456246-dfdbdf0f-1e43-4329-aaa9-1c4008f9800d.jpg) ## Getting Started -*Python-mlb-statsapi* is a Python library that provides access to the MLB Stats API, allowing developers to retrieve information related to MLB teams, players, stats, and more. Written in Python 3.10+. - -All models are built with [Pydantic](https://docs.pydantic.dev/) for robust data validation and serialization. Field names follow Python's `snake_case` convention for a more Pythonic experience. +`python-mlb-statsapi` provides Python access to the MLB Stats API for teams, players, schedules, games, stats, and more. -For detailed documentation, check out the [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) which contains information on return objects, endpoint structure, usage examples, and more. +Returned objects are built with [Pydantic](https://docs.pydantic.dev/), and model fields use Python `snake_case` names. +Version 1.1.0 adds first-class async support through `AsyncMlb` while keeping the existing synchronous `Mlb` API available without changes. -
+[Examples](#examples) | [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) | [Public API](docs/public-api.md) | [MLB Stats API](https://statsapi.mlb.com/) -### [Examples](#examples) | [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) | [API](https://statsapi.mlb.com/) +## Installation -
+### Synchronous client -## Installation ```bash python3 -m pip install python-mlb-statsapi ``` +### Async support + +Install the optional `async` extra to use `AsyncMlb` and `AsyncMlbDataAdapter`: + +```bash +python3 -m pip install "python-mlb-statsapi[async]" +``` + +The async extra installs HTTPX. Python 3.10 or newer is required. + ### Python support | Claim | Value | | --- | --- | -| Minimum declared Python version (`Requires-Python`) | `>=3.10` | +| Minimum Python version | `>=3.10` | | CI-validated versions | 3.10, 3.11, 3.12, 3.13, 3.14 | -The minimum declared Python version is 3.10 and the CI-validated versions are -3.10 through 3.14. There is no upper Python bound. Prerelease interpreters are -excluded from the required test matrix and are not claimed as supported. - ## Quick Start -```python ->>> import mlbstatsapi ->>> mlb = mlbstatsapi.Mlb() - ->>> mlb.get_people_id("Ty France") -[664034] ->>> player = mlb.get_person(664034) ->>> print(player.full_name) -Ty France +### Sync ->>> stats = ['season', 'seasonAdvanced'] ->>> groups = ['hitting'] ->>> params = {'season': 2022} ->>> mlb.get_player_stats(664034, stats, groups, **params) -{'hitting': {'season': Stat, 'seasonAdvanced': Stat }} +```python +from mlbstatsapi import Mlb ->>> mlb.get_team_id("Seattle Mariners") -[136] +with Mlb() as mlb: + player = mlb.get_person(664034) + team = mlb.get_team(136) ->>> team = mlb.get_team(136) ->>> print(team.name, team.franchise_name) -Seattle Mariners Seattle +print(player.full_name) +print(team.name) ``` -## HTTP Sessions, Timeouts, Retries, and Error Behavior +### Async -Version 0.8.0 added shared HTTP Sessions, explicit timeouts, optional Session injection, bounded retries, and structured transport exceptions. Version 0.9.0 made that transport configurable with a public retry policy, richer `MlbHttpError` context, compatibility warnings, and a versioned User-Agent. Version 1.0.0 makes strict HTTP handling the default and documents the stable public API contract. +```python +import asyncio + +from mlbstatsapi import AsyncMlb -The `Mlb` client remains synchronous. Shared Sessions pool reusable connections; they do not cache MLB response bodies, and the client does not enable response caching by default. -For the complete reference see the [HTTP transport documentation](docs/http-transport.md). For what changed in this release see the [1.0.0 release notes](docs/releases/1.0.0.md). For the stable public API boundary see the [public API contract](docs/public-api.md). +async def main(): + async with AsyncMlb() as mlb: + player = await mlb.get_person(664034) + team = await mlb.get_team(136) -### Upgrading to version 1.0 + print(player.full_name) + print(team.name) -`Mlb()` now uses strict HTTP handling by default. It is equivalent to `Mlb(strict_http=True)`. -```text -Mlb() now uses strict HTTP handling by default -Final non-404 4xx responses raise MlbHttpError -404 keeps endpoint-specific None / [] / {} behavior -Final 5xx still raises MlbHttpError -Timeouts still raise MlbTimeoutError -Transport failures still raise MlbTransportError -Successful invalid JSON still raises MlbDecodeError +asyncio.run(main()) ``` -Recommended version 1.0 usage: +### Concurrent async requests + +`AsyncMlb` supports concurrent requests on the same event loop. Concurrency is caller-controlled. ```python -import mlbstatsapi +import asyncio -try: - with mlbstatsapi.Mlb() as mlb: - player = mlb.get_person(664034) -except mlbstatsapi.MlbHttpError as exc: - print(exc.status_code) - print(exc.reason) - print(exc.url) -``` +from mlbstatsapi import AsyncMlb -Temporary compatibility opt-out while migrating: -```python -import mlbstatsapi +async def main(): + async with AsyncMlb() as mlb: + player, team = await asyncio.gather( + mlb.get_person(664034), + mlb.get_team(136), + ) -with mlbstatsapi.Mlb(strict_http=False) as mlb: - player = mlb.get_person(664034) -``` + print(player.full_name) + print(team.name) -`strict_http=False` is a temporary migration opt-out and an explicit request for historical 0.9 behavior. It is not the recommended long-term 1.0 configuration. See [Migrating from 0.9.x to 1.0](docs/http-transport.md#migrating-from-09x-to-10) for the full process, warning-as-error guidance, and before-and-after examples. -### Recommended context-manager usage +asyncio.run(main()) +``` -Prefer a context manager so library-owned HTTP resources are closed when the block exits, including when the block exits because of an exception: +`AsyncMlb` does not create hidden background tasks or automatic request fanout. -```python -import mlbstatsapi +## Sync or Async? -with mlbstatsapi.Mlb() as mlb: - player = mlb.get_person(664034) - team = mlb.get_team(136) -``` +| | `Mlb` | `AsyncMlb` | +| --- | --- | --- | +| HTTP library | Requests | HTTPX | +| Context manager | `with Mlb()` | `async with AsyncMlb()` | +| Request | `mlb.get_team(...)` | `await mlb.get_team(...)` | +| Explicit cleanup | `mlb.close()` | `await mlb.aclose()` | -One `Mlb` client uses one shared `requests.Session`. The v1 and v1.1 adapters share that Session, so repeated requests can reuse pooled connections. A Session manages a pool of reusable connections; it is not one permanent network connection. +Where an async endpoint is supported, both clients return the same Pydantic models and follow the same public HTTP/error behavior. -Callers who do not use a context manager may call `mlb.close()` instead. Repeated `close()` calls are safe. Closing a client only closes a Session the library created; a caller-injected Session is left open for its owner. +Async endpoint coverage is expanding in v1.1.0. See the [public API contract](docs/public-api.md) for the current supported async methods. -### Compatibility mode +## HTTP Behavior -Callers who need historical 0.9 empty-result behavior for final non-404 4xx responses can pass `strict_http=False`. That path emits `MlbHttpCompatibilityWarning` exactly once per suppressed final response, does not change 404 handling, and does not suppress final 5xx, timeout, transport, or decode failures. +Both clients use explicit timeouts, bounded retries for temporary failures, structured exceptions, and pooled HTTP connections. -The category inherits from `FutureWarning`, so it stays visible under default Python warning filters. Applications can promote only this package category to an error: +Library-created HTTP resources are configured and closed by the library. Caller-injected Requests Sessions or HTTPX clients remain caller-owned and are not closed or reconfigured by the library. -```python -import warnings -import mlbstatsapi +`strict_http=True` is the default. Final non-404 4xx responses raise `MlbHttpError`, while existing endpoint-specific 404 behavior is preserved. -warnings.filterwarnings( - "error", - category=mlbstatsapi.MlbHttpCompatibilityWarning, -) -``` +The main transport exceptions are: -Filter on `mlbstatsapi.MlbHttpCompatibilityWarning` specifically rather than disabling all warnings or all `FutureWarning` instances, which would also hide unrelated notices from other libraries. Prefer removing `strict_http=False` and catching `MlbHttpError` over permanently ignoring the warning. +- `MlbHttpError` +- `MlbTimeoutError` +- `MlbTransportError` +- `MlbDecodeError` -### Custom timeouts +Example: -Every request uses an explicit timeout. The defaults are: +```python +from mlbstatsapi import Mlb, MlbHttpError, MlbTimeoutError -```text -Connection timeout: 3.05 seconds -Read timeout: 30 seconds +try: + with Mlb() as mlb: + player = mlb.get_person(664034) +except MlbTimeoutError: + print("The MLB API timed out") +except MlbHttpError as exc: + print(exc.status_code, exc.reason) ``` -The read timeout is the maximum wait while reading response data. It is not one absolute total duration for the complete request. +For retry policy, timeouts, compatibility mode, custom Sessions, ownership rules, and migration guidance, see the [HTTP transport documentation](docs/http-transport.md). -Use a scalar to apply the same value to both connect and read phases: +For the supported 1.x API surface and async endpoint list, see the [public API contract](docs/public-api.md). -```python -import mlbstatsapi +## Working with Pydantic Models -with mlbstatsapi.Mlb(timeout=10) as mlb: - player = mlb.get_person(664034) -``` +All returned model objects use Pydantic. -Or provide separate connection and read timeouts: +### Convert to a dictionary ```python -import mlbstatsapi +from mlbstatsapi import Mlb -with mlbstatsapi.Mlb( - timeout=(5.0, 60.0), -) as mlb: +with Mlb() as mlb: player = mlb.get_person(664034) -``` -```text -5.0 seconds: connection timeout -60.0 seconds: read timeout +print(player.model_dump(exclude_none=True)) ``` -### Injecting a custom Session - -Advanced callers may inject a caller-owned Session: +### Convert to JSON ```python -import requests -import mlbstatsapi - -session = requests.Session() -session.headers.update({ - "User-Agent": "my-baseball-project/1.0", -}) - -try: - with mlbstatsapi.Mlb(session=session) as mlb: - player = mlb.get_person(664034) -finally: - session.close() +print(player.model_dump_json(indent=2)) ``` -Ownership rules: +### Snake case fields -```text -Library-created Session - The library configures and closes it -Caller-injected Session - The caller configures and closes it -``` +MLB response names are converted to Python-style field names: -`Mlb.close()` does not close a caller-injected Session, and exiting `with Mlb(session=session)` does not close the injected Session either. The library does not replace or reconfigure adapters or headers on an injected Session. Callers control custom retry, TLS, proxy, header, and adapter configuration. +```python +print(player.full_name) # not fullName +print(player.primary_position) # not primaryPosition +print(player.bat_side) # not batSide +``` -### Reusing the retry policy on a caller-managed Session +## Examples -`create_retry_policy()` remains public. It returns a new instance of the same tested policy the library mounts on Sessions it creates, so a caller-managed Session can opt in to identical retry behavior: +### Find a player or team ```python -import requests -import mlbstatsapi +from mlbstatsapi import Mlb -session = requests.Session() -adapter = requests.adapters.HTTPAdapter( - max_retries=mlbstatsapi.create_retry_policy(), -) -session.mount("https://", adapter) -session.mount("http://", adapter) +with Mlb() as mlb: + player_id = mlb.get_people_id("Ty France")[0] + team_id = mlb.get_team_id("Seattle Mariners")[0] -try: - with mlbstatsapi.Mlb(session=session) as mlb: - player = mlb.get_person(664034) -finally: - session.close() + player = mlb.get_person(player_id) + team = mlb.get_team(team_id) + +print(player.full_name) +print(team.name) ``` -* The caller mounts the adapters -* The caller closes the injected Session -* The library never reconfigures an injected Session +### Schedule -### Versioned User-Agent +Sync: -A Session created by the library sends a package-specific User-Agent: +```python +from mlbstatsapi import Mlb -```text -python-mlb-statsapi/ +with Mlb() as mlb: + schedule = mlb.get_schedule(date="2022-10-13") ``` -For this release's currently declared package metadata that resolves to `python-mlb-statsapi/1.0.1`. The version is read from the installed distribution metadata, so it always matches the installed release. Only the `User-Agent` header is set; other Requests defaults such as `Accept-Encoding` remain intact, and the header carries no identifiers beyond the package name and version. +Async: -Headers on a caller-injected Session are left untouched, so applications that set their own User-Agent keep it. +```python +import asyncio -### Structured exception handling +from mlbstatsapi import AsyncMlb -```python -import mlbstatsapi -try: - with mlbstatsapi.Mlb() as mlb: - player = mlb.get_person(664034) -except mlbstatsapi.MlbTimeoutError: - print("The MLB API timed out") -except mlbstatsapi.MlbTransportError: - print("The request could not reach the MLB API") -except mlbstatsapi.MlbHttpError as exc: - print(exc.method) - print(exc.status_code) - print(exc.reason) - print(exc.url) - print(exc.response_data) - print(exc.body_excerpt) -except mlbstatsapi.MlbDecodeError: - print("The MLB API returned invalid JSON") -``` +async def main(): + async with AsyncMlb() as mlb: + schedule = await mlb.get_schedule(date="2022-10-13") + return schedule -* `MlbTimeoutError` represents connection and read timeouts -* `MlbTransportError` represents other request transport failures -* `MlbHttpError` represents an unexpected final HTTP response -* `MlbDecodeError` represents invalid JSON in a successful response -`MlbHttpError` exposes `method`, `status_code`, `reason`, `url`, `response_data`, and `body_excerpt`. `response_data` holds the decoded JSON dictionary or list when the error body contains one, and is `None` otherwise. `body_excerpt` is a bounded excerpt of the response text, capped at 500 characters. Complete response bodies are never automatically logged, and `str(exc)` stays concise. +schedule = asyncio.run(main()) +``` -### Backward-compatible exception handling +### Game data -All new transport exceptions inherit from `TheMlbStatsApiException`, so existing broad exception handling remains compatible: +Sync: ```python -import mlbstatsapi +from mlbstatsapi import Mlb -try: - with mlbstatsapi.Mlb() as mlb: - player = mlb.get_person(664034) -except mlbstatsapi.TheMlbStatsApiException: - print("The MLB request failed") +with Mlb() as mlb: + game = mlb.get_game(662242) + play_by_play = mlb.get_game_play_by_play(662242) + line_score = mlb.get_game_line_score(662242) + box_score = mlb.get_game_box_score(662242) ``` -### Default retry behavior +Async: -Library-created Sessions automatically retry temporary GET failures for: +```python +import asyncio -```text -429 -500 -502 -503 -504 -``` +from mlbstatsapi import AsyncMlb -```text -Initial request: 1 -Maximum retries: 3 -Maximum total attempts: 4 -Backoff factor: 0.5 -Retry-After respected: yes -``` -Only GET requests are retried, and retries are bounded. Ordinary client errors such as 400, 401, 403, and 404 are not retried. Invalid JSON and Pydantic validation failures are not retried. Retries improve resilience for transient failures, but they do not guarantee success. The retry values are unchanged from versions 0.8.0 and 0.9.0. The version 1.0 strict default does not change retry or Session behavior. +async def main(): + async with AsyncMlb() as mlb: + game, play_by_play, line_score, box_score = await asyncio.gather( + mlb.get_game(662242), + mlb.get_game_play_by_play(662242), + mlb.get_game_line_score(662242), + mlb.get_game_box_score(662242), + ) -### Existing 404 compatibility + return game, play_by_play, line_score, box_score -Version 1.0.0 preserves existing endpoint-specific not-found behavior under both the default and `strict_http=False`. Depending on the endpoint, a 404 may still produce: -```text -None -[] -{} +results = asyncio.run(main()) ``` -Not every 404 raises `MlbHttpError`, and the strict default does not change that. +### Player stats -### HTTP behavior at a glance +The higher-level stats helpers remain on the synchronous `Mlb` client in v1.1.0. -| Final response | Default 1.0 behavior | Explicit compatibility mode | -| -------------- | -------------------- | --------------------------- | -| Successful 2xx | Normal result | Normal result | -| Non-404 4xx | `MlbHttpError` | Warning and historical empty result | -| 404 | Existing endpoint behavior | Existing endpoint behavior | -| Final 429 | `MlbHttpError` after retries | Warning and historical empty result after retries | -| Final 5xx | `MlbHttpError` | `MlbHttpError` | +```python +from mlbstatsapi import Mlb -See the [HTTP transport documentation](docs/http-transport.md) for the complete retry policy, Session ownership rules, warning behavior, cleanup behavior, and migration guidance, and the [1.0.0 release notes](docs/releases/1.0.0.md) for the release summary. +with Mlb() as mlb: + player_id = mlb.get_people_id("Ty France")[0] + stats = mlb.get_player_stats( + player_id, + stats=["season", "career"], + groups=["hitting"], + season=2022, + ) -## Working with Pydantic Models +season = stats["hitting"]["season"] +for split in season.splits: + print(split.stat.model_dump(exclude_none=True)) +``` -All returned objects are Pydantic models, giving you access to powerful serialization and validation features. +### Team roster -### Convert to Dictionary ```python ->>> player = mlb.get_person(664034) ->>> player.model_dump() -{'id': 664034, 'full_name': 'Ty France', 'link': '/api/v1/people/664034', ...} +from mlbstatsapi import Mlb -# Exclude None values ->>> player.model_dump(exclude_none=True) -{'id': 664034, 'full_name': 'Ty France', 'link': '/api/v1/people/664034', ...} +with Mlb() as mlb: + players = mlb.get_team_roster(136) -# Include only specific fields ->>> player.model_dump(include={'id', 'full_name', 'primary_position'}) -{'id': 664034, 'full_name': 'Ty France', 'primary_position': Position(...)} +for player in players: + print(f"#{player.jersey_number} {player.person.full_name}") ``` -### Convert to JSON -```python ->>> player = mlb.get_person(664034) ->>> player.model_dump_json() -'{"id": 664034, "full_name": "Ty France", "link": "/api/v1/people/664034", ...}' - -# Pretty print with indentation ->>> print(player.model_dump_json(indent=2)) -{ - "id": 664034, - "full_name": "Ty France", - "link": "/api/v1/people/664034", - ... -} -``` +The same roster endpoint is also available through `AsyncMlb`: -### Access Fields with Snake Case Names ```python ->>> player = mlb.get_person(664034) ->>> player.full_name # Not fullName -'Ty France' ->>> player.primary_position # Not primaryPosition -Position(code='3', name='First Base', ...) ->>> player.bat_side # Not batSide -CodeDesc(code='R', description='Right') +players = await mlb.get_team_roster(136) ``` ## Documentation -### [People, Person, Players, Coaches](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-People) -* `Mlb.get_people_id(self, fullname: str, sport_id: int = 1, search_key: str = 'fullname', **params)` - Return Person Id(s) from fullname -* `Mlb.get_person(self, player_id: int, **params)` - Return Person Object from Id -* `Mlb.get_people(self, sport_id: int = 1, **params)` - Return all Players from Sport -### [Draft](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Draft(round)) -* `Mlb.get_draft(self, year_id: int, **params)` - Return a draft for a given year -### [Awards](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Award) -* `Mlb.get_awards(self, award_id: int, **params)` - Return award recipients for a given award -### [Teams](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Team) -* `Mlb.get_team_id(self, team_name: str, search_key: str = 'name', **params)` - Return Team Id(s) from name -* `Mlb.get_team(self, team_id: int, **params)` - Return Team Object from Team Id -* `Mlb.get_teams(self, sport_id: int = 1, **params)` - Return all Teams for Sport -* `Mlb.get_team_coaches(self, team_id: int, **params)` - Return coaching roster for team for current or specified season -* `Mlb.get_team_roster(self, team_id: int, **params)` - Return player roster for team for current or specified season -### [Stats](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Stats) -* `Mlb.get_player_stats(self, person_id: int, stats: list, groups: list, **params)` - Return stats by player id, stat type and groups -* `Mlb.get_team_stats(self, team_id: int, stats: list, groups: list, **params)` - Return stats by team id, stat types and groups -* `Mlb.get_stats(self, stats: list, groups: list, **params: dict)` - Return stats by stat type and group args -* `Mlb.get_players_stats_for_game(self, person_id: int, game_id: int, **params)` - Return player stats for a game -### [Gamepace](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Gamepace) -* `Mlb.get_gamepace(self, season: str, sport_id=1, **params)` - Return pace of game metrics for specific sport, league or team. -### [Venues](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Venue) -* `Mlb.get_venue_id(self, venue_name: str, search_key: str = 'name', **params)` - Return Venue Id(s) -* `Mlb.get_venue(self, venue_id: int, **params)` - Return Venue Object from venue Id -* `Mlb.get_venues(self, **params)` - Return all Venues -### [Sports](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Sport) -* `Mlb.get_sport(self, sport_id: int, **params)` - Return a Sport object from Id -* `Mlb.get_sports(self, **params)` - Return all Sports -* `Mlb.get_sport_id(self, sport_name: str, search_key: str = 'name', **params)`- Return Sport Id from name -### [Schedules](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Schedule) -* `Mlb.get_schedule(self, date: str, start_date: str, end_date: str, sport_id: int, team_id: int, **params)` - Return a Schedule -### [Divisions](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Division) -* `Mlb.get_division(self, division_id: int, **params)` - Return a Division -* `Mlb.get_divisions(self, **params)` - Return all Divisions -* `Mlb.get_division_id(self, division_name: str, search_key: str = 'name', **params)` - Return Division Id(s) from name -### [Leagues](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-League) -* `Mlb.get_league(self, league_id: int, **params)` - Return a League from Id -* `Mlb.get_leagues(self, **params)` - Return all Leagues -* `Mlb.get_league_id(self, league_name: str, search_key: str = 'name', **params)` - Return League Id(s) -### [Seasons](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Season) -* `Mlb.get_season(self, season_id: str, sport_id: int = None, **params)` - Return a season -* `Mlb.get_seasons(self, sportid: int = None, **params)` - Return all seasons -### [Standings](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Standings) -* `Mlb.get_standings(self, league_id: int, season: str, **params)` - Return standings -### [Schedules](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Schedule) -* `Mlb.get_schedule(self, date: str = None, start_date: str = None, end_date: str = None, sport_id: int = 1, team_id: int = None, **params)` - Return a Schedule from dates -* `Mlb.get_scheduled_games_by_date(self, date: str = None,start_date: str = None, end_date: str = None, sport_id: int = 1, **params)` - Return game ids from dates -### [Games](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Game) -* `Mlb.get_game(self, game_id: int, **params)` - Return the Game for a specific Game Id -* `Mlb.get_game_play_by_play(self, game_id: int, **params)` - Return Play by play data for a game -* `Mlb.get_game_line_score(self, game_id: int, **params)` - Return a Linescore for a game -* `Mlb.get_game_box_score(self, game_id: int, **params)` - Return a Boxscore for a game - +- [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) - endpoint and model documentation +- [Public API contract](docs/public-api.md) - supported package API and async endpoint coverage +- [HTTP transport](docs/http-transport.md) - retries, timeouts, errors, ownership, and compatibility behavior +- [Release notes](docs/releases/) - release-specific changes and migration notes ## Contributing -Contributions are welcome! Whether it's bug fixes, new features, or documentation improvements, we appreciate your help. +Contributions, bug fixes, tests, and documentation improvements are welcome. -### Getting Started +### Setup -1. Fork the repository -2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/python-mlb-statsapi.git` -3. Install dependencies: `poetry install` -4. Create a branch: `git checkout -b feat/your-feature` +```bash +git clone https://github.com/YOUR_USERNAME/python-mlb-statsapi.git +cd python-mlb-statsapi +poetry install -E async +``` -### Development +### Tests Offline tests are deterministic and should run before every pull request: ```bash -poetry run pytest \ - tests/ \ - --ignore=tests/external_tests +poetry run pytest tests/ --ignore=tests/external_tests ``` -External tests contact the live MLB API. They require internet access and are separate from normal offline CI: +External tests contact the live MLB API and are kept separate from normal offline CI: ```bash -poetry run pytest \ - tests/external_tests/ +poetry run pytest tests/external_tests/ ``` -These live tests may fail because the MLB service is unavailable or because MLB changes undocumented payloads. - Full local validation: ```bash @@ -494,348 +357,26 @@ python3 scripts/validate_release.py poetry run twine check dist/* ``` -`scripts/validate_release.py` is the same release check offline CI runs. It inspects the built wheel and source distribution, clean-installs each artifact into its own temporary virtual environment, and runs the same public-API smoke test against both installed artifacts. The smoke test verifies the declared metadata, the supported package-root imports, the strict HTTP default, explicit strict and compatibility modes, the versioned `User-Agent`, and injected-Session ownership. Every response it observes comes from an injected fake Session, so it never contacts the MLB API. - -Offline CI is the normal pull-request gate. External tests are available manually, on a weekly schedule, and before releases. +Live tests may fail when the MLB service is unavailable or when MLB changes undocumented payloads. -### Pull Request Guidelines +### Pull requests - Run offline tests before submitting a PR -- Use the [PR template](.github/pull_request_template.md) when creating your pull request -- Follow the branch naming convention: - - `feat/` - New features - - `fix/` - Bug fixes - - `docs/` - Documentation updates - - `refactor/` - Code improvements +- Use the [PR template](.github/pull_request_template.md) +- Keep changes focused and reviewable -### Reporting Issues +Suggested branch prefixes: -Found a bug or have a feature request? Please [open an issue](https://github.com/zero-sum-seattle/python-mlb-statsapi/issues/new) with: +- `feat/` - new features +- `fix/` - bug fixes +- `docs/` - documentation +- `refactor/` - code improvements -- A clear description of the problem or feature -- Steps to reproduce (for bugs) -- Expected vs actual behavior -- Python version and package version - - -## Examples +### Reporting issues -Let's show some examples of getting stat objects from the API. What is baseball without stats, right? +Found a bug or have a feature request? [Open an issue](https://github.com/zero-sum-seattle/python-mlb-statsapi/issues/new) and include: -### Player Stats -Get the Id(s) of the players you want stats for and set stat types and groups. -```python ->>> mlb = mlbstatsapi.Mlb() ->>> player_id = mlb.get_people_id("Ty France")[0] ->>> stats = ['season', 'career'] ->>> groups = ['hitting', 'pitching'] ->>> params = {'season': 2022} -``` - -Use player id with stat types and groups to return a stats dictionary -```python ->>> stat_dict = mlb.get_player_stats(player_id, stats=stats, groups=groups, **params) ->>> season_hitting_stat = stat_dict['hitting']['season'] ->>> career_pitching_stat = stat_dict['pitching']['career'] -``` - -Print season hitting stats using Pydantic's `model_dump()` -```python ->>> for split in season_hitting_stat.splits: -... print(split.stat.model_dump(exclude_none=True)) -{'games_played': 140, 'groundouts': 163, 'airouts': 148, 'runs': 65, 'doubles': 27, ...} -``` - -Or access individual fields directly -```python ->>> for split in season_hitting_stat.splits: -... print(f"Games: {split.stat.games_played}") -... print(f"Home Runs: {split.stat.home_runs}") -... print(f"Batting Avg: {split.stat.avg}") -Games: 140 -Home Runs: 20 -Batting Avg: .274 -``` - -### Team Stats -Get the Team Id(s) -```python ->>> mlb = mlbstatsapi.Mlb() ->>> team_id = mlb.get_team_id('Seattle Mariners')[0] -``` - -Set the stat types and groups -```python ->>> stats = ['season', 'seasonAdvanced'] ->>> groups = ['hitting'] ->>> params = {'season': 2022} -``` - -Use team id and the stat types and groups to return season hitting stats -```python ->>> stats = mlb.get_team_stats(team_id, stats=stats, groups=groups, **params) ->>> season_hitting = stats['hitting']['season'] ->>> advanced_hitting = stats['hitting']['seasonAdvanced'] -``` - -Print stats as JSON -```python ->>> for split in season_hitting.splits: -... print(split.stat.model_dump_json(indent=2, exclude_none=True)) -{ - "games_played": 162, - "groundouts": 1273, - "runs": 690, - "doubles": 229, - ... -} -``` - -### Expected Stats -```python ->>> player_id = mlb.get_people_id('Ty France')[0] ->>> stats = ['expectedStatistics'] ->>> group = ['hitting'] ->>> params = {'season': 2022} - ->>> stats = mlb.get_player_stats(player_id, stats=stats, groups=group, **params) ->>> expected = stats['hitting']['expectedStatistics'] ->>> for split in expected.splits: -... print(f"Expected AVG: {split.stat.avg}") -... print(f"Expected SLG: {split.stat.slg}") -Expected AVG: .259 -Expected SLG: .394 -``` - -### vsPlayer Stats -Get pitcher and batter player Ids -```python ->>> ty_france_id = mlb.get_people_id('Ty France')[0] ->>> shohei_ohtani_id = mlb.get_people_id('Shohei Ohtani')[0] -``` - -Set stat type, stat groups, and params -```python ->>> stats = ['vsPlayer'] ->>> group = ['hitting'] ->>> params = {'opposingPlayerId': shohei_ohtani_id, 'season': 2022} -``` - -Get stats -```python ->>> stats = mlb.get_player_stats(ty_france_id, stats=stats, groups=group, **params) ->>> vs_player = stats['hitting']['vsPlayer'] ->>> for split in vs_player.splits: -... print(f"Games: {split.stat.games_played}, Hits: {split.stat.hits}") -Games: 2, Hits: 2 -``` - -### Hot/Cold Zones -```python ->>> ty_france_id = mlb.get_people_id('Ty France')[0] ->>> stats = ['hotColdZones'] ->>> hitting_group = ['hitting'] ->>> params = {'season': 2022} - ->>> hotcoldzones = mlb.get_player_stats(ty_france_id, stats=stats, groups=hitting_group, **params) ->>> zones = hotcoldzones['stats']['hotColdZones'] - ->>> for split in zones.splits: -... print(f"Stat: {split.stat.name}") -... for zone in split.stat.zones: -... print(f" Zone {zone.zone}: {zone.value}") -Stat: battingAverage - Zone 01: .226 - Zone 02: .400 - ... -``` - -### Schedule Examples -Get a schedule for a given date -```python ->>> mlb = mlbstatsapi.Mlb() ->>> schedule = mlb.get_schedule(date='2022-10-13') ->>> dates = schedule.dates - ->>> for date in dates: -... for game in date.games: -... print(f"Game: {game.game_pk}") -... print(f"Status: {game.status.detailed_state}") -... print(f"Home: {game.teams.home.team.name}") -... print(f"Away: {game.teams.away.team.name}") -``` - -### Game Examples -Get a Game for a given game id -```python ->>> mlb = mlbstatsapi.Mlb() ->>> game = mlb.get_game(662242) -``` - -Get the weather for a game -```python ->>> weather = game.game_data.weather ->>> print(f"Condition: {weather.condition}") ->>> print(f"Temperature: {weather.temp}") ->>> print(f"Wind: {weather.wind}") -``` - -Get the current status of a game -```python ->>> linescore = game.live_data.linescore ->>> home_info = game.game_data.teams.home ->>> away_info = game.game_data.teams.away ->>> home_status = linescore.teams.home ->>> away_status = linescore.teams.away - ->>> print(f"Home: {home_info.franchise_name} {home_info.club_name}") ->>> print(f" Runs: {home_status.runs}, Hits: {home_status.hits}, Errors: {home_status.errors}") ->>> print(f"Away: {away_info.franchise_name} {away_info.club_name}") ->>> print(f" Runs: {away_status.runs}, Hits: {away_status.hits}, Errors: {away_status.errors}") ->>> print(f"Inning: {linescore.inning_half} {linescore.current_inning_ordinal}") -``` - -Get play by play, line score, and box score objects -```python ->>> play_by_play = game.live_data.plays ->>> line_score = game.live_data.linescore ->>> box_score = game.live_data.boxscore -``` - -#### Play by Play -Get only the play by play for a given game id -```python ->>> playbyplay = mlb.get_game_play_by_play(662242) -``` - -#### Line Score -Get only the line score for a given game id -```python ->>> linescore = mlb.get_game_line_score(662242) -``` - -#### Box Score -Get only the box score for a given game id -```python ->>> boxscore = mlb.get_game_box_score(662242) -``` - -### Gamepace Examples -Get pace of game metrics for a specific season -```python ->>> mlb = mlbstatsapi.Mlb() ->>> gamepace = mlb.get_gamepace(season=2021) ->>> print(f"Hits per game: {gamepace.sports[0].sport_game_pace.hits_per_game}") -``` - -### People Examples -Get all Players for a given sport id -```python ->>> mlb = mlbstatsapi.Mlb() ->>> players = mlb.get_people(sport_id=1) ->>> for player in players: -... print(f"{player.id}: {player.full_name}") -``` - -Get a player id -```python ->>> player_id = mlb.get_people_id("Ty France") ->>> print(player_id[0]) -664034 -``` - -### Team Examples -Get a Team -```python ->>> mlb = mlbstatsapi.Mlb() ->>> team_id = mlb.get_team_id("Seattle Mariners")[0] ->>> team = mlb.get_team(team_id) ->>> print(f"{team.id}: {team.name}") ->>> print(f"Venue: {team.venue.name}") -``` - -Get a Player Roster -```python ->>> mlb = mlbstatsapi.Mlb() ->>> players = mlb.get_team_roster(136) ->>> for player in players: -... print(f"#{player.jersey_number} {player.person.full_name}") -``` - -Get a Coach Roster -```python ->>> mlb = mlbstatsapi.Mlb() ->>> coaches = mlb.get_team_coaches(136) ->>> for coach in coaches: -... print(f"{coach.person.full_name}: {coach.title}") -``` - -### Draft Examples -Get a draft for a year -```python ->>> mlb = mlbstatsapi.Mlb() ->>> draft = mlb.get_draft('2019') -``` - -Get Players from Draft -```python ->>> draftpicks = draft[0].picks ->>> for pick in draftpicks: -... print(f"Round {pick.pick_round}, Pick {pick.pick_number}: {pick.person.full_name}") -``` - -### Award Examples -Get awards for a given award id -```python ->>> mlb = mlbstatsapi.Mlb() ->>> retired_numbers = mlb.get_awards(award_id='RETIREDUNI_108') ->>> for recipient in retired_numbers.awards: -... print(f"{recipient.player.full_name}: {recipient.name} ({recipient.date})") -``` - -### Venue Examples -Get a Venue -```python ->>> mlb = mlbstatsapi.Mlb() ->>> venue_id = mlb.get_venue_id('PNC Park')[0] ->>> venue = mlb.get_venue(venue_id) ->>> print(f"{venue.name} - {venue.location.city}, {venue.location.state}") -``` - -### Division Examples -Get a division -```python ->>> mlb = mlbstatsapi.Mlb() ->>> division = mlb.get_division(200) ->>> print(division.name) -American League West -``` - -### League Examples -Get a league -```python ->>> mlb = mlbstatsapi.Mlb() ->>> league = mlb.get_league(103) ->>> print(league.name) -American League -``` - -### Season Examples -Get a Season -```python ->>> mlb = mlbstatsapi.Mlb() ->>> season = mlb.get_season(2018) ->>> print(f"Season: {season.season_id}") ->>> print(f"Regular Season: {season.regular_season_start_date} to {season.regular_season_end_date}") -``` - -### Standings Examples -Get Standings -```python ->>> mlb = mlbstatsapi.Mlb() ->>> standings = mlb.get_standings(103, 2018) ->>> for record in standings: -... print(f"Division: {record.division.name}") -... for team in record.team_records: -... print(f" {team.team.name}: {team.wins}-{team.losses}") -``` +- A clear description +- Steps to reproduce when reporting a bug +- Expected and actual behavior +- Python and package versions From 962d0ae1d0928b6bbee0a143aba9b9dc3f72692e Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sun, 23 Aug 2026 12:43:56 -0700 Subject: [PATCH 51/81] revert: keep README work on the dedicated docs branch --- README.md | 869 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 664 insertions(+), 205 deletions(-) diff --git a/README.md b/README.md index 6be3f0a2..141379d5 100644 --- a/README.md +++ b/README.md @@ -9,344 +9,481 @@ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/python-mlb-statsapi) ![GitHub](https://img.shields.io/github/license/zero-sum-seattle/python-mlb-statsapi) -
- -### *Copyright Notice* +
+### *Copyright Notice* This package and its authors are not affiliated with MLB or any MLB team. This API wrapper interfaces with MLB's Stats API. Use of MLB data is subject to the notice posted at http://gdx.mlb.com/components/copyright.txt. -###### This is an educational project - Not for commercial use. +###### This is an educational project - Not for commercial use. + ![MLB Stats API](https://user-images.githubusercontent.com/2068393/203456246-dfdbdf0f-1e43-4329-aaa9-1c4008f9800d.jpg) ## Getting Started -`python-mlb-statsapi` provides Python access to the MLB Stats API for teams, players, schedules, games, stats, and more. - -Returned objects are built with [Pydantic](https://docs.pydantic.dev/), and model fields use Python `snake_case` names. +*Python-mlb-statsapi* is a Python library that provides access to the MLB Stats API, allowing developers to retrieve information related to MLB teams, players, stats, and more. Written in Python 3.10+. -Version 1.1.0 adds first-class async support through `AsyncMlb` while keeping the existing synchronous `Mlb` API available without changes. +All models are built with [Pydantic](https://docs.pydantic.dev/) for robust data validation and serialization. Field names follow Python's `snake_case` convention for a more Pythonic experience. -[Examples](#examples) | [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) | [Public API](docs/public-api.md) | [MLB Stats API](https://statsapi.mlb.com/) +For detailed documentation, check out the [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) which contains information on return objects, endpoint structure, usage examples, and more. -## Installation - -### Synchronous client -```bash -python3 -m pip install python-mlb-statsapi -``` +
-### Async support +### [Examples](#examples) | [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) | [API](https://statsapi.mlb.com/) -Install the optional `async` extra to use `AsyncMlb` and `AsyncMlbDataAdapter`: +
+## Installation ```bash -python3 -m pip install "python-mlb-statsapi[async]" +python3 -m pip install python-mlb-statsapi ``` -The async extra installs HTTPX. Python 3.10 or newer is required. - ### Python support | Claim | Value | | --- | --- | -| Minimum Python version | `>=3.10` | +| Minimum declared Python version (`Requires-Python`) | `>=3.10` | | CI-validated versions | 3.10, 3.11, 3.12, 3.13, 3.14 | +The minimum declared Python version is 3.10 and the CI-validated versions are +3.10 through 3.14. There is no upper Python bound. Prerelease interpreters are +excluded from the required test matrix and are not claimed as supported. + ## Quick Start +```python +>>> import mlbstatsapi +>>> mlb = mlbstatsapi.Mlb() -### Sync +>>> mlb.get_people_id("Ty France") +[664034] -```python -from mlbstatsapi import Mlb +>>> player = mlb.get_person(664034) +>>> print(player.full_name) +Ty France -with Mlb() as mlb: - player = mlb.get_person(664034) - team = mlb.get_team(136) +>>> stats = ['season', 'seasonAdvanced'] +>>> groups = ['hitting'] +>>> params = {'season': 2022} +>>> mlb.get_player_stats(664034, stats, groups, **params) +{'hitting': {'season': Stat, 'seasonAdvanced': Stat }} -print(player.full_name) -print(team.name) -``` +>>> mlb.get_team_id("Seattle Mariners") +[136] -### Async +>>> team = mlb.get_team(136) +>>> print(team.name, team.franchise_name) +Seattle Mariners Seattle +``` -```python -import asyncio +## HTTP Sessions, Timeouts, Retries, and Error Behavior -from mlbstatsapi import AsyncMlb +Version 0.8.0 added shared HTTP Sessions, explicit timeouts, optional Session injection, bounded retries, and structured transport exceptions. Version 0.9.0 made that transport configurable with a public retry policy, richer `MlbHttpError` context, compatibility warnings, and a versioned User-Agent. Version 1.0.0 makes strict HTTP handling the default and documents the stable public API contract. +The `Mlb` client remains synchronous. Shared Sessions pool reusable connections; they do not cache MLB response bodies, and the client does not enable response caching by default. -async def main(): - async with AsyncMlb() as mlb: - player = await mlb.get_person(664034) - team = await mlb.get_team(136) +For the complete reference see the [HTTP transport documentation](docs/http-transport.md). For what changed in this release see the [1.0.0 release notes](docs/releases/1.0.0.md). For the stable public API boundary see the [public API contract](docs/public-api.md). - print(player.full_name) - print(team.name) +### Upgrading to version 1.0 +`Mlb()` now uses strict HTTP handling by default. It is equivalent to `Mlb(strict_http=True)`. -asyncio.run(main()) +```text +Mlb() now uses strict HTTP handling by default +Final non-404 4xx responses raise MlbHttpError +404 keeps endpoint-specific None / [] / {} behavior +Final 5xx still raises MlbHttpError +Timeouts still raise MlbTimeoutError +Transport failures still raise MlbTransportError +Successful invalid JSON still raises MlbDecodeError ``` -### Concurrent async requests - -`AsyncMlb` supports concurrent requests on the same event loop. Concurrency is caller-controlled. +Recommended version 1.0 usage: ```python -import asyncio +import mlbstatsapi -from mlbstatsapi import AsyncMlb +try: + with mlbstatsapi.Mlb() as mlb: + player = mlb.get_person(664034) +except mlbstatsapi.MlbHttpError as exc: + print(exc.status_code) + print(exc.reason) + print(exc.url) +``` +Temporary compatibility opt-out while migrating: -async def main(): - async with AsyncMlb() as mlb: - player, team = await asyncio.gather( - mlb.get_person(664034), - mlb.get_team(136), - ) +```python +import mlbstatsapi - print(player.full_name) - print(team.name) +with mlbstatsapi.Mlb(strict_http=False) as mlb: + player = mlb.get_person(664034) +``` +`strict_http=False` is a temporary migration opt-out and an explicit request for historical 0.9 behavior. It is not the recommended long-term 1.0 configuration. See [Migrating from 0.9.x to 1.0](docs/http-transport.md#migrating-from-09x-to-10) for the full process, warning-as-error guidance, and before-and-after examples. -asyncio.run(main()) -``` +### Recommended context-manager usage -`AsyncMlb` does not create hidden background tasks or automatic request fanout. +Prefer a context manager so library-owned HTTP resources are closed when the block exits, including when the block exits because of an exception: -## Sync or Async? +```python +import mlbstatsapi -| | `Mlb` | `AsyncMlb` | -| --- | --- | --- | -| HTTP library | Requests | HTTPX | -| Context manager | `with Mlb()` | `async with AsyncMlb()` | -| Request | `mlb.get_team(...)` | `await mlb.get_team(...)` | -| Explicit cleanup | `mlb.close()` | `await mlb.aclose()` | +with mlbstatsapi.Mlb() as mlb: + player = mlb.get_person(664034) + team = mlb.get_team(136) +``` -Where an async endpoint is supported, both clients return the same Pydantic models and follow the same public HTTP/error behavior. +One `Mlb` client uses one shared `requests.Session`. The v1 and v1.1 adapters share that Session, so repeated requests can reuse pooled connections. A Session manages a pool of reusable connections; it is not one permanent network connection. -Async endpoint coverage is expanding in v1.1.0. See the [public API contract](docs/public-api.md) for the current supported async methods. +Callers who do not use a context manager may call `mlb.close()` instead. Repeated `close()` calls are safe. Closing a client only closes a Session the library created; a caller-injected Session is left open for its owner. -## HTTP Behavior +### Compatibility mode -Both clients use explicit timeouts, bounded retries for temporary failures, structured exceptions, and pooled HTTP connections. +Callers who need historical 0.9 empty-result behavior for final non-404 4xx responses can pass `strict_http=False`. That path emits `MlbHttpCompatibilityWarning` exactly once per suppressed final response, does not change 404 handling, and does not suppress final 5xx, timeout, transport, or decode failures. -Library-created HTTP resources are configured and closed by the library. Caller-injected Requests Sessions or HTTPX clients remain caller-owned and are not closed or reconfigured by the library. +The category inherits from `FutureWarning`, so it stays visible under default Python warning filters. Applications can promote only this package category to an error: -`strict_http=True` is the default. Final non-404 4xx responses raise `MlbHttpError`, while existing endpoint-specific 404 behavior is preserved. +```python +import warnings +import mlbstatsapi -The main transport exceptions are: +warnings.filterwarnings( + "error", + category=mlbstatsapi.MlbHttpCompatibilityWarning, +) +``` -- `MlbHttpError` -- `MlbTimeoutError` -- `MlbTransportError` -- `MlbDecodeError` +Filter on `mlbstatsapi.MlbHttpCompatibilityWarning` specifically rather than disabling all warnings or all `FutureWarning` instances, which would also hide unrelated notices from other libraries. Prefer removing `strict_http=False` and catching `MlbHttpError` over permanently ignoring the warning. -Example: +### Custom timeouts -```python -from mlbstatsapi import Mlb, MlbHttpError, MlbTimeoutError +Every request uses an explicit timeout. The defaults are: -try: - with Mlb() as mlb: - player = mlb.get_person(664034) -except MlbTimeoutError: - print("The MLB API timed out") -except MlbHttpError as exc: - print(exc.status_code, exc.reason) +```text +Connection timeout: 3.05 seconds +Read timeout: 30 seconds ``` -For retry policy, timeouts, compatibility mode, custom Sessions, ownership rules, and migration guidance, see the [HTTP transport documentation](docs/http-transport.md). +The read timeout is the maximum wait while reading response data. It is not one absolute total duration for the complete request. -For the supported 1.x API surface and async endpoint list, see the [public API contract](docs/public-api.md). +Use a scalar to apply the same value to both connect and read phases: -## Working with Pydantic Models +```python +import mlbstatsapi -All returned model objects use Pydantic. +with mlbstatsapi.Mlb(timeout=10) as mlb: + player = mlb.get_person(664034) +``` -### Convert to a dictionary +Or provide separate connection and read timeouts: ```python -from mlbstatsapi import Mlb +import mlbstatsapi -with Mlb() as mlb: +with mlbstatsapi.Mlb( + timeout=(5.0, 60.0), +) as mlb: player = mlb.get_person(664034) +``` -print(player.model_dump(exclude_none=True)) +```text +5.0 seconds: connection timeout +60.0 seconds: read timeout ``` -### Convert to JSON +### Injecting a custom Session + +Advanced callers may inject a caller-owned Session: ```python -print(player.model_dump_json(indent=2)) -``` +import requests +import mlbstatsapi + +session = requests.Session() +session.headers.update({ + "User-Agent": "my-baseball-project/1.0", +}) -### Snake case fields +try: + with mlbstatsapi.Mlb(session=session) as mlb: + player = mlb.get_person(664034) +finally: + session.close() +``` -MLB response names are converted to Python-style field names: +Ownership rules: -```python -print(player.full_name) # not fullName -print(player.primary_position) # not primaryPosition -print(player.bat_side) # not batSide +```text +Library-created Session + The library configures and closes it +Caller-injected Session + The caller configures and closes it ``` -## Examples +`Mlb.close()` does not close a caller-injected Session, and exiting `with Mlb(session=session)` does not close the injected Session either. The library does not replace or reconfigure adapters or headers on an injected Session. Callers control custom retry, TLS, proxy, header, and adapter configuration. -### Find a player or team +### Reusing the retry policy on a caller-managed Session -```python -from mlbstatsapi import Mlb +`create_retry_policy()` remains public. It returns a new instance of the same tested policy the library mounts on Sessions it creates, so a caller-managed Session can opt in to identical retry behavior: -with Mlb() as mlb: - player_id = mlb.get_people_id("Ty France")[0] - team_id = mlb.get_team_id("Seattle Mariners")[0] +```python +import requests +import mlbstatsapi - player = mlb.get_person(player_id) - team = mlb.get_team(team_id) +session = requests.Session() +adapter = requests.adapters.HTTPAdapter( + max_retries=mlbstatsapi.create_retry_policy(), +) +session.mount("https://", adapter) +session.mount("http://", adapter) -print(player.full_name) -print(team.name) +try: + with mlbstatsapi.Mlb(session=session) as mlb: + player = mlb.get_person(664034) +finally: + session.close() ``` -### Schedule +* The caller mounts the adapters +* The caller closes the injected Session +* The library never reconfigures an injected Session -Sync: +### Versioned User-Agent -```python -from mlbstatsapi import Mlb +A Session created by the library sends a package-specific User-Agent: -with Mlb() as mlb: - schedule = mlb.get_schedule(date="2022-10-13") +```text +python-mlb-statsapi/ ``` -Async: +For this release's currently declared package metadata that resolves to `python-mlb-statsapi/1.0.1`. The version is read from the installed distribution metadata, so it always matches the installed release. Only the `User-Agent` header is set; other Requests defaults such as `Accept-Encoding` remain intact, and the header carries no identifiers beyond the package name and version. -```python -import asyncio +Headers on a caller-injected Session are left untouched, so applications that set their own User-Agent keep it. -from mlbstatsapi import AsyncMlb +### Structured exception handling +```python +import mlbstatsapi -async def main(): - async with AsyncMlb() as mlb: - schedule = await mlb.get_schedule(date="2022-10-13") - return schedule +try: + with mlbstatsapi.Mlb() as mlb: + player = mlb.get_person(664034) +except mlbstatsapi.MlbTimeoutError: + print("The MLB API timed out") +except mlbstatsapi.MlbTransportError: + print("The request could not reach the MLB API") +except mlbstatsapi.MlbHttpError as exc: + print(exc.method) + print(exc.status_code) + print(exc.reason) + print(exc.url) + print(exc.response_data) + print(exc.body_excerpt) +except mlbstatsapi.MlbDecodeError: + print("The MLB API returned invalid JSON") +``` +* `MlbTimeoutError` represents connection and read timeouts +* `MlbTransportError` represents other request transport failures +* `MlbHttpError` represents an unexpected final HTTP response +* `MlbDecodeError` represents invalid JSON in a successful response -schedule = asyncio.run(main()) -``` +`MlbHttpError` exposes `method`, `status_code`, `reason`, `url`, `response_data`, and `body_excerpt`. `response_data` holds the decoded JSON dictionary or list when the error body contains one, and is `None` otherwise. `body_excerpt` is a bounded excerpt of the response text, capped at 500 characters. Complete response bodies are never automatically logged, and `str(exc)` stays concise. -### Game data +### Backward-compatible exception handling -Sync: +All new transport exceptions inherit from `TheMlbStatsApiException`, so existing broad exception handling remains compatible: ```python -from mlbstatsapi import Mlb +import mlbstatsapi -with Mlb() as mlb: - game = mlb.get_game(662242) - play_by_play = mlb.get_game_play_by_play(662242) - line_score = mlb.get_game_line_score(662242) - box_score = mlb.get_game_box_score(662242) +try: + with mlbstatsapi.Mlb() as mlb: + player = mlb.get_person(664034) +except mlbstatsapi.TheMlbStatsApiException: + print("The MLB request failed") ``` -Async: +### Default retry behavior -```python -import asyncio +Library-created Sessions automatically retry temporary GET failures for: -from mlbstatsapi import AsyncMlb +```text +429 +500 +502 +503 +504 +``` +```text +Initial request: 1 +Maximum retries: 3 +Maximum total attempts: 4 +Backoff factor: 0.5 +Retry-After respected: yes +``` -async def main(): - async with AsyncMlb() as mlb: - game, play_by_play, line_score, box_score = await asyncio.gather( - mlb.get_game(662242), - mlb.get_game_play_by_play(662242), - mlb.get_game_line_score(662242), - mlb.get_game_box_score(662242), - ) +Only GET requests are retried, and retries are bounded. Ordinary client errors such as 400, 401, 403, and 404 are not retried. Invalid JSON and Pydantic validation failures are not retried. Retries improve resilience for transient failures, but they do not guarantee success. The retry values are unchanged from versions 0.8.0 and 0.9.0. The version 1.0 strict default does not change retry or Session behavior. - return game, play_by_play, line_score, box_score +### Existing 404 compatibility +Version 1.0.0 preserves existing endpoint-specific not-found behavior under both the default and `strict_http=False`. Depending on the endpoint, a 404 may still produce: -results = asyncio.run(main()) +```text +None +[] +{} ``` -### Player stats +Not every 404 raises `MlbHttpError`, and the strict default does not change that. -The higher-level stats helpers remain on the synchronous `Mlb` client in v1.1.0. +### HTTP behavior at a glance -```python -from mlbstatsapi import Mlb +| Final response | Default 1.0 behavior | Explicit compatibility mode | +| -------------- | -------------------- | --------------------------- | +| Successful 2xx | Normal result | Normal result | +| Non-404 4xx | `MlbHttpError` | Warning and historical empty result | +| 404 | Existing endpoint behavior | Existing endpoint behavior | +| Final 429 | `MlbHttpError` after retries | Warning and historical empty result after retries | +| Final 5xx | `MlbHttpError` | `MlbHttpError` | -with Mlb() as mlb: - player_id = mlb.get_people_id("Ty France")[0] - stats = mlb.get_player_stats( - player_id, - stats=["season", "career"], - groups=["hitting"], - season=2022, - ) +See the [HTTP transport documentation](docs/http-transport.md) for the complete retry policy, Session ownership rules, warning behavior, cleanup behavior, and migration guidance, and the [1.0.0 release notes](docs/releases/1.0.0.md) for the release summary. -season = stats["hitting"]["season"] -for split in season.splits: - print(split.stat.model_dump(exclude_none=True)) -``` +## Working with Pydantic Models -### Team roster +All returned objects are Pydantic models, giving you access to powerful serialization and validation features. +### Convert to Dictionary ```python -from mlbstatsapi import Mlb +>>> player = mlb.get_person(664034) +>>> player.model_dump() +{'id': 664034, 'full_name': 'Ty France', 'link': '/api/v1/people/664034', ...} -with Mlb() as mlb: - players = mlb.get_team_roster(136) +# Exclude None values +>>> player.model_dump(exclude_none=True) +{'id': 664034, 'full_name': 'Ty France', 'link': '/api/v1/people/664034', ...} -for player in players: - print(f"#{player.jersey_number} {player.person.full_name}") +# Include only specific fields +>>> player.model_dump(include={'id', 'full_name', 'primary_position'}) +{'id': 664034, 'full_name': 'Ty France', 'primary_position': Position(...)} ``` -The same roster endpoint is also available through `AsyncMlb`: +### Convert to JSON +```python +>>> player = mlb.get_person(664034) +>>> player.model_dump_json() +'{"id": 664034, "full_name": "Ty France", "link": "/api/v1/people/664034", ...}' + +# Pretty print with indentation +>>> print(player.model_dump_json(indent=2)) +{ + "id": 664034, + "full_name": "Ty France", + "link": "/api/v1/people/664034", + ... +} +``` +### Access Fields with Snake Case Names ```python -players = await mlb.get_team_roster(136) +>>> player = mlb.get_person(664034) +>>> player.full_name # Not fullName +'Ty France' +>>> player.primary_position # Not primaryPosition +Position(code='3', name='First Base', ...) +>>> player.bat_side # Not batSide +CodeDesc(code='R', description='Right') ``` ## Documentation -- [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) - endpoint and model documentation -- [Public API contract](docs/public-api.md) - supported package API and async endpoint coverage -- [HTTP transport](docs/http-transport.md) - retries, timeouts, errors, ownership, and compatibility behavior -- [Release notes](docs/releases/) - release-specific changes and migration notes +### [People, Person, Players, Coaches](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-People) +* `Mlb.get_people_id(self, fullname: str, sport_id: int = 1, search_key: str = 'fullname', **params)` - Return Person Id(s) from fullname +* `Mlb.get_person(self, player_id: int, **params)` - Return Person Object from Id +* `Mlb.get_people(self, sport_id: int = 1, **params)` - Return all Players from Sport +### [Draft](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Draft(round)) +* `Mlb.get_draft(self, year_id: int, **params)` - Return a draft for a given year +### [Awards](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Award) +* `Mlb.get_awards(self, award_id: int, **params)` - Return award recipients for a given award +### [Teams](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Team) +* `Mlb.get_team_id(self, team_name: str, search_key: str = 'name', **params)` - Return Team Id(s) from name +* `Mlb.get_team(self, team_id: int, **params)` - Return Team Object from Team Id +* `Mlb.get_teams(self, sport_id: int = 1, **params)` - Return all Teams for Sport +* `Mlb.get_team_coaches(self, team_id: int, **params)` - Return coaching roster for team for current or specified season +* `Mlb.get_team_roster(self, team_id: int, **params)` - Return player roster for team for current or specified season +### [Stats](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Stats) +* `Mlb.get_player_stats(self, person_id: int, stats: list, groups: list, **params)` - Return stats by player id, stat type and groups +* `Mlb.get_team_stats(self, team_id: int, stats: list, groups: list, **params)` - Return stats by team id, stat types and groups +* `Mlb.get_stats(self, stats: list, groups: list, **params: dict)` - Return stats by stat type and group args +* `Mlb.get_players_stats_for_game(self, person_id: int, game_id: int, **params)` - Return player stats for a game +### [Gamepace](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Gamepace) +* `Mlb.get_gamepace(self, season: str, sport_id=1, **params)` - Return pace of game metrics for specific sport, league or team. +### [Venues](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Venue) +* `Mlb.get_venue_id(self, venue_name: str, search_key: str = 'name', **params)` - Return Venue Id(s) +* `Mlb.get_venue(self, venue_id: int, **params)` - Return Venue Object from venue Id +* `Mlb.get_venues(self, **params)` - Return all Venues +### [Sports](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Sport) +* `Mlb.get_sport(self, sport_id: int, **params)` - Return a Sport object from Id +* `Mlb.get_sports(self, **params)` - Return all Sports +* `Mlb.get_sport_id(self, sport_name: str, search_key: str = 'name', **params)`- Return Sport Id from name +### [Schedules](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Schedule) +* `Mlb.get_schedule(self, date: str, start_date: str, end_date: str, sport_id: int, team_id: int, **params)` - Return a Schedule +### [Divisions](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Division) +* `Mlb.get_division(self, division_id: int, **params)` - Return a Division +* `Mlb.get_divisions(self, **params)` - Return all Divisions +* `Mlb.get_division_id(self, division_name: str, search_key: str = 'name', **params)` - Return Division Id(s) from name +### [Leagues](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-League) +* `Mlb.get_league(self, league_id: int, **params)` - Return a League from Id +* `Mlb.get_leagues(self, **params)` - Return all Leagues +* `Mlb.get_league_id(self, league_name: str, search_key: str = 'name', **params)` - Return League Id(s) +### [Seasons](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Season) +* `Mlb.get_season(self, season_id: str, sport_id: int = None, **params)` - Return a season +* `Mlb.get_seasons(self, sportid: int = None, **params)` - Return all seasons +### [Standings](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Standings) +* `Mlb.get_standings(self, league_id: int, season: str, **params)` - Return standings +### [Schedules](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Schedule) +* `Mlb.get_schedule(self, date: str = None, start_date: str = None, end_date: str = None, sport_id: int = 1, team_id: int = None, **params)` - Return a Schedule from dates +* `Mlb.get_scheduled_games_by_date(self, date: str = None,start_date: str = None, end_date: str = None, sport_id: int = 1, **params)` - Return game ids from dates +### [Games](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Game) +* `Mlb.get_game(self, game_id: int, **params)` - Return the Game for a specific Game Id +* `Mlb.get_game_play_by_play(self, game_id: int, **params)` - Return Play by play data for a game +* `Mlb.get_game_line_score(self, game_id: int, **params)` - Return a Linescore for a game +* `Mlb.get_game_box_score(self, game_id: int, **params)` - Return a Boxscore for a game + ## Contributing -Contributions, bug fixes, tests, and documentation improvements are welcome. +Contributions are welcome! Whether it's bug fixes, new features, or documentation improvements, we appreciate your help. -### Setup +### Getting Started -```bash -git clone https://github.com/YOUR_USERNAME/python-mlb-statsapi.git -cd python-mlb-statsapi -poetry install -E async -``` +1. Fork the repository +2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/python-mlb-statsapi.git` +3. Install dependencies: `poetry install` +4. Create a branch: `git checkout -b feat/your-feature` -### Tests +### Development Offline tests are deterministic and should run before every pull request: ```bash -poetry run pytest tests/ --ignore=tests/external_tests +poetry run pytest \ + tests/ \ + --ignore=tests/external_tests ``` -External tests contact the live MLB API and are kept separate from normal offline CI: +External tests contact the live MLB API. They require internet access and are separate from normal offline CI: ```bash -poetry run pytest tests/external_tests/ +poetry run pytest \ + tests/external_tests/ ``` +These live tests may fail because the MLB service is unavailable or because MLB changes undocumented payloads. + Full local validation: ```bash @@ -357,26 +494,348 @@ python3 scripts/validate_release.py poetry run twine check dist/* ``` -Live tests may fail when the MLB service is unavailable or when MLB changes undocumented payloads. +`scripts/validate_release.py` is the same release check offline CI runs. It inspects the built wheel and source distribution, clean-installs each artifact into its own temporary virtual environment, and runs the same public-API smoke test against both installed artifacts. The smoke test verifies the declared metadata, the supported package-root imports, the strict HTTP default, explicit strict and compatibility modes, the versioned `User-Agent`, and injected-Session ownership. Every response it observes comes from an injected fake Session, so it never contacts the MLB API. + +Offline CI is the normal pull-request gate. External tests are available manually, on a weekly schedule, and before releases. -### Pull requests +### Pull Request Guidelines - Run offline tests before submitting a PR -- Use the [PR template](.github/pull_request_template.md) -- Keep changes focused and reviewable +- Use the [PR template](.github/pull_request_template.md) when creating your pull request +- Follow the branch naming convention: + - `feat/` - New features + - `fix/` - Bug fixes + - `docs/` - Documentation updates + - `refactor/` - Code improvements -Suggested branch prefixes: +### Reporting Issues -- `feat/` - new features -- `fix/` - bug fixes -- `docs/` - documentation -- `refactor/` - code improvements +Found a bug or have a feature request? Please [open an issue](https://github.com/zero-sum-seattle/python-mlb-statsapi/issues/new) with: -### Reporting issues +- A clear description of the problem or feature +- Steps to reproduce (for bugs) +- Expected vs actual behavior +- Python version and package version + + +## Examples -Found a bug or have a feature request? [Open an issue](https://github.com/zero-sum-seattle/python-mlb-statsapi/issues/new) and include: +Let's show some examples of getting stat objects from the API. What is baseball without stats, right? -- A clear description -- Steps to reproduce when reporting a bug -- Expected and actual behavior -- Python and package versions +### Player Stats +Get the Id(s) of the players you want stats for and set stat types and groups. +```python +>>> mlb = mlbstatsapi.Mlb() +>>> player_id = mlb.get_people_id("Ty France")[0] +>>> stats = ['season', 'career'] +>>> groups = ['hitting', 'pitching'] +>>> params = {'season': 2022} +``` + +Use player id with stat types and groups to return a stats dictionary +```python +>>> stat_dict = mlb.get_player_stats(player_id, stats=stats, groups=groups, **params) +>>> season_hitting_stat = stat_dict['hitting']['season'] +>>> career_pitching_stat = stat_dict['pitching']['career'] +``` + +Print season hitting stats using Pydantic's `model_dump()` +```python +>>> for split in season_hitting_stat.splits: +... print(split.stat.model_dump(exclude_none=True)) +{'games_played': 140, 'groundouts': 163, 'airouts': 148, 'runs': 65, 'doubles': 27, ...} +``` + +Or access individual fields directly +```python +>>> for split in season_hitting_stat.splits: +... print(f"Games: {split.stat.games_played}") +... print(f"Home Runs: {split.stat.home_runs}") +... print(f"Batting Avg: {split.stat.avg}") +Games: 140 +Home Runs: 20 +Batting Avg: .274 +``` + +### Team Stats +Get the Team Id(s) +```python +>>> mlb = mlbstatsapi.Mlb() +>>> team_id = mlb.get_team_id('Seattle Mariners')[0] +``` + +Set the stat types and groups +```python +>>> stats = ['season', 'seasonAdvanced'] +>>> groups = ['hitting'] +>>> params = {'season': 2022} +``` + +Use team id and the stat types and groups to return season hitting stats +```python +>>> stats = mlb.get_team_stats(team_id, stats=stats, groups=groups, **params) +>>> season_hitting = stats['hitting']['season'] +>>> advanced_hitting = stats['hitting']['seasonAdvanced'] +``` + +Print stats as JSON +```python +>>> for split in season_hitting.splits: +... print(split.stat.model_dump_json(indent=2, exclude_none=True)) +{ + "games_played": 162, + "groundouts": 1273, + "runs": 690, + "doubles": 229, + ... +} +``` + +### Expected Stats +```python +>>> player_id = mlb.get_people_id('Ty France')[0] +>>> stats = ['expectedStatistics'] +>>> group = ['hitting'] +>>> params = {'season': 2022} + +>>> stats = mlb.get_player_stats(player_id, stats=stats, groups=group, **params) +>>> expected = stats['hitting']['expectedStatistics'] +>>> for split in expected.splits: +... print(f"Expected AVG: {split.stat.avg}") +... print(f"Expected SLG: {split.stat.slg}") +Expected AVG: .259 +Expected SLG: .394 +``` + +### vsPlayer Stats +Get pitcher and batter player Ids +```python +>>> ty_france_id = mlb.get_people_id('Ty France')[0] +>>> shohei_ohtani_id = mlb.get_people_id('Shohei Ohtani')[0] +``` + +Set stat type, stat groups, and params +```python +>>> stats = ['vsPlayer'] +>>> group = ['hitting'] +>>> params = {'opposingPlayerId': shohei_ohtani_id, 'season': 2022} +``` + +Get stats +```python +>>> stats = mlb.get_player_stats(ty_france_id, stats=stats, groups=group, **params) +>>> vs_player = stats['hitting']['vsPlayer'] +>>> for split in vs_player.splits: +... print(f"Games: {split.stat.games_played}, Hits: {split.stat.hits}") +Games: 2, Hits: 2 +``` + +### Hot/Cold Zones +```python +>>> ty_france_id = mlb.get_people_id('Ty France')[0] +>>> stats = ['hotColdZones'] +>>> hitting_group = ['hitting'] +>>> params = {'season': 2022} + +>>> hotcoldzones = mlb.get_player_stats(ty_france_id, stats=stats, groups=hitting_group, **params) +>>> zones = hotcoldzones['stats']['hotColdZones'] + +>>> for split in zones.splits: +... print(f"Stat: {split.stat.name}") +... for zone in split.stat.zones: +... print(f" Zone {zone.zone}: {zone.value}") +Stat: battingAverage + Zone 01: .226 + Zone 02: .400 + ... +``` + +### Schedule Examples +Get a schedule for a given date +```python +>>> mlb = mlbstatsapi.Mlb() +>>> schedule = mlb.get_schedule(date='2022-10-13') +>>> dates = schedule.dates + +>>> for date in dates: +... for game in date.games: +... print(f"Game: {game.game_pk}") +... print(f"Status: {game.status.detailed_state}") +... print(f"Home: {game.teams.home.team.name}") +... print(f"Away: {game.teams.away.team.name}") +``` + +### Game Examples +Get a Game for a given game id +```python +>>> mlb = mlbstatsapi.Mlb() +>>> game = mlb.get_game(662242) +``` + +Get the weather for a game +```python +>>> weather = game.game_data.weather +>>> print(f"Condition: {weather.condition}") +>>> print(f"Temperature: {weather.temp}") +>>> print(f"Wind: {weather.wind}") +``` + +Get the current status of a game +```python +>>> linescore = game.live_data.linescore +>>> home_info = game.game_data.teams.home +>>> away_info = game.game_data.teams.away +>>> home_status = linescore.teams.home +>>> away_status = linescore.teams.away + +>>> print(f"Home: {home_info.franchise_name} {home_info.club_name}") +>>> print(f" Runs: {home_status.runs}, Hits: {home_status.hits}, Errors: {home_status.errors}") +>>> print(f"Away: {away_info.franchise_name} {away_info.club_name}") +>>> print(f" Runs: {away_status.runs}, Hits: {away_status.hits}, Errors: {away_status.errors}") +>>> print(f"Inning: {linescore.inning_half} {linescore.current_inning_ordinal}") +``` + +Get play by play, line score, and box score objects +```python +>>> play_by_play = game.live_data.plays +>>> line_score = game.live_data.linescore +>>> box_score = game.live_data.boxscore +``` + +#### Play by Play +Get only the play by play for a given game id +```python +>>> playbyplay = mlb.get_game_play_by_play(662242) +``` + +#### Line Score +Get only the line score for a given game id +```python +>>> linescore = mlb.get_game_line_score(662242) +``` + +#### Box Score +Get only the box score for a given game id +```python +>>> boxscore = mlb.get_game_box_score(662242) +``` + +### Gamepace Examples +Get pace of game metrics for a specific season +```python +>>> mlb = mlbstatsapi.Mlb() +>>> gamepace = mlb.get_gamepace(season=2021) +>>> print(f"Hits per game: {gamepace.sports[0].sport_game_pace.hits_per_game}") +``` + +### People Examples +Get all Players for a given sport id +```python +>>> mlb = mlbstatsapi.Mlb() +>>> players = mlb.get_people(sport_id=1) +>>> for player in players: +... print(f"{player.id}: {player.full_name}") +``` + +Get a player id +```python +>>> player_id = mlb.get_people_id("Ty France") +>>> print(player_id[0]) +664034 +``` + +### Team Examples +Get a Team +```python +>>> mlb = mlbstatsapi.Mlb() +>>> team_id = mlb.get_team_id("Seattle Mariners")[0] +>>> team = mlb.get_team(team_id) +>>> print(f"{team.id}: {team.name}") +>>> print(f"Venue: {team.venue.name}") +``` + +Get a Player Roster +```python +>>> mlb = mlbstatsapi.Mlb() +>>> players = mlb.get_team_roster(136) +>>> for player in players: +... print(f"#{player.jersey_number} {player.person.full_name}") +``` + +Get a Coach Roster +```python +>>> mlb = mlbstatsapi.Mlb() +>>> coaches = mlb.get_team_coaches(136) +>>> for coach in coaches: +... print(f"{coach.person.full_name}: {coach.title}") +``` + +### Draft Examples +Get a draft for a year +```python +>>> mlb = mlbstatsapi.Mlb() +>>> draft = mlb.get_draft('2019') +``` + +Get Players from Draft +```python +>>> draftpicks = draft[0].picks +>>> for pick in draftpicks: +... print(f"Round {pick.pick_round}, Pick {pick.pick_number}: {pick.person.full_name}") +``` + +### Award Examples +Get awards for a given award id +```python +>>> mlb = mlbstatsapi.Mlb() +>>> retired_numbers = mlb.get_awards(award_id='RETIREDUNI_108') +>>> for recipient in retired_numbers.awards: +... print(f"{recipient.player.full_name}: {recipient.name} ({recipient.date})") +``` + +### Venue Examples +Get a Venue +```python +>>> mlb = mlbstatsapi.Mlb() +>>> venue_id = mlb.get_venue_id('PNC Park')[0] +>>> venue = mlb.get_venue(venue_id) +>>> print(f"{venue.name} - {venue.location.city}, {venue.location.state}") +``` + +### Division Examples +Get a division +```python +>>> mlb = mlbstatsapi.Mlb() +>>> division = mlb.get_division(200) +>>> print(division.name) +American League West +``` + +### League Examples +Get a league +```python +>>> mlb = mlbstatsapi.Mlb() +>>> league = mlb.get_league(103) +>>> print(league.name) +American League +``` + +### Season Examples +Get a Season +```python +>>> mlb = mlbstatsapi.Mlb() +>>> season = mlb.get_season(2018) +>>> print(f"Season: {season.season_id}") +>>> print(f"Regular Season: {season.regular_season_start_date} to {season.regular_season_end_date}") +``` + +### Standings Examples +Get Standings +```python +>>> mlb = mlbstatsapi.Mlb() +>>> standings = mlb.get_standings(103, 2018) +>>> for record in standings: +... print(f"Division: {record.division.name}") +... for team in record.team_records: +... print(f" {team.team.name}: {team.wins}-{team.losses}") +``` From 7d2f0ecd37eddd0bff6230c5f4e86cbaea27f32c Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sun, 23 Aug 2026 15:11:09 -0700 Subject: [PATCH 52/81] feat(async): add the stat endpoint group to AsyncMlb Ports the last four stats methods to AsyncMlb at strict parity with Mlb: get_stats, get_player_stats, get_team_stats, and get_players_stats_for_game. All four sync methods ended in the same copy-pasted tail -- short-circuit on 400-499, then create_split_data(data['stats']) if present and truthy, else {}. That block existed four times in mlb_api.py. It moves to a shared _parsers/stats.py::parse_split_stats(), following the pattern the rest of the async port already uses, and both clients now call the one copy. Also fixes a real bug on the sync side while collapsing those copies: get_players_stats_for_game accepted **params and never passed ep_params to the adapter, so every caller-supplied keyword was silently discarded before the request was built. Both clients now forward them, covered by a named regression test in the parity suite. No new types, constants, or validation: an unrecognized stat type or group still yields {} rather than raising, matching sync exactly. docs/public-api.md notes that sharp edge alongside the newly supported methods. Docstring corrections on Mlb.get_players_stats_for_game: it described game_id as "list of stat types", person_id as "the team id", and its example called get_player_stats_for_game, which is not a method. Tests: 1016 passed (up from 974). tests/external_tests/stats/ 30 passed against the live API, confirming the sync refactor did not move behavior. Co-Authored-By: Claude Opus 5 --- docs/public-api.md | 12 ++ mlbstatsapi/_parsers/stats.py | 16 ++ mlbstatsapi/async_mlb.py | 229 +++++++++++++++++++++++++++++ mlbstatsapi/mlb_api.py | 40 ++--- tests/parsers/test_stats_parser.py | 88 +++++++++++ tests/test_async_mlb.py | 123 ++++++++++++++++ tests/test_public_api.py | 4 + tests/test_sync_async_parity.py | 128 ++++++++++++++++ 8 files changed, 611 insertions(+), 29 deletions(-) create mode 100644 mlbstatsapi/_parsers/stats.py create mode 100644 tests/parsers/test_stats_parser.py diff --git a/docs/public-api.md b/docs/public-api.md index 6a3d6a75..94310e4c 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -323,6 +323,10 @@ get_attendance( get_draft(year_id: int, **params) get_awards(award_id: str, **params) get_homerun_derby(game_id, **params) +get_team_stats(team_id: int, stats: list, groups: list, **params) +get_players_stats_for_game(person_id: int, game_id: int, **params) +get_player_stats(person_id: int, stats: list, groups: list, **params) +get_stats(stats: list, groups: list, **params) get_team_id(team_name: str, search_key: str = 'name', **params) get_people_id( fullname: str, @@ -357,6 +361,14 @@ introduced by the async port. way its sibling game helpers do; missing linescore data falls through to an implicit `None`. +The four stat methods return the same nested `dict` their sync counterparts +do, keyed by stat group and then by stat type — `{'hitting': {'season': Stat}}` +— and return `{}` on a 400–499 response, on a body with no `stats`, and on a +`stats` entry carrying no splits. Note that an unrecognized value in `stats` or +`groups` is not rejected; it produces the same empty `{}`. Valid values are +listed at `https://statsapi.mlb.com/api/v1/statTypes` and +`https://statsapi.mlb.com/api/v1/statGroups`. + Every other `Mlb` endpoint method not listed above is not yet supported on `AsyncMlb`; calling it there raises `AttributeError`. See issue #305 for the tracked expansion plan. diff --git a/mlbstatsapi/_parsers/stats.py b/mlbstatsapi/_parsers/stats.py new file mode 100644 index 00000000..af05d62c --- /dev/null +++ b/mlbstatsapi/_parsers/stats.py @@ -0,0 +1,16 @@ +from mlbstatsapi import mlb_module + + +def parse_split_stats(data: dict) -> dict: + """Parse split stat data from an MLB stats response body. + + Shared by every stats endpoint -- ``/stats``, ``/people/{id}/stats``, + ``/teams/{id}/stats``, and ``/people/{id}/stats/game/{game_id}`` -- all of + which return the same ``stats`` envelope. + + Returns a dict keyed by stat group, then by stat type, or ``{}`` when the + response carries no stats. + """ + if not data or not data.get("stats"): + return {} + return mlb_module.create_split_data(data["stats"]) diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index 94a479e5..fee87d04 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -27,6 +27,7 @@ from ._parsers.seasons import parse_season, parse_seasons from ._parsers.sports import parse_sport, parse_sports from ._parsers.standings import parse_standings +from ._parsers.stats import parse_split_stats from ._parsers.teams import parse_team, parse_teams from ._parsers.venues import parse_venue, parse_venues from .async_mlb_dataadapter import AsyncMlbDataAdapter @@ -2022,3 +2023,231 @@ async def get_homerun_derby( return None return parse_homerun_derby(mlb_data.data) + + async def get_team_stats( + self, + team_id: int, + stats: list, + groups: list, + **params, + ) -> dict: + """ + returns a split stat data for a team + + Async counterpart of ``Mlb.get_team_stats``. + + Parameters + ---------- + team_id : int + the team id + stats : list + list of stat types. List of statTypes can be found at https://statsapi.mlb.com/api/v1/statTypes + groups : list + list of stat groups. List of statGroups can be found at https://statsapi.mlb.com/api/v1/statGroups + + Other Parameters + ---------------- + season : str + Insert year to return team stats for a particular season, season=2018 + + Returns + ------- + dict + returns a dict of stats + + See Also + -------- + AsyncMlb.get_player_stats : Get stats for a player + AsyncMlb.get_stats : Get stats + AsyncMlb.get_players_stats_for_game : Get player stats for a game + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... stats = await mlb.get_team_stats(133, ["season"], ["pitching"]) + {'pitching': {'season': Stat}} + """ + params["stats"] = stats + params["group"] = groups + + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"teams/{team_id}/stats", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return {} + + return parse_split_stats(mlb_data.data) + + async def get_players_stats_for_game( + self, + person_id: int, + game_id: int, + **params, + ) -> dict: + """ + Insert personId and gamePk to view stats for individual player based on a specific game. + + Fielding, Hitting, & Pitching gameLog Statistics as well as vsPlayer stats. + + Async counterpart of ``Mlb.get_players_stats_for_game``. + + Parameters + ---------- + person_id : int + the person id + game_id : int + the game id + + Returns + ------- + dict + returns a dict of stats + + See Also + -------- + AsyncMlb.get_team_stats : Get team stats + AsyncMlb.get_player_stats : Get stats for a player + AsyncMlb.get_stats : Get stats + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... stats = await mlb.get_players_stats_for_game(663728, 715757) + ... print(stats["stats"]["gameLog"]) + ... print(stats["hitting"]["playLog"]) + """ + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"people/{person_id}/stats/game/{game_id}", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return {} + + return parse_split_stats(mlb_data.data) + + async def get_player_stats( + self, + person_id: int, + stats: list, + groups: list, + **params, + ) -> dict: + """ + returns stat data for a player + + Async counterpart of ``Mlb.get_player_stats``. + + Parameters + ---------- + person_id : int + the person id + stats : list + list of stat types. List of statTypes can be found at https://statsapi.mlb.com/api/v1/statTypes + groups : list + list of stat groups. List of statGroups can be found at https://statsapi.mlb.com/api/v1/statGroups + + Other Parameters + ---------------- + season : str + Insert year to return player stats for a particular season, season=2018 + eventType : str + Notes for individual events for playLog, playLog can be filered by individual events. + List of eventTypes can be found at https://statsapi.mlb.com/api/v1/eventTypes + + Returns + ------- + dict + returns a dict of stats + + See Also + -------- + AsyncMlb.get_stats : Get stats + AsyncMlb.get_team_stats : Get team stats + AsyncMlb.get_players_stats_for_game : Get player stats for a game + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... stats = await mlb.get_player_stats(647351, ["season"], ["hitting"]) + {'hitting': {'season': Stat}} + """ + params["stats"] = stats + params["group"] = groups + + mlb_data = await self._mlb_adapter_v1.get( + endpoint=f"people/{person_id}/stats", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return {} + + return parse_split_stats(mlb_data.data) + + async def get_stats( + self, + stats: list, + groups: list, + **params, + ) -> dict: + """ + return a stat dictionary + + Async counterpart of ``Mlb.get_stats``. + + Parameters + ---------- + stats : list + list of stat types. List of statTypes can be found at https://statsapi.mlb.com/api/v1/statTypes + groups : list + list of stat groups. List of statGroups can be found at https://statsapi.mlb.com/api/v1/statGroups + + Other Parameters + ---------------- + season : str + Insert year to return stats for a particular season, season=2018 + teamId : int + Insert teamId to return statistics for a given team. Default to "Qualified" playerPool. + For a list of all teamIds : AsyncMlb.get_leagues() + leagueId : int + Insert leagueId to return statistics for a given league. Default to "Qualified" playerPool + For a list of all leagueIds : AsyncMlb.get_leagues() + gameType : str + Insert gameType to return statistics for a given sport or league based on gameType. Default to "Qualified" playerPool + Find available gameType at https://statsapi.mlb.com/api/v1/gameTypes + sportIds : int + Insert sportId to return statistics for a given sport. + For a list of all sportIds : AsyncMlb.get_sports() + + Returns + ------- + dict + returns a dict of stats + + See Also + -------- + AsyncMlb.get_team_stats : Get team stats + AsyncMlb.get_player_stats : Get player stats + AsyncMlb.get_players_stats_for_game : Get player stats for a game + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... stats = await mlb.get_stats(["season"], ["hitting"]) + {'hitting': {'season': Stat}} + """ + params["stats"] = stats + params["group"] = groups + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="stats", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return {} + + return parse_split_stats(mlb_data.data) diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index b19c5308..f4ef944b 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -36,6 +36,7 @@ from ._parsers.seasons import parse_seasons, parse_season from ._parsers.sports import parse_sports, parse_sport from ._parsers.standings import parse_standings +from ._parsers.stats import parse_split_stats from ._parsers.teams import parse_teams, parse_team from ._parsers.schedules import parse_schedule from ._parsers.venues import parse_venues, parse_venue @@ -2027,12 +2028,7 @@ def get_team_stats(self, team_id: int, stats: list, groups: list, **params) -> d if 400 <= mlb_data.status_code <= 499: return {} - if 'stats' in mlb_data.data and mlb_data.data['stats']: - splits = mlb_module.create_split_data(mlb_data.data['stats']) - else: - return {} - - return splits + return parse_split_stats(mlb_data.data) def get_players_stats_for_game(self, person_id: int, game_id: int, **params) -> dict: """ @@ -2043,9 +2039,9 @@ def get_players_stats_for_game(self, person_id: int, game_id: int, **params) -> Parameters ---------- person_id : int - the team id - game_id : list - list of stat types + the person id + game_id : int + the game id Returns ------- @@ -2063,20 +2059,16 @@ def get_players_stats_for_game(self, person_id: int, game_id: int, **params) -> >>> mlb = Mlb() >>> player_id = 663728 >>> game_id = 715757 - >>> stats = mlb.get_player_stats_for_game(person_id=person_id, game_id=game_id) + >>> stats = mlb.get_players_stats_for_game(person_id=person_id, game_id=game_id) >>> print(stats['stats']['gameLog']) >>> print(stats['hitting']['playLog']) """ - mlb_data = self._mlb_adapter_v1.get(endpoint=f'people/{person_id}/stats/game/{game_id}') + mlb_data = self._mlb_adapter_v1.get(endpoint=f'people/{person_id}/stats/game/{game_id}', + ep_params=params) if 400 <= mlb_data.status_code <= 499: return {} - if 'stats' in mlb_data.data and mlb_data.data['stats']: - splits = mlb_module.create_split_data(mlb_data.data['stats']) - else: - return {} - - return splits + return parse_split_stats(mlb_data.data) def get_player_stats(self, person_id: int, stats: list, groups: list, **params) -> dict: """ @@ -2125,12 +2117,7 @@ def get_player_stats(self, person_id: int, stats: list, groups: list, **params) if 400 <= mlb_data.status_code <= 499: return {} - if 'stats' in mlb_data.data and mlb_data.data['stats']: - splits = mlb_module.create_split_data(mlb_data.data['stats']) - else: - return {} - - return splits + return parse_split_stats(mlb_data.data) def get_stats(self, stats: list, groups: list, **params: dict) -> dict: """ @@ -2184,11 +2171,6 @@ def get_stats(self, stats: list, groups: list, **params: dict) -> dict: if 400 <= mlb_data.status_code <= 499: return {} - if 'stats' in mlb_data.data and mlb_data.data['stats']: - splits = mlb_module.create_split_data(mlb_data.data['stats']) - else: - return {} - - return splits + return parse_split_stats(mlb_data.data) # This is to test pypi, please delete later diff --git a/tests/parsers/test_stats_parser.py b/tests/parsers/test_stats_parser.py new file mode 100644 index 00000000..97526e58 --- /dev/null +++ b/tests/parsers/test_stats_parser.py @@ -0,0 +1,88 @@ +from mlbstatsapi._parsers.stats import parse_split_stats +from mlbstatsapi.models.stats import Stat + + +HITTING_SEASON = { + "type": {"displayName": "season"}, + "group": {"displayName": "hitting"}, + "totalSplits": 1, + "splits": [ + { + "season": "2022", + "stat": { + "gamesPlayed": 157, + "atBats": 586, + "hits": 160, + "homeRuns": 34, + "avg": ".273", + }, + "team": {"id": 108, "name": "Los Angeles Angels", "link": "/api/v1/teams/108"}, + "player": {"id": 660271, "fullName": "Shohei Ohtani", "link": "/api/v1/people/660271"}, + } + ], +} + +PITCHING_SEASON = { + "type": {"displayName": "season"}, + "group": {"displayName": "pitching"}, + "totalSplits": 1, + "splits": [ + { + "season": "2022", + "stat": {"gamesPlayed": 28, "wins": 15, "losses": 9, "era": "2.33"}, + "team": {"id": 108, "name": "Los Angeles Angels", "link": "/api/v1/teams/108"}, + "player": {"id": 660271, "fullName": "Shohei Ohtani", "link": "/api/v1/people/660271"}, + } + ], +} + + +def test_parses_a_single_group_and_type(): + stats = parse_split_stats({"stats": [HITTING_SEASON]}) + + assert list(stats) == ["hitting"] + assert list(stats["hitting"]) == ["season"] + assert isinstance(stats["hitting"]["season"], Stat) + + +def test_keys_by_group_then_type(): + stats = parse_split_stats({"stats": [HITTING_SEASON, PITCHING_SEASON]}) + + assert set(stats) == {"hitting", "pitching"} + assert stats["hitting"]["season"].group == "hitting" + assert stats["pitching"]["season"].group == "pitching" + + +def test_carries_the_split_payload_through(): + stats = parse_split_stats({"stats": [HITTING_SEASON]}) + + split = stats["hitting"]["season"].splits[0] + assert split.season == "2022" + assert split.stat.home_runs == 34 + + +def test_missing_stats_key_returns_an_empty_mapping(): + assert parse_split_stats({}) == {} + + +def test_empty_stats_list_returns_an_empty_mapping(): + assert parse_split_stats({"stats": []}) == {} + + +def test_empty_body_returns_an_empty_mapping(): + assert parse_split_stats(None) == {} + + +def test_a_group_with_no_splits_is_skipped(): + """create_split_data drops entries carrying no splits rather than keying an empty Stat.""" + empty = dict(HITTING_SEASON, splits=[]) + + assert parse_split_stats({"stats": [empty]}) == {} + + +def test_a_group_with_no_splits_does_not_suppress_its_siblings(): + empty = dict(HITTING_SEASON, splits=[]) + + stats = parse_split_stats({"stats": [empty, PITCHING_SEASON]}) + + assert list(stats) == ["pitching"] diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py index afa87feb..c6137b3a 100644 --- a/tests/test_async_mlb.py +++ b/tests/test_async_mlb.py @@ -51,6 +51,7 @@ from mlbstatsapi.models.seasons import Season # noqa: E402 from mlbstatsapi.models.sports import Sport # noqa: E402 from mlbstatsapi.models.standings import Standings # noqa: E402 +from mlbstatsapi.models.stats import Stat # noqa: E402 from mlbstatsapi.models.teams import Team # noqa: E402 from mlbstatsapi.models.venues import Venue # noqa: E402 @@ -348,6 +349,28 @@ EXPECTED_VENUE = Venue(id=31, link="/api/v1/venues/31", name="PNC Park") # The two ways an endpoint legitimately comes back with nothing to parse. +STATS_PAYLOAD = { + "stats": [ + { + "type": {"displayName": "season"}, + "group": {"displayName": "hitting"}, + "totalSplits": 1, + "splits": [ + { + "season": "2022", + "stat": {"gamesPlayed": 157, "homeRuns": 34, "avg": ".273"}, + "team": {"id": 108, "name": "Los Angeles Angels", "link": "/api/v1/teams/108"}, + "player": { + "id": 660271, + "fullName": "Shohei Ohtani", + "link": "/api/v1/people/660271", + }, + } + ], + } + ] +} + NO_RESULT_RESPONSES = { "404": httpx.Response(404, json={}), "empty 200": httpx.Response(200, json={}), @@ -1122,6 +1145,102 @@ async def scenario(): assert asyncio.run(scenario()) is None +def test_get_stats_request_matches_the_sync_client(): + handler = _Handler(_json(STATS_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_stats(["season"], ["hitting"]) + + stats = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_stats", ["season"], ["hitting"]) + assert list(stats) == ["hitting"] + assert isinstance(stats["hitting"]["season"], Stat) + + +def test_get_player_stats_request_matches_the_sync_client(): + handler = _Handler(_json(STATS_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_player_stats(660271, ["season"], ["hitting"]) + + stats = asyncio.run(scenario()) + + assert_matches_sync( + handler.request, "get_player_stats", 660271, ["season"], ["hitting"] + ) + assert isinstance(stats["hitting"]["season"], Stat) + + +def test_get_team_stats_request_matches_the_sync_client(): + handler = _Handler(_json(STATS_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_team_stats(133, ["season"], ["hitting"]) + + stats = asyncio.run(scenario()) + + assert_matches_sync( + handler.request, "get_team_stats", 133, ["season"], ["hitting"] + ) + assert isinstance(stats["hitting"]["season"], Stat) + + +def test_get_players_stats_for_game_request_matches_the_sync_client(): + handler = _Handler(_json(STATS_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_players_stats_for_game(660271, 715757) + + stats = asyncio.run(scenario()) + + assert_matches_sync( + handler.request, "get_players_stats_for_game", 660271, 715757 + ) + assert isinstance(stats["hitting"]["season"], Stat) + + +def test_get_players_stats_for_game_forwards_extra_params(): + """The signature accepts **params, so they have to reach the query string.""" + handler = _Handler(_json(STATS_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_players_stats_for_game( + 660271, 715757, eventType="single" + ) + + asyncio.run(scenario()) + + assert handler.request.url.params["eventType"] == "single" + + +@pytest.mark.parametrize( + "method, args", + [ + ("get_stats", (["season"], ["hitting"])), + ("get_player_stats", (660271, ["season"], ["hitting"])), + ("get_team_stats", (133, ["season"], ["hitting"])), + ("get_players_stats_for_game", (660271, 715757)), + ], +) +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_stat_endpoints_return_an_empty_mapping_when_there_are_no_stats( + method, args, label +): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await getattr(mlb, method)(*args) + + assert asyncio.run(scenario()) == {} + + def test_get_team_id_request_matches_the_sync_client(): handler = _Handler(_json({"teams": [{"id": 133, "name": "Athletics"}]})) @@ -1371,6 +1490,10 @@ def test_public_signatures_match_the_sync_client(): "get_draft", "get_awards", "get_homerun_derby", + "get_stats", + "get_player_stats", + "get_team_stats", + "get_players_stats_for_game", "get_game", "get_game_play_by_play", "get_game_line_score", diff --git a/tests/test_public_api.py b/tests/test_public_api.py index b3cae605..7aba78f8 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -264,6 +264,10 @@ def _normalize_signature(fn: Any) -> str: "get_draft": "(year_id: int, **params)", "get_awards": "(award_id: str, **params)", "get_homerun_derby": "(game_id, **params)", + "get_team_stats": "(team_id: int, stats: list, groups: list, **params)", + "get_players_stats_for_game": "(person_id: int, game_id: int, **params)", + "get_player_stats": "(person_id: int, stats: list, groups: list, **params)", + "get_stats": "(stats: list, groups: list, **params)", "get_team_id": "(team_name: str, search_key: str='name', **params)", "get_people_id": ( "(fullname: str, sport_id: int=1, search_key: str='fullName', **params)" diff --git a/tests/test_sync_async_parity.py b/tests/test_sync_async_parity.py index 17461364..2f22bcef 100644 --- a/tests/test_sync_async_parity.py +++ b/tests/test_sync_async_parity.py @@ -54,6 +54,7 @@ from mlbstatsapi.models.seasons import Season # noqa: E402 from mlbstatsapi.models.sports import Sport # noqa: E402 from mlbstatsapi.models.standings import Standings # noqa: E402 +from mlbstatsapi.models.stats import Stat # noqa: E402 from mlbstatsapi.models.teams import Team # noqa: E402 from mlbstatsapi.models.venues import Venue # noqa: E402 @@ -341,6 +342,32 @@ }, } +STATS_PAYLOAD = { + "stats": [ + { + "type": {"displayName": "season"}, + "group": {"displayName": "hitting"}, + "totalSplits": 1, + "splits": [ + { + "season": "2022", + "stat": {"gamesPlayed": 157, "homeRuns": 34, "avg": ".273"}, + "team": { + "id": 108, + "name": "Los Angeles Angels", + "link": "/api/v1/teams/108", + }, + "player": { + "id": 660271, + "fullName": "Shohei Ohtani", + "link": "/api/v1/people/660271", + }, + } + ], + } + ] +} + # The canned transport failures, per client. Each pair is the closest # equivalent the two libraries offer, so the public exception is the only # thing being compared. @@ -723,6 +750,87 @@ def test_get_homerun_derby_success_parity(): assert result.request == ("GET", "/api/v1/homeRunDerby/511101", {}) +def test_get_stats_success_parity(): + """A successful stats response parses to the same split mapping on both clients.""" + result = call_both("get_stats", ["season"], ["hitting"], payload=STATS_PAYLOAD) + + assert list(result.sync) == ["hitting"], "sync get_stats did not key by group" + assert isinstance(result.sync["hitting"]["season"], Stat) + assert result.asynchronous == result.sync + assert result.request == ( + "GET", + "/api/v1/stats", + {"stats": "season", "group": "hitting"}, + ) + + +def test_get_player_stats_success_parity(): + """A successful player stats response parses the same on both clients.""" + result = call_both( + "get_player_stats", 660271, ["season"], ["hitting"], payload=STATS_PAYLOAD + ) + + assert isinstance(result.sync["hitting"]["season"], Stat) + assert result.asynchronous == result.sync + assert result.request == ( + "GET", + "/api/v1/people/660271/stats", + {"stats": "season", "group": "hitting"}, + ) + + +def test_get_team_stats_success_parity(): + """A successful team stats response parses the same on both clients.""" + result = call_both( + "get_team_stats", 133, ["season"], ["hitting"], payload=STATS_PAYLOAD + ) + + assert isinstance(result.sync["hitting"]["season"], Stat) + assert result.asynchronous == result.sync + assert result.request == ( + "GET", + "/api/v1/teams/133/stats", + {"stats": "season", "group": "hitting"}, + ) + + +def test_get_players_stats_for_game_success_parity(): + """A successful per-game stats response parses the same on both clients.""" + result = call_both( + "get_players_stats_for_game", 660271, 715757, payload=STATS_PAYLOAD + ) + + assert isinstance(result.sync["hitting"]["season"], Stat) + assert result.asynchronous == result.sync + assert result.request == ( + "GET", + "/api/v1/people/660271/stats/game/715757", + {}, + ) + + +def test_get_players_stats_for_game_forwards_params_on_both_clients(): + """Regression coverage: **params used to be accepted and silently dropped. + + ``get_players_stats_for_game`` advertises ``**params`` but never passed + ``ep_params`` to the adapter, so every caller-supplied keyword vanished + before the request was built. Both clients now forward them. + """ + result = call_both( + "get_players_stats_for_game", + 660271, + 715757, + eventType="single", + payload=STATS_PAYLOAD, + ) + + assert result.request == ( + "GET", + "/api/v1/people/660271/stats/game/715757", + {"eventType": "single"}, + ) + + def test_get_team_id_success_parity(): """A matching name is resolved to the same id list on both clients.""" result = call_both( @@ -1056,6 +1164,26 @@ def test_get_homerun_derby_no_result_parity(label): ) +@pytest.mark.parametrize( + "method, args", + [ + ("get_stats", (["season"], ["hitting"])), + ("get_player_stats", (660271, ["season"], ["hitting"])), + ("get_team_stats", (133, ["season"], ["hitting"])), + ("get_players_stats_for_game", (660271, 715757)), + ], +) +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_stat_endpoint_no_result_parity(method, args, label): + """Every no-result response returns an empty mapping on either client.""" + result = call_both(method, *args, **NO_RESULT_RESPONSES[label]) + + assert result.sync == {}, f"sync {method} returned {result.sync!r} for {label}" + assert result.asynchronous == {}, ( + f"async {method} returned {result.asynchronous!r} for {label}" + ) + + def test_get_homerun_derby_malformed_error_body_parity(): """Regression coverage: the bare-None-instead-of-return-None bug fix. From 113a8c0677f275d665425f83e5a88557072a4b9a Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sun, 23 Aug 2026 16:39:26 -0700 Subject: [PATCH 53/81] feat(async): add the last three endpoints, completing AsyncMlb coverage Ports get_persons, get_scheduled_games_by_date, and get_gamepace. AsyncMlb now exposes every endpoint method Mlb does; the only remaining public difference is that close() is spelled aclose(). Two more shared parsers, following the established pattern: _parsers/schedules.py gains parse_scheduled_games(), and _parsers/gamepace.py is new. Both replace inline loops/conditionals in mlb_api.py, so the two clients share one copy. Fixes an httpx/requests divergence that would have silently broken get_gamepace on the async side. Mlb builds that request as endpoint="gamePace?season=2021" with ep_params={"sportId": 1} and relies on Requests merging the endpoint's query with the params. HTTPX does not merge -- passing params replaces a query already on the URL -- so copying the sync idiom drops the season entirely and silently returns whatever the unfiltered endpoint gives back. AsyncMlb passes the season as an ordinary param instead, which produces a byte-identical request. Verified against the live API: async and sync return equal GamePace objects for season 2021. tests/test_async_mlb.py's assert_matches_sync() had the same blind spot -- it compared url.path against the raw endpoint string and would not have caught this. It now splits an endpoint's embedded query and folds it into the expected params, which is what Requests does, so the expectation is the merged query either client must end up sending. This also subsumes the get_awards trailing-? special case it previously carried. get_scheduled_games_by_date preserves Mlb's quirk of returning None rather than the [] its annotation promises when no date selector was given, asserted explicitly in the parity suite rather than left implicit. Tests: 1057 passed (up from 1016). tests/external_tests/ 148 passed, 1 skipped against the live API. Co-Authored-By: Claude Opus 5 --- docs/public-api.md | 28 ++- mlbstatsapi/_parsers/gamepace.py | 17 ++ mlbstatsapi/_parsers/schedules.py | 18 +- mlbstatsapi/async_mlb.py | 217 +++++++++++++++++++++- mlbstatsapi/mlb_api.py | 18 +- tests/parsers/test_gamepace_parser.py | 65 +++++++ tests/parsers/test_schedules.py | 106 ++++++++++- tests/test_async_mlb.py | 258 +++++++++++++++++++++++++- tests/test_public_api.py | 6 + tests/test_sync_async_parity.py | 181 ++++++++++++++++++ 10 files changed, 886 insertions(+), 28 deletions(-) create mode 100644 mlbstatsapi/_parsers/gamepace.py create mode 100644 tests/parsers/test_gamepace_parser.py diff --git a/docs/public-api.md b/docs/public-api.md index 94310e4c..260094c2 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -327,6 +327,15 @@ get_team_stats(team_id: int, stats: list, groups: list, **params) get_players_stats_for_game(person_id: int, game_id: int, **params) get_player_stats(person_id: int, stats: list, groups: list, **params) get_stats(stats: list, groups: list, **params) +get_persons(person_ids: str | list[int], **params) +get_scheduled_games_by_date( + date: str = None, + start_date: str = None, + end_date: str = None, + sport_id: int = 1, + **params, +) +get_gamepace(season: str, sport_id=1, **params) get_team_id(team_name: str, search_key: str = 'name', **params) get_people_id( fullname: str, @@ -369,9 +378,22 @@ do, keyed by stat group and then by stat type — `{'hitting': {'season': Stat}} listed at `https://statsapi.mlb.com/api/v1/statTypes` and `https://statsapi.mlb.com/api/v1/statGroups`. -Every other `Mlb` endpoint method not listed above is not yet supported on -`AsyncMlb`; calling it there raises `AttributeError`. See issue #305 for the -tracked expansion plan. +`AsyncMlb` now covers every endpoint method `Mlb` exposes. The only public +name that differs is lifecycle: `Mlb.close()` is spelled `AsyncMlb.aclose()`. + +`get_scheduled_games_by_date` inherits the same documented quirk as +`Mlb.get_scheduled_games_by_date`: it is annotated `list[ScheduleGames]` but +returns `None` when no `date`, `start_date`/`end_date` pair, or `gamePks` was +given to select with. This is preserved for parity, not introduced by the +async port. + +`get_gamepace` sends the same request on both clients but builds it +differently. `Mlb` embeds the season in the endpoint string +(`gamePace?season=2021`) and relies on Requests merging that query with the +rest of the parameters. HTTPX replaces a URL's existing query rather than +merging into it, so `AsyncMlb` passes the season as an ordinary parameter. +Callers see no difference; this matters only if you are reading the two +implementations side by side. ## Low-level adapter diff --git a/mlbstatsapi/_parsers/gamepace.py b/mlbstatsapi/_parsers/gamepace.py new file mode 100644 index 00000000..d04b3a59 --- /dev/null +++ b/mlbstatsapi/_parsers/gamepace.py @@ -0,0 +1,17 @@ +from mlbstatsapi.models.gamepace import GamePace + + +def parse_gamepace(data: dict) -> GamePace | None: + """Parse a GamePace from an MLB /gamePace response body. + + The endpoint keys its metrics by whichever of ``teams``, ``leagues`` or + ``sports`` the caller's ``orgType`` selected, so a body carrying none of + them has nothing to build from. + """ + if not data: + return None + + if not (data.get("teams") or data.get("leagues") or data.get("sports")): + return None + + return GamePace(**data) diff --git a/mlbstatsapi/_parsers/schedules.py b/mlbstatsapi/_parsers/schedules.py index 39fc0d6b..3179d4e0 100644 --- a/mlbstatsapi/_parsers/schedules.py +++ b/mlbstatsapi/_parsers/schedules.py @@ -1,4 +1,4 @@ -from mlbstatsapi.models.schedules import Schedule +from mlbstatsapi.models.schedules import Schedule, ScheduleGames def parse_schedule(data: dict) -> Schedule | None: @@ -7,3 +7,19 @@ def parse_schedule(data: dict) -> Schedule | None: return None return Schedule(**data) + + +def parse_scheduled_games(data: dict) -> list[ScheduleGames]: + """Parse the games out of an MLB /schedule response body, flattened. + + The response nests games under one entry per date; this returns them as a + single list, dropping the date grouping. + """ + if not data or not data.get("dates"): + return [] + + return [ + ScheduleGames(**game) + for date in data["dates"] + for game in date["games"] + ] diff --git a/mlbstatsapi/async_mlb.py b/mlbstatsapi/async_mlb.py index fee87d04..839dbf63 100644 --- a/mlbstatsapi/async_mlb.py +++ b/mlbstatsapi/async_mlb.py @@ -19,11 +19,12 @@ parse_linescore, parse_plays, ) +from ._parsers.gamepace import parse_gamepace from ._parsers.homerunderby import parse_homerun_derby from ._parsers.leagues import parse_league, parse_leagues from ._parsers.people import parse_person, parse_people from ._parsers.roster import parse_roster_coaches, parse_roster_players -from ._parsers.schedules import parse_schedule +from ._parsers.schedules import parse_schedule, parse_scheduled_games from ._parsers.seasons import parse_season, parse_seasons from ._parsers.sports import parse_sport, parse_sports from ._parsers.standings import parse_standings @@ -37,10 +38,11 @@ from .models.divisions import Division from .models.drafts import Round from .models.game import BoxScore, Game, Linescore, Plays +from .models.gamepace import GamePace from .models.homerunderby import HomeRunDerby from .models.leagues import League from .models.people import Coach, Person, Player -from .models.schedules import Schedule +from .models.schedules import Schedule, ScheduleGames from .models.seasons import Season from .models.sports import Sport from .models.standings import Standings @@ -2251,3 +2253,214 @@ async def get_stats( return {} return parse_split_stats(mlb_data.data) + + async def get_persons( + self, + person_ids: str | list[int], + **params, + ) -> list[Person]: + """ + This endpoint returns statistical data and biographical information + for players, umpires, and coaches based on playerId. + + Async counterpart of ``Mlb.get_persons``. + + Parameters + ---------- + person_ids : str, list[int] + Insert personId(s) to return biographical information for a + specific player. Format '605151,592450' or [605151,592450] + + Other Parameters + ---------------- + hydrate : str + Insert hydration(s) to return statistical or biographical data + for a specific player(s). + Format stats(group=["statGroup1","statGroup2"], + type=["statType1","statType2"]). + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + list of Person + returns a list of Person + + See Also + -------- + AsyncMlb.get_people : Return a list of People from sport id. + AsyncMlb.get_people_id : Return person id from name. + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... people = await mlb.get_persons("605151,592450") + [Person, Person] + """ + params["personIds"] = person_ids + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="people", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_people(mlb_data.data) + + async def get_scheduled_games_by_date( + self, + date: str = None, + start_date: str = None, + end_date: str = None, + sport_id: int = 1, + **params, + ) -> list[ScheduleGames]: + """ + return game ids for a specific date and game status + + Async counterpart of ``Mlb.get_scheduled_games_by_date``. + + Parameters + ---------- + date : str + start date, 'yyyy-mm-dd' + start_date : str + Start date, 'yyyy-mm-dd' + end_date : str + end date, 'yyyy-mm-dd' + sport_id : int + sport id of schedule, defaults to 1 + + Other Parameters + ---------------- + leagueId : int, str + Insert leagueId to return all schedules based on a particular + scheduleType for a specific league. Usage: 1 or '1,11' + gamePks : int, str + Insert gamePks to return all schedules based on a particular + scheduleType for specific games. Usage: 531493 or '531493,531497' + venueIds : int + Insert venueId to return all schedules based on a particular + scheduleType for a specific venueId. + gameTypes : str + Insert gameTypes to return schedule information for all games in + particular gameTypes. For a list of all gameTypes: + https://statsapi.mlb.com/api/v1/gameTypes + + Returns + ------- + list of ScheduleGames + returns a list of matching games + + See Also + -------- + AsyncMlb.get_game_ids : return a list of game ids + AsyncMlb.get_game : return a specific game from game id + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... games = await mlb.get_scheduled_games_by_date("2022-10-13") + [ScheduleGames, ScheduleGames] + """ + params = build_schedule_params( + date=date, + start_date=start_date, + end_date=end_date, + sport_id=sport_id, + **params, + ) + + # Mirrors Mlb.get_scheduled_games_by_date, which returns None -- not + # the empty list its annotation promises -- when no date selector was + # given. Preserved for parity, not introduced here. + if params is None: + return None + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="schedule", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return [] + + return parse_scheduled_games(mlb_data.data) + + async def get_gamepace( + self, + season: str, + sport_id=1, + **params, + ) -> GamePace | None: + """ + Get pace of game metrics for specific sport, league or team. + + Async counterpart of ``Mlb.get_gamepace``. + + Parameters + ---------- + season : str + Insert year to return a directory of pace of game metrics for a + given season. + sport_id : int + Insert a sportId to return a directory of pace of game metrics + for a specific sport, defaults to 1 + + Other Parameters + ---------------- + teamIds : int + Insert a teamIds to return directory of pace of game metrics for + a given team. Format '110' or '110,147' + leagueId : int + Insert leagueIds to return a directory of pace of game metrics + for a given league. Format '103' or '103,104' + leagueListId : str + Insert a unique League List Identifier to return a directory of + pace of game metrics for a specific league listId. + gameType : str + Insert gameType(s) to return a directory of pace of game metrics + for a specific gameType. For a list of all gameTypes: + https://statsapi.mlb.com/api/v1/gameTypes + orgType : str + Insert a orgType to return a directory of pace of game metrics + based on team, league or sport. + Available values : T- TEAM, L- LEAGUE, S- SPORT + includeChildren : bool + Insert includeChildren to return a directory of pace of game + metrics for all child teams in a given parent sport. + fields : str + Comma delimited list of specific fields to be returned. + Format: topLevelNode, childNode, attribute + + Returns + ------- + GamePace + + Examples + -------- + >>> async with AsyncMlb() as mlb: + ... gamepace = await mlb.get_gamepace("2021") + GamePace + """ + # Mlb.get_gamepace embeds the season in the endpoint string + # ("gamePace?season=2021") and lets Requests merge that query with + # ep_params. HTTPX does not merge -- passing params replaces a query + # already present on the URL -- so copying that idiom here would drop + # the season silently. Passing it as a param produces the identical + # request on both clients. + params["season"] = season + params["sportId"] = sport_id + + mlb_data = await self._mlb_adapter_v1.get( + endpoint="gamePace", + ep_params=params, + ) + + if 400 <= mlb_data.status_code <= 499: + return None + + return parse_gamepace(mlb_data.data) diff --git a/mlbstatsapi/mlb_api.py b/mlbstatsapi/mlb_api.py index f4ef944b..9ca1d429 100644 --- a/mlbstatsapi/mlb_api.py +++ b/mlbstatsapi/mlb_api.py @@ -38,7 +38,8 @@ from ._parsers.standings import parse_standings from ._parsers.stats import parse_split_stats from ._parsers.teams import parse_teams, parse_team -from ._parsers.schedules import parse_schedule +from ._parsers.gamepace import parse_gamepace +from ._parsers.schedules import parse_schedule, parse_scheduled_games from ._parsers.venues import parse_venues, parse_venue from .mlb_dataadapter import ( @@ -869,18 +870,11 @@ def get_scheduled_games_by_date(self, date: str = None, params["sportId"] = sport_id - games = [] - mlb_data = self._mlb_adapter_v1.get(endpoint='schedule', ep_params=params) if 400 <= mlb_data.status_code <= 499: return [] - if 'dates' in mlb_data.data and mlb_data.data['dates']: - for date in mlb_data.data['dates']: - for game in date['games']: - games.append(ScheduleGames(**game)) - - return games + return parse_scheduled_games(mlb_data.data) def get_game(self, game_id: int, **params) -> Union[Game, None]: """ @@ -1182,11 +1176,7 @@ def get_gamepace(self, season: str, sport_id=1, **params) -> Union[GamePace, Non if 400 <= mlb_data.status_code <= 499: return None - if ('teams' in mlb_data.data and mlb_data.data['teams'] - or 'leagues' in mlb_data.data and mlb_data.data['leagues'] - or 'sports' in mlb_data.data and mlb_data.data['sports']): - - return GamePace(**mlb_data.data) + return parse_gamepace(mlb_data.data) def get_venue(self, venue_id: int, **params) -> Union[Venue, None]: """ diff --git a/tests/parsers/test_gamepace_parser.py b/tests/parsers/test_gamepace_parser.py new file mode 100644 index 00000000..e036a4e3 --- /dev/null +++ b/tests/parsers/test_gamepace_parser.py @@ -0,0 +1,65 @@ +from mlbstatsapi._parsers.gamepace import parse_gamepace +from mlbstatsapi.models.gamepace import GamePace + + +SPORT_PACE = { + "hitsPer9Inn": 16.68, + "runsPer9Inn": 9.3, + "pitchesPer9Inn": 299.83, + "totalGames": 2429, + "timePerGame": "03:11:26", + "season": "2021", + "sport": {"id": 1, "code": "mlb", "link": "/api/v1/sports/1"}, +} + +TEAM_PACE = dict( + SPORT_PACE, + team={"id": 133, "name": "Athletics", "link": "/api/v1/teams/133"}, +) + +LEAGUE_PACE = dict( + SPORT_PACE, + league={"id": 103, "name": "American League", "link": "/api/v1/league/103"}, +) + + +def test_parses_sports_pace(): + gamepace = parse_gamepace({"sports": [SPORT_PACE]}) + + assert isinstance(gamepace, GamePace) + assert len(gamepace.sports) == 1 + assert gamepace.sports[0].season == "2021" + + +def test_parses_teams_pace(): + gamepace = parse_gamepace({"teams": [TEAM_PACE]}) + + assert isinstance(gamepace, GamePace) + assert len(gamepace.teams) == 1 + + +def test_parses_leagues_pace(): + gamepace = parse_gamepace({"leagues": [LEAGUE_PACE]}) + + assert isinstance(gamepace, GamePace) + assert len(gamepace.leagues) == 1 + + +def test_any_one_populated_key_is_enough(): + """The endpoint keys metrics by orgType, so only one of the three arrives.""" + gamepace = parse_gamepace({"teams": [], "leagues": [], "sports": [SPORT_PACE]}) + + assert isinstance(gamepace, GamePace) + + +def test_a_body_with_none_of_the_three_keys_returns_none(): + assert parse_gamepace({"copyright": "NOTICE"}) is None + + +def test_a_body_whose_keys_are_all_empty_returns_none(): + assert parse_gamepace({"teams": [], "leagues": [], "sports": []}) is None + + +def test_empty_body_returns_none(): + assert parse_gamepace({}) is None + assert parse_gamepace(None) is None diff --git a/tests/parsers/test_schedules.py b/tests/parsers/test_schedules.py index fcd1b418..61c27f15 100644 --- a/tests/parsers/test_schedules.py +++ b/tests/parsers/test_schedules.py @@ -1,8 +1,8 @@ import pytest from pydantic import ValidationError -from mlbstatsapi._parsers.schedules import parse_schedule -from mlbstatsapi.models.schedules import Schedule +from mlbstatsapi._parsers.schedules import parse_schedule, parse_scheduled_games +from mlbstatsapi.models.schedules import Schedule, ScheduleGames def test_parse_schedule(): @@ -48,3 +48,105 @@ def test_parse_schedule_requires_totals(): ] } ) + + +def _game(game_pk: int) -> dict: + return { + "gamePk": game_pk, + "gameGuid": "d344c53c-9e37-4c4b-86ae-f20e769115fc", + "link": f"/api/v1.1/game/{game_pk}/feed/live", + "gameType": "D", + "season": "2022", + "gameDate": "2022-10-13T19:37:00Z", + "officialDate": "2022-10-13", + "status": { + "abstractGameState": "Final", + "codedGameState": "F", + "detailedState": "Final", + "statusCode": "F", + "startTimeTBD": False, + "abstractGameCode": "F", + }, + "teams": { + "away": { + "team": {"id": 136, "name": "Seattle Mariners", "link": "/api/v1/teams/136"}, + "leagueRecord": {"wins": 0, "losses": 2, "ties": 0, "pct": ".000"}, + "score": 2, + "isWinner": False, + "splitSquad": False, + "seriesNumber": 1, + }, + "home": { + "team": {"id": 117, "name": "Houston Astros", "link": "/api/v1/teams/117"}, + "leagueRecord": {"wins": 2, "losses": 0, "ties": 0, "pct": "1.000"}, + "score": 4, + "isWinner": True, + "splitSquad": False, + "seriesNumber": 1, + }, + }, + "venue": {"id": 2392, "name": "Minute Maid Park", "link": "/api/v1/venues/2392"}, + "content": {"link": f"/api/v1/game/{game_pk}/content"}, + "isTie": False, + "gameNumber": 1, + "publicFacing": True, + "doubleHeader": "N", + "gamedayType": "P", + "tiebreaker": "N", + "calendarEventID": f"14-{game_pk}-2022-10-13", + "seasonDisplay": "2022", + "dayNight": "day", + "description": "ALDS Game 2", + "scheduledInnings": 9, + "reverseHomeAwayStatus": False, + "inningBreakLength": 120, + "gamesInSeries": 5, + "seriesGameNumber": 2, + "seriesDescription": "AL Division Series", + "recordSource": "S", + "ifNecessary": "N", + "ifNecessaryDescription": "Normal Game", + } + + +def _date(date: str, *games: dict) -> dict: + return { + "date": date, + "totalItems": len(games), + "totalEvents": 0, + "totalGames": len(games), + "totalGamesInProgress": 0, + "games": list(games), + } + + +def test_parse_scheduled_games_builds_models(): + games = parse_scheduled_games({"dates": [_date("2022-10-13", _game(715757))]}) + + assert len(games) == 1 + assert isinstance(games[0], ScheduleGames) + assert games[0].game_pk == 715757 + + +def test_parse_scheduled_games_flattens_across_dates(): + """The response groups games by date; the parser drops that grouping.""" + games = parse_scheduled_games( + { + "dates": [ + _date("2022-10-13", _game(715757), _game(715758)), + _date("2022-10-14", _game(715759)), + ] + } + ) + + assert [game.game_pk for game in games] == [715757, 715758, 715759] + + +def test_parse_scheduled_games_with_no_dates_returns_empty_list(): + assert parse_scheduled_games({"dates": []}) == [] + assert parse_scheduled_games({}) == [] + assert parse_scheduled_games(None) == [] + + +def test_parse_scheduled_games_with_a_date_carrying_no_games_returns_empty_list(): + assert parse_scheduled_games({"dates": [_date("2022-10-13")]}) == [] diff --git a/tests/test_async_mlb.py b/tests/test_async_mlb.py index c6137b3a..87c68b3d 100644 --- a/tests/test_async_mlb.py +++ b/tests/test_async_mlb.py @@ -25,6 +25,7 @@ import asyncio from contextlib import asynccontextmanager from unittest.mock import AsyncMock, MagicMock +from urllib.parse import parse_qsl import pytest @@ -44,6 +45,7 @@ from mlbstatsapi.models.divisions import Division # noqa: E402 from mlbstatsapi.models.drafts import Round # noqa: E402 from mlbstatsapi.models.game import BoxScore, Game, Linescore, Plays # noqa: E402 +from mlbstatsapi.models.gamepace import GamePace # noqa: E402 from mlbstatsapi.models.homerunderby import HomeRunDerby # noqa: E402 from mlbstatsapi.models.leagues import League # noqa: E402 from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 @@ -349,6 +351,94 @@ EXPECTED_VENUE = Venue(id=31, link="/api/v1/venues/31", name="PNC Park") # The two ways an endpoint legitimately comes back with nothing to parse. +SCHEDULED_GAME = { + "gamePk": 715757, + "gameGuid": "d344c53c-9e37-4c4b-86ae-f20e769115fc", + "link": "/api/v1.1/game/715757/feed/live", + "gameType": "D", + "season": "2022", + "gameDate": "2022-10-13T19:37:00Z", + "officialDate": "2022-10-13", + "status": { + "abstractGameState": "Final", + "codedGameState": "F", + "detailedState": "Final", + "statusCode": "F", + "startTimeTBD": False, + "abstractGameCode": "F", + }, + "teams": { + "away": { + "team": {"id": 136, "name": "Seattle Mariners", "link": "/api/v1/teams/136"}, + "leagueRecord": {"wins": 0, "losses": 2, "ties": 0, "pct": ".000"}, + "score": 2, + "isWinner": False, + "splitSquad": False, + "seriesNumber": 1, + }, + "home": { + "team": {"id": 117, "name": "Houston Astros", "link": "/api/v1/teams/117"}, + "leagueRecord": {"wins": 2, "losses": 0, "ties": 0, "pct": "1.000"}, + "score": 4, + "isWinner": True, + "splitSquad": False, + "seriesNumber": 1, + }, + }, + "venue": {"id": 2392, "name": "Minute Maid Park", "link": "/api/v1/venues/2392"}, + "content": {"link": "/api/v1/game/715757/content"}, + "isTie": False, + "gameNumber": 1, + "publicFacing": True, + "doubleHeader": "N", + "gamedayType": "P", + "tiebreaker": "N", + "calendarEventID": "14-715757-2022-10-13", + "seasonDisplay": "2022", + "dayNight": "day", + "description": "ALDS Game 2", + "scheduledInnings": 9, + "reverseHomeAwayStatus": False, + "inningBreakLength": 120, + "gamesInSeries": 5, + "seriesGameNumber": 2, + "seriesDescription": "AL Division Series", + "recordSource": "S", + "ifNecessary": "N", + "ifNecessaryDescription": "Normal Game", +} + +SCHEDULED_GAMES_PAYLOAD = { + "totalItems": 1, + "totalEvents": 0, + "totalGames": 1, + "totalGamesInProgress": 0, + "dates": [ + { + "date": "2022-10-13", + "totalItems": 1, + "totalEvents": 0, + "totalGames": 1, + "totalGamesInProgress": 0, + "games": [SCHEDULED_GAME], + } + ], +} + +GAMEPACE_PAYLOAD = { + "sports": [ + { + "hitsPer9Inn": 16.68, + "runsPer9Inn": 9.3, + "pitchesPer9Inn": 299.83, + "totalGames": 2429, + "timePerGame": "03:11:26", + "season": "2021", + "sport": {"id": 1, "code": "mlb", "link": "/api/v1/sports/1"}, + } + ] +} + STATS_PAYLOAD = { "stats": [ { @@ -474,14 +564,20 @@ def _flatten_params(params: dict) -> list[tuple[str, str]]: def assert_matches_sync(request: httpx.Request, method: str, *args, **kwargs) -> None: - """Assert an observed request is the one ``Mlb`` would have made.""" + """Assert an observed request is the one ``Mlb`` would have made. + + Some Mlb endpoint strings carry their own query: get_gamepace embeds the + season, and get_awards ends in a bare "?". Requests merges that query with + ep_params, so the expectation is the two combined -- which is what either + client has to end up sending, however it chose to build the URL. + """ endpoint, params, ver = sync_request_for(method, *args, **kwargs) - # get_awards's endpoint string has a trailing "?" (harmless legacy cruft - # both Requests and HTTPX strip as an empty query separator), which never - # shows up in url.path. - assert request.url.path == f"/api/{ver}/{endpoint}".rstrip("?") - assert sorted(request.url.params.multi_items()) == _flatten_params(params) + path, _, embedded_query = endpoint.partition("?") + expected = _flatten_params(params) + list(parse_qsl(embedded_query)) + + assert request.url.path == f"/api/{ver}/{path}" + assert sorted(request.url.params.multi_items()) == sorted(expected) # --------------------------------------------------------------------------- @@ -1241,6 +1337,153 @@ async def scenario(): assert asyncio.run(scenario()) == {} +def test_get_persons_request_matches_the_sync_client(): + handler = _Handler(_json(PERSON_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_persons("660271,605151") + + people = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_persons", "660271,605151") + assert people == [EXPECTED_PERSON] + + +def test_get_persons_accepts_a_list_of_ids(): + """The signature allows a list as well as a comma-delimited string.""" + handler = _Handler(_json(PERSON_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_persons([660271, 605151]) + + asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_persons", [660271, 605151]) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_persons_returns_an_empty_list_when_there_are_no_people(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_persons("1") + + assert asyncio.run(scenario()) == [] + + +def test_get_scheduled_games_by_date_request_matches_the_sync_client(): + handler = _Handler(_json(SCHEDULED_GAMES_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_scheduled_games_by_date("2022-10-13") + + games = asyncio.run(scenario()) + + assert_matches_sync( + handler.request, "get_scheduled_games_by_date", "2022-10-13" + ) + assert [game.game_pk for game in games] == [715757] + + +def test_get_scheduled_games_by_date_accepts_a_date_range(): + handler = _Handler(_json(SCHEDULED_GAMES_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_scheduled_games_by_date( + start_date="2022-10-13", end_date="2022-10-14" + ) + + asyncio.run(scenario()) + + assert_matches_sync( + handler.request, + "get_scheduled_games_by_date", + start_date="2022-10-13", + end_date="2022-10-14", + ) + + +def test_get_scheduled_games_by_date_accepts_game_pks_without_a_date(): + """gamePks is its own selector; no date is required alongside it.""" + handler = _Handler(_json(SCHEDULED_GAMES_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_scheduled_games_by_date(gamePks=715757) + + asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_scheduled_games_by_date", gamePks=715757) + + +def test_get_scheduled_games_by_date_without_a_selector_returns_none_without_requesting(): + """Mirrors Mlb, which returns None rather than [] when nothing selects a date.""" + handler = _Handler(_json(SCHEDULED_GAMES_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_scheduled_games_by_date() + + assert asyncio.run(scenario()) is None + assert handler.requests == [] + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_scheduled_games_by_date_returns_an_empty_list_when_there_are_no_games(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_scheduled_games_by_date("2022-10-13") + + assert asyncio.run(scenario()) == [] + + +def test_get_gamepace_request_matches_the_sync_client(): + handler = _Handler(_json(GAMEPACE_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_gamepace("2021") + + gamepace = asyncio.run(scenario()) + + assert_matches_sync(handler.request, "get_gamepace", "2021") + assert isinstance(gamepace, GamePace) + assert gamepace.sports[0].season == "2021" + + +def test_get_gamepace_puts_the_season_in_the_query_string(): + """The season rides in the endpoint string rather than in ep_params.""" + handler = _Handler(_json(GAMEPACE_PAYLOAD)) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_gamepace("2021") + + asyncio.run(scenario()) + + assert handler.request.url.path == "/api/v1/gamePace" + assert handler.request.url.params["season"] == "2021" + assert handler.request.url.params["sportId"] == "1" + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_gamepace_returns_none_when_there_is_no_pace_data(label): + handler = _Handler(NO_RESULT_RESPONSES[label]) + + async def scenario(): + async with async_mlb(handler) as mlb: + return await mlb.get_gamepace("2021") + + assert asyncio.run(scenario()) is None + + def test_get_team_id_request_matches_the_sync_client(): handler = _Handler(_json({"teams": [{"id": 133, "name": "Athletics"}]})) @@ -1494,6 +1737,9 @@ def test_public_signatures_match_the_sync_client(): "get_player_stats", "get_team_stats", "get_players_stats_for_game", + "get_persons", + "get_scheduled_games_by_date", + "get_gamepace", "get_game", "get_game_play_by_play", "get_game_line_score", diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 7aba78f8..d909bad3 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -268,6 +268,12 @@ def _normalize_signature(fn: Any) -> str: "get_players_stats_for_game": "(person_id: int, game_id: int, **params)", "get_player_stats": "(person_id: int, stats: list, groups: list, **params)", "get_stats": "(stats: list, groups: list, **params)", + "get_persons": "(person_ids: str | list[int], **params)", + "get_scheduled_games_by_date": ( + "(date: str=None, start_date: str=None, end_date: str=None, " + "sport_id: int=1, **params)" + ), + "get_gamepace": "(season: str, sport_id=1, **params)", "get_team_id": "(team_name: str, search_key: str='name', **params)", "get_people_id": ( "(fullname: str, sport_id: int=1, search_key: str='fullName', **params)" diff --git a/tests/test_sync_async_parity.py b/tests/test_sync_async_parity.py index 2f22bcef..38540ce6 100644 --- a/tests/test_sync_async_parity.py +++ b/tests/test_sync_async_parity.py @@ -47,6 +47,7 @@ from mlbstatsapi.models.divisions import Division # noqa: E402 from mlbstatsapi.models.drafts import Round # noqa: E402 from mlbstatsapi.models.game import BoxScore, Game, Linescore, Plays # noqa: E402 +from mlbstatsapi.models.gamepace import GamePace # noqa: E402 from mlbstatsapi.models.homerunderby import HomeRunDerby # noqa: E402 from mlbstatsapi.models.leagues import League # noqa: E402 from mlbstatsapi.models.people import Coach, Person, Player # noqa: E402 @@ -342,6 +343,94 @@ }, } +SCHEDULED_GAMES_PAYLOAD = { + "totalItems": 1, + "totalEvents": 0, + "totalGames": 1, + "totalGamesInProgress": 0, + "dates": [ + { + "date": "2022-10-13", + "totalItems": 1, + "totalEvents": 0, + "totalGames": 1, + "totalGamesInProgress": 0, + "games": [ + { + "gamePk": 715757, + "gameGuid": "d344c53c-9e37-4c4b-86ae-f20e769115fc", + "link": "/api/v1.1/game/715757/feed/live", + "gameType": "D", + "season": "2022", + "gameDate": "2022-10-13T19:37:00Z", + "officialDate": "2022-10-13", + "status": { + "abstractGameState": "Final", + "codedGameState": "F", + "detailedState": "Final", + "statusCode": "F", + "startTimeTBD": False, + "abstractGameCode": "F", + }, + "teams": { + "away": { + "team": {"id": 136, "name": "Seattle Mariners", "link": "/api/v1/teams/136"}, + "leagueRecord": {"wins": 0, "losses": 2, "ties": 0, "pct": ".000"}, + "score": 2, + "isWinner": False, + "splitSquad": False, + "seriesNumber": 1, + }, + "home": { + "team": {"id": 117, "name": "Houston Astros", "link": "/api/v1/teams/117"}, + "leagueRecord": {"wins": 2, "losses": 0, "ties": 0, "pct": "1.000"}, + "score": 4, + "isWinner": True, + "splitSquad": False, + "seriesNumber": 1, + }, + }, + "venue": {"id": 2392, "name": "Minute Maid Park", "link": "/api/v1/venues/2392"}, + "content": {"link": "/api/v1/game/715757/content"}, + "isTie": False, + "gameNumber": 1, + "publicFacing": True, + "doubleHeader": "N", + "gamedayType": "P", + "tiebreaker": "N", + "calendarEventID": "14-715757-2022-10-13", + "seasonDisplay": "2022", + "dayNight": "day", + "description": "ALDS Game 2", + "scheduledInnings": 9, + "reverseHomeAwayStatus": False, + "inningBreakLength": 120, + "gamesInSeries": 5, + "seriesGameNumber": 2, + "seriesDescription": "AL Division Series", + "recordSource": "S", + "ifNecessary": "N", + "ifNecessaryDescription": "Normal Game", + } + ], + } + ], +} + +GAMEPACE_PAYLOAD = { + "sports": [ + { + "hitsPer9Inn": 16.68, + "runsPer9Inn": 9.3, + "pitchesPer9Inn": 299.83, + "totalGames": 2429, + "timePerGame": "03:11:26", + "season": "2021", + "sport": {"id": 1, "code": "mlb", "link": "/api/v1/sports/1"}, + } + ] +} + STATS_PAYLOAD = { "stats": [ { @@ -831,6 +920,50 @@ def test_get_players_stats_for_game_forwards_params_on_both_clients(): ) +def test_get_persons_success_parity(): + """A successful people response parses to the same Person list on both clients.""" + result = call_both("get_persons", "660271", payload=PERSON_PAYLOAD) + + assert result.sync == [Person(**PERSON_PAYLOAD["people"][0])] + assert result.asynchronous == result.sync + assert result.request == ("GET", "/api/v1/people", {"personIds": "660271"}) + + +def test_get_scheduled_games_by_date_success_parity(): + """A successful schedule response parses to the same game list on both clients.""" + result = call_both( + "get_scheduled_games_by_date", "2022-10-13", payload=SCHEDULED_GAMES_PAYLOAD + ) + + assert [game.game_pk for game in result.sync] == [715757] + assert result.asynchronous == result.sync + assert result.request == ( + "GET", + "/api/v1/schedule", + {"date": "2022-10-13", "sportId": "1"}, + ) + + +def test_get_gamepace_success_parity(): + """A successful gamePace response parses to the same GamePace on both clients. + + The season is the part that matters here. Mlb embeds it in the endpoint + string and relies on Requests merging that query with ep_params; HTTPX + replaces rather than merges, so AsyncMlb passes it as a param instead. + Asserting one shared request signature pins that the two routes converge. + """ + result = call_both("get_gamepace", "2021", payload=GAMEPACE_PAYLOAD) + + assert isinstance(result.sync, GamePace), "sync get_gamepace did not return a GamePace" + assert result.sync.sports[0].season == "2021" + assert result.asynchronous == result.sync + assert result.request == ( + "GET", + "/api/v1/gamePace", + {"season": "2021", "sportId": "1"}, + ) + + def test_get_team_id_success_parity(): """A matching name is resolved to the same id list on both clients.""" result = call_both( @@ -1184,6 +1317,54 @@ def test_stat_endpoint_no_result_parity(method, args, label): ) +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_persons_no_result_parity(label): + """Every no-result response returns an empty list on either client.""" + result = call_both("get_persons", "1", **NO_RESULT_RESPONSES[label]) + + assert result.sync == [], f"sync get_persons returned {result.sync!r} for {label}" + assert result.asynchronous == [], ( + f"async get_persons returned {result.asynchronous!r} for {label}" + ) + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_scheduled_games_by_date_no_result_parity(label): + """Every no-result response returns an empty list on either client.""" + result = call_both( + "get_scheduled_games_by_date", "2022-10-13", **NO_RESULT_RESPONSES[label] + ) + + assert result.sync == [], ( + f"sync get_scheduled_games_by_date returned {result.sync!r} for {label}" + ) + assert result.asynchronous == [], ( + f"async get_scheduled_games_by_date returned {result.asynchronous!r} for {label}" + ) + + +def test_get_scheduled_games_by_date_without_a_selector_parity(): + """Both clients return None -- not [] -- when nothing selects a date. + + The annotation promises list[ScheduleGames]. Mlb returns a bare None here + and AsyncMlb preserves that rather than quietly correcting it, so the two + stay interchangeable. + """ + assert call_sync("get_scheduled_games_by_date") is None + assert call_async("get_scheduled_games_by_date") is None + + +@pytest.mark.parametrize("label", list(NO_RESULT_RESPONSES)) +def test_get_gamepace_no_result_parity(label): + """Every no-result response returns None on either client.""" + result = call_both("get_gamepace", "2021", **NO_RESULT_RESPONSES[label]) + + assert result.sync is None, f"sync get_gamepace returned {result.sync!r} for {label}" + assert result.asynchronous is None, ( + f"async get_gamepace returned {result.asynchronous!r} for {label}" + ) + + def test_get_homerun_derby_malformed_error_body_parity(): """Regression coverage: the bare-None-instead-of-return-None bug fix. From 8b998140c5dba70b0f56a35144056578e04798b7 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sun, 23 Aug 2026 12:57:05 -0700 Subject: [PATCH 54/81] docs: add current async usage guide --- docs/async.md | 153 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 docs/async.md diff --git a/docs/async.md b/docs/async.md new file mode 100644 index 00000000..fc59625b --- /dev/null +++ b/docs/async.md @@ -0,0 +1,153 @@ +# Async Usage + +`AsyncMlb` is the public asynchronous client for `python-mlb-statsapi` 1.1. +It requires the optional `async` extra. + +## Installation + +```bash +python3 -m pip install "python-mlb-statsapi[async]" +``` + +A synchronous-only install remains unchanged and does not require HTTPX. + +## Quick start + +```python +import asyncio + +from mlbstatsapi import AsyncMlb + + +async def main(): + async with AsyncMlb() as mlb: + player = await mlb.get_person(664034) + team = await mlb.get_team(136) + + print(player.full_name) + print(team.name) + + +asyncio.run(main()) +``` + +Use `async with` when possible so library-owned HTTP resources are closed when +the block exits. If a context manager is not practical, explicit cleanup is +also supported: + +```python +mlb = AsyncMlb() +try: + player = await mlb.get_person(664034) +finally: + await mlb.aclose() +``` + +Repeated `aclose()` calls are safe. + +## Concurrent requests + +One `AsyncMlb` instance supports concurrent in-flight requests on the same +event loop. Concurrency is controlled by the caller. + +```python +import asyncio + +from mlbstatsapi import AsyncMlb + + +async def main(): + async with AsyncMlb() as mlb: + player, team = await asyncio.gather( + mlb.get_person(664034), + mlb.get_team(136), + ) + + return player, team + + +player, team = asyncio.run(main()) +``` + +`AsyncMlb` does not create hidden background tasks or automatic request fanout. +Cross-event-loop use of the same client is not promised. + +## Supported endpoints + +The async surface is intentionally smaller than the synchronous `Mlb` surface +while 1.1 support is being expanded. The currently supported awaitable endpoint +methods on `release/1.1.0` are: + +```text +get_team(...) +get_teams(...) +get_person(...) +get_people(...) +get_schedule(...) +``` + +Where an async endpoint is supported, it returns the same Pydantic model types +and follows the same public HTTP/error behavior as the matching synchronous +method. + +For the authoritative list and signatures, see the +[public API contract](public-api.md#asyncmlb-public-client). + +## Error handling + +The public exception hierarchy is shared with the synchronous client: + +```python +from mlbstatsapi import ( + AsyncMlb, + MlbDecodeError, + MlbHttpError, + MlbTimeoutError, + MlbTransportError, +) + + +async def get_player(): + try: + async with AsyncMlb() as mlb: + return await mlb.get_person(664034) + except MlbTimeoutError: + print("The MLB API timed out") + except MlbTransportError: + print("The request could not reach the MLB API") + except MlbHttpError as exc: + print(exc.status_code, exc.reason) + except MlbDecodeError: + print("The MLB API returned invalid JSON") +``` + +`strict_http=True` is the default. Existing endpoint-specific 404 behavior is +preserved. See the [HTTP transport documentation](http-transport.md) for the +complete status, timeout, retry, and compatibility-mode contract. + +## Custom HTTPX client + +Advanced callers may inject their own `httpx.AsyncClient`: + +```python +import httpx + +from mlbstatsapi import AsyncMlb + + +client = httpx.AsyncClient() +try: + async with AsyncMlb(client=client) as mlb: + player = await mlb.get_person(664034) +finally: + await client.aclose() +``` + +An injected client remains caller-owned and is not closed by `AsyncMlb`. + +## Documentation boundaries + +- [README](../README.md) — installation and quick-start examples +- [Usage examples](examples.md) — longer synchronous examples +- [Public API contract](public-api.md) — supported symbols, signatures, and endpoint coverage +- [HTTP transport](http-transport.md) — timeouts, retries, errors, and compatibility behavior From faf2ab4a6adb23f1f3379057fac3d342144f97eb Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sun, 23 Aug 2026 12:57:31 -0700 Subject: [PATCH 55/81] docs: restore focused usage examples --- docs/examples.md | 173 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 docs/examples.md diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 00000000..817afd89 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,173 @@ +# Usage Examples + +This document collects the longer usage examples that previously lived in the README. The README keeps a short quick start; this guide is the extended tour. + +Every example in this file uses the synchronous `Mlb` client. Async usage is documented separately in [async.md](async.md). + +For return-object structure and endpoint details see the [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki). For the supported method list, parameters, and return shapes see the [public API contract](public-api.md). For transport behavior see the [HTTP transport documentation](http-transport.md). + +## Working with Pydantic Models + +All returned objects are Pydantic models, giving you access to serialization and validation helpers. + +```python +from mlbstatsapi import Mlb + +with Mlb() as mlb: + player = mlb.get_person(664034) + +print(player.full_name) +print(player.model_dump(exclude_none=True)) +print(player.model_dump_json(indent=2)) +``` + +## Players and teams + +```python +from mlbstatsapi import Mlb + +with Mlb() as mlb: + player_id = mlb.get_people_id("Ty France")[0] + team_id = mlb.get_team_id("Seattle Mariners")[0] + + player = mlb.get_person(player_id) + team = mlb.get_team(team_id) + +print(player.full_name) +print(team.name) +``` + +## Player stats + +```python +from mlbstatsapi import Mlb + +with Mlb() as mlb: + player_id = mlb.get_people_id("Ty France")[0] + stats = mlb.get_player_stats( + player_id, + stats=["season", "career"], + groups=["hitting", "pitching"], + season=2022, + ) + +season_hitting = stats["hitting"]["season"] +for split in season_hitting.splits: + print(split.stat.model_dump(exclude_none=True)) +``` + +## Team stats + +```python +from mlbstatsapi import Mlb + +with Mlb() as mlb: + team_id = mlb.get_team_id("Seattle Mariners")[0] + stats = mlb.get_team_stats( + team_id, + stats=["season", "seasonAdvanced"], + groups=["hitting"], + season=2022, + ) + +season_hitting = stats["hitting"]["season"] +for split in season_hitting.splits: + print(split.stat.model_dump_json(indent=2, exclude_none=True)) +``` + +## Schedule + +```python +from mlbstatsapi import Mlb + +with Mlb() as mlb: + schedule = mlb.get_schedule(date="2022-10-13") + +for date in schedule.dates: + for game in date.games: + print(game.game_pk, game.status.detailed_state) +``` + +## Game data + +```python +from mlbstatsapi import Mlb + +with Mlb() as mlb: + game = mlb.get_game(662242) + play_by_play = mlb.get_game_play_by_play(662242) + line_score = mlb.get_game_line_score(662242) + box_score = mlb.get_game_box_score(662242) +``` + +## Rosters + +```python +from mlbstatsapi import Mlb + +with Mlb() as mlb: + players = mlb.get_team_roster(136) + coaches = mlb.get_team_coaches(136) + +for player in players: + print(f"#{player.jersey_number} {player.person.full_name}") + +for coach in coaches: + print(f"{coach.person.full_name}: {coach.title}") +``` + +## Draft + +```python +from mlbstatsapi import Mlb + +with Mlb() as mlb: + draft = mlb.get_draft("2019") + +for pick in draft[0].picks: + print(f"Round {pick.pick_round}, Pick {pick.pick_number}: {pick.person.full_name}") +``` + +## Awards + +```python +from mlbstatsapi import Mlb + +with Mlb() as mlb: + retired_numbers = mlb.get_awards(award_id="RETIREDUNI_108") + +for recipient in retired_numbers.awards: + print(f"{recipient.player.full_name}: {recipient.name} ({recipient.date})") +``` + +## Venue, division, league, and season + +```python +from mlbstatsapi import Mlb + +with Mlb() as mlb: + venue_id = mlb.get_venue_id("PNC Park")[0] + venue = mlb.get_venue(venue_id) + division = mlb.get_division(200) + league = mlb.get_league(103) + season = mlb.get_season(2018) + +print(venue.name) +print(division.name) +print(league.name) +print(season.season_id) +``` + +## Standings + +```python +from mlbstatsapi import Mlb + +with Mlb() as mlb: + standings = mlb.get_standings(103, 2018) + +for record in standings: + print(f"Division: {record.division.name}") + for team in record.team_records: + print(f" {team.team.name}: {team.wins}-{team.losses}") +``` From a8b38e61fe6855199779d2ca71edbc9d63a49346 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sun, 23 Aug 2026 12:58:19 -0700 Subject: [PATCH 56/81] docs: rebase README refactor onto 1.1 async surface --- README.md | 843 ++++++++---------------------------------------------- 1 file changed, 125 insertions(+), 718 deletions(-) diff --git a/README.md b/README.md index 141379d5..c23d18f4 100644 --- a/README.md +++ b/README.md @@ -9,833 +9,240 @@ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/python-mlb-statsapi) ![GitHub](https://img.shields.io/github/license/zero-sum-seattle/python-mlb-statsapi) -
+### [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) | [Examples](docs/examples.md) | [Async](docs/async.md) | [Public API](docs/public-api.md) | [MLB Stats API](https://statsapi.mlb.com/) -### *Copyright Notice* -This package and its authors are not affiliated with MLB or any MLB team. This API wrapper interfaces with MLB's Stats API. Use of MLB data is subject to the notice posted at http://gdx.mlb.com/components/copyright.txt. +
-###### This is an educational project - Not for commercial use. +`python-mlb-statsapi` provides Python access to the MLB Stats API for teams, players, schedules, games, stats, and more. +Returned objects are built with [Pydantic](https://docs.pydantic.dev/), and model fields use Python `snake_case` names. -![MLB Stats API](https://user-images.githubusercontent.com/2068393/203456246-dfdbdf0f-1e43-4329-aaa9-1c4008f9800d.jpg) +Version 1.1 adds first-class async support through `AsyncMlb` while keeping the existing synchronous `Mlb` API available without code changes for sync users. -## Getting Started +### Copyright Notice -*Python-mlb-statsapi* is a Python library that provides access to the MLB Stats API, allowing developers to retrieve information related to MLB teams, players, stats, and more. Written in Python 3.10+. +This package and its authors are not affiliated with MLB or any MLB team. This API wrapper interfaces with MLB's Stats API. Use of MLB data is subject to the notice posted at http://gdx.mlb.com/components/copyright.txt. -All models are built with [Pydantic](https://docs.pydantic.dev/) for robust data validation and serialization. Field names follow Python's `snake_case` convention for a more Pythonic experience. +###### This is an educational project - Not for commercial use. -For detailed documentation, check out the [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) which contains information on return objects, endpoint structure, usage examples, and more. +![MLB Stats API](https://user-images.githubusercontent.com/2068393/203456246-dfdbdf0f-1e43-4329-aaa9-1c4008f9800d.jpg) +## Installation -
+### Synchronous client + +```bash +python3 -m pip install python-mlb-statsapi +``` -### [Examples](#examples) | [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) | [API](https://statsapi.mlb.com/) +### Async support -
+Install the optional `async` extra to use `AsyncMlb` and `AsyncMlbDataAdapter`: -## Installation ```bash -python3 -m pip install python-mlb-statsapi +python3 -m pip install "python-mlb-statsapi[async]" ``` -### Python support +The async extra installs HTTPX. Python 3.10 or newer is required. | Claim | Value | | --- | --- | -| Minimum declared Python version (`Requires-Python`) | `>=3.10` | +| Minimum Python version | `>=3.10` | | CI-validated versions | 3.10, 3.11, 3.12, 3.13, 3.14 | -The minimum declared Python version is 3.10 and the CI-validated versions are -3.10 through 3.14. There is no upper Python bound. Prerelease interpreters are -excluded from the required test matrix and are not claimed as supported. +See [Python support](docs/public-api.md#python-support) for the complete policy. ## Quick Start -```python ->>> import mlbstatsapi ->>> mlb = mlbstatsapi.Mlb() - ->>> mlb.get_people_id("Ty France") -[664034] ->>> player = mlb.get_person(664034) ->>> print(player.full_name) -Ty France +### Sync ->>> stats = ['season', 'seasonAdvanced'] ->>> groups = ['hitting'] ->>> params = {'season': 2022} ->>> mlb.get_player_stats(664034, stats, groups, **params) -{'hitting': {'season': Stat, 'seasonAdvanced': Stat }} +```python +from mlbstatsapi import Mlb ->>> mlb.get_team_id("Seattle Mariners") -[136] +with Mlb() as mlb: + player = mlb.get_person(664034) + team = mlb.get_team(136) ->>> team = mlb.get_team(136) ->>> print(team.name, team.franchise_name) -Seattle Mariners Seattle +print(player.full_name) +print(team.name) ``` -## HTTP Sessions, Timeouts, Retries, and Error Behavior - -Version 0.8.0 added shared HTTP Sessions, explicit timeouts, optional Session injection, bounded retries, and structured transport exceptions. Version 0.9.0 made that transport configurable with a public retry policy, richer `MlbHttpError` context, compatibility warnings, and a versioned User-Agent. Version 1.0.0 makes strict HTTP handling the default and documents the stable public API contract. +### Async -The `Mlb` client remains synchronous. Shared Sessions pool reusable connections; they do not cache MLB response bodies, and the client does not enable response caching by default. +```python +import asyncio -For the complete reference see the [HTTP transport documentation](docs/http-transport.md). For what changed in this release see the [1.0.0 release notes](docs/releases/1.0.0.md). For the stable public API boundary see the [public API contract](docs/public-api.md). +from mlbstatsapi import AsyncMlb -### Upgrading to version 1.0 -`Mlb()` now uses strict HTTP handling by default. It is equivalent to `Mlb(strict_http=True)`. +async def main(): + async with AsyncMlb() as mlb: + player = await mlb.get_person(664034) + team = await mlb.get_team(136) -```text -Mlb() now uses strict HTTP handling by default -Final non-404 4xx responses raise MlbHttpError -404 keeps endpoint-specific None / [] / {} behavior -Final 5xx still raises MlbHttpError -Timeouts still raise MlbTimeoutError -Transport failures still raise MlbTransportError -Successful invalid JSON still raises MlbDecodeError -``` + print(player.full_name) + print(team.name) -Recommended version 1.0 usage: -```python -import mlbstatsapi - -try: - with mlbstatsapi.Mlb() as mlb: - player = mlb.get_person(664034) -except mlbstatsapi.MlbHttpError as exc: - print(exc.status_code) - print(exc.reason) - print(exc.url) +asyncio.run(main()) ``` -Temporary compatibility opt-out while migrating: +See [Async usage](docs/async.md) for lifecycle, concurrency, custom HTTPX clients, and the current async endpoint list. -```python -import mlbstatsapi +## Sync or Async? -with mlbstatsapi.Mlb(strict_http=False) as mlb: - player = mlb.get_person(664034) -``` +| | `Mlb` | `AsyncMlb` | +| --- | --- | --- | +| HTTP library | Requests | HTTPX | +| Context manager | `with Mlb()` | `async with AsyncMlb()` | +| Request | `mlb.get_team(...)` | `await mlb.get_team(...)` | +| Explicit cleanup | `mlb.close()` | `await mlb.aclose()` | -`strict_http=False` is a temporary migration opt-out and an explicit request for historical 0.9 behavior. It is not the recommended long-term 1.0 configuration. See [Migrating from 0.9.x to 1.0](docs/http-transport.md#migrating-from-09x-to-10) for the full process, warning-as-error guidance, and before-and-after examples. +Where an async endpoint is supported, both clients return the same Pydantic models and follow the same public HTTP/error behavior. -### Recommended context-manager usage +The async surface is still smaller than the full synchronous API while 1.1 coverage is expanded. The [public API contract](docs/public-api.md#asyncmlb-public-client) is the authoritative list of supported async methods. -Prefer a context manager so library-owned HTTP resources are closed when the block exits, including when the block exits because of an exception: +## Concurrent Async Requests -```python -import mlbstatsapi +`AsyncMlb` supports concurrent requests on the same event loop. Concurrency is controlled by the caller. -with mlbstatsapi.Mlb() as mlb: - player = mlb.get_person(664034) - team = mlb.get_team(136) -``` - -One `Mlb` client uses one shared `requests.Session`. The v1 and v1.1 adapters share that Session, so repeated requests can reuse pooled connections. A Session manages a pool of reusable connections; it is not one permanent network connection. +```python +import asyncio -Callers who do not use a context manager may call `mlb.close()` instead. Repeated `close()` calls are safe. Closing a client only closes a Session the library created; a caller-injected Session is left open for its owner. +from mlbstatsapi import AsyncMlb -### Compatibility mode -Callers who need historical 0.9 empty-result behavior for final non-404 4xx responses can pass `strict_http=False`. That path emits `MlbHttpCompatibilityWarning` exactly once per suppressed final response, does not change 404 handling, and does not suppress final 5xx, timeout, transport, or decode failures. +async def main(): + async with AsyncMlb() as mlb: + player, team = await asyncio.gather( + mlb.get_person(664034), + mlb.get_team(136), + ) -The category inherits from `FutureWarning`, so it stays visible under default Python warning filters. Applications can promote only this package category to an error: + return player, team -```python -import warnings -import mlbstatsapi -warnings.filterwarnings( - "error", - category=mlbstatsapi.MlbHttpCompatibilityWarning, -) +player, team = asyncio.run(main()) ``` -Filter on `mlbstatsapi.MlbHttpCompatibilityWarning` specifically rather than disabling all warnings or all `FutureWarning` instances, which would also hide unrelated notices from other libraries. Prefer removing `strict_http=False` and catching `MlbHttpError` over permanently ignoring the warning. - -### Custom timeouts - -Every request uses an explicit timeout. The defaults are: +`AsyncMlb` does not create hidden background tasks or automatic request fanout. -```text -Connection timeout: 3.05 seconds -Read timeout: 30 seconds -``` - -The read timeout is the maximum wait while reading response data. It is not one absolute total duration for the complete request. +## Common Methods -Use a scalar to apply the same value to both connect and read phases: +### Players ```python -import mlbstatsapi - -with mlbstatsapi.Mlb(timeout=10) as mlb: - player = mlb.get_person(664034) +player = mlb.get_person(664034) +players = mlb.get_people() +player_ids = mlb.get_people_id("Ty France") ``` -Or provide separate connection and read timeouts: +### Teams ```python -import mlbstatsapi - -with mlbstatsapi.Mlb( - timeout=(5.0, 60.0), -) as mlb: - player = mlb.get_person(664034) +team = mlb.get_team(136) +teams = mlb.get_teams() +team_ids = mlb.get_team_id("Seattle Mariners") ``` -```text -5.0 seconds: connection timeout -60.0 seconds: read timeout -``` - -### Injecting a custom Session - -Advanced callers may inject a caller-owned Session: +### Stats ```python -import requests -import mlbstatsapi - -session = requests.Session() -session.headers.update({ - "User-Agent": "my-baseball-project/1.0", -}) - -try: - with mlbstatsapi.Mlb(session=session) as mlb: - player = mlb.get_person(664034) -finally: - session.close() -``` - -Ownership rules: - -```text -Library-created Session - The library configures and closes it -Caller-injected Session - The caller configures and closes it +stats = mlb.get_player_stats( + 664034, + stats=["season", "career"], + groups=["hitting"], + season=2022, +) ``` -`Mlb.close()` does not close a caller-injected Session, and exiting `with Mlb(session=session)` does not close the injected Session either. The library does not replace or reconfigure adapters or headers on an injected Session. Callers control custom retry, TLS, proxy, header, and adapter configuration. - -### Reusing the retry policy on a caller-managed Session +Higher-level stats helpers remain on the synchronous `Mlb` client in the current 1.1 async surface. -`create_retry_policy()` remains public. It returns a new instance of the same tested policy the library mounts on Sessions it creates, so a caller-managed Session can opt in to identical retry behavior: +### Schedule ```python -import requests -import mlbstatsapi - -session = requests.Session() -adapter = requests.adapters.HTTPAdapter( - max_retries=mlbstatsapi.create_retry_policy(), -) -session.mount("https://", adapter) -session.mount("http://", adapter) - -try: - with mlbstatsapi.Mlb(session=session) as mlb: - player = mlb.get_person(664034) -finally: - session.close() +schedule = mlb.get_schedule(date="2022-10-13") ``` -* The caller mounts the adapters -* The caller closes the injected Session -* The library never reconfigures an injected Session +`get_schedule` is available on both `Mlb` and `AsyncMlb`. -### Versioned User-Agent +Longer runnable examples live in [docs/examples.md](docs/examples.md). -A Session created by the library sends a package-specific User-Agent: +## HTTP and Error Behavior -```text -python-mlb-statsapi/ -``` - -For this release's currently declared package metadata that resolves to `python-mlb-statsapi/1.0.1`. The version is read from the installed distribution metadata, so it always matches the installed release. Only the `User-Agent` header is set; other Requests defaults such as `Accept-Encoding` remain intact, and the header carries no identifiers beyond the package name and version. +Both clients use explicit timeouts, structured exceptions, and pooled HTTP connections. `strict_http=True` is the default. Final non-404 4xx responses raise `MlbHttpError`, while existing endpoint-specific 404 behavior is preserved. -Headers on a caller-injected Session are left untouched, so applications that set their own User-Agent keep it. +The main transport exceptions are: -### Structured exception handling +* `MlbHttpError` +* `MlbTimeoutError` +* `MlbTransportError` +* `MlbDecodeError` ```python -import mlbstatsapi +from mlbstatsapi import Mlb, MlbHttpError, MlbTimeoutError try: - with mlbstatsapi.Mlb() as mlb: + with Mlb() as mlb: player = mlb.get_person(664034) -except mlbstatsapi.MlbTimeoutError: +except MlbTimeoutError: print("The MLB API timed out") -except mlbstatsapi.MlbTransportError: - print("The request could not reach the MLB API") -except mlbstatsapi.MlbHttpError as exc: - print(exc.method) - print(exc.status_code) - print(exc.reason) - print(exc.url) - print(exc.response_data) - print(exc.body_excerpt) -except mlbstatsapi.MlbDecodeError: - print("The MLB API returned invalid JSON") +except MlbHttpError as exc: + print(exc.status_code, exc.reason) ``` -* `MlbTimeoutError` represents connection and read timeouts -* `MlbTransportError` represents other request transport failures -* `MlbHttpError` represents an unexpected final HTTP response -* `MlbDecodeError` represents invalid JSON in a successful response +For timeouts, retries, compatibility mode, ownership rules, and transport details, see [docs/http-transport.md](docs/http-transport.md). -`MlbHttpError` exposes `method`, `status_code`, `reason`, `url`, `response_data`, and `body_excerpt`. `response_data` holds the decoded JSON dictionary or list when the error body contains one, and is `None` otherwise. `body_excerpt` is a bounded excerpt of the response text, capped at 500 characters. Complete response bodies are never automatically logged, and `str(exc)` stays concise. +## Working with Models -### Backward-compatible exception handling - -All new transport exceptions inherit from `TheMlbStatsApiException`, so existing broad exception handling remains compatible: +Every returned model object uses Pydantic and Python-style `snake_case` fields: ```python -import mlbstatsapi - -try: - with mlbstatsapi.Mlb() as mlb: - player = mlb.get_person(664034) -except mlbstatsapi.TheMlbStatsApiException: - print("The MLB request failed") -``` - -### Default retry behavior - -Library-created Sessions automatically retry temporary GET failures for: - -```text -429 -500 -502 -503 -504 -``` - -```text -Initial request: 1 -Maximum retries: 3 -Maximum total attempts: 4 -Backoff factor: 0.5 -Retry-After respected: yes -``` - -Only GET requests are retried, and retries are bounded. Ordinary client errors such as 400, 401, 403, and 404 are not retried. Invalid JSON and Pydantic validation failures are not retried. Retries improve resilience for transient failures, but they do not guarantee success. The retry values are unchanged from versions 0.8.0 and 0.9.0. The version 1.0 strict default does not change retry or Session behavior. - -### Existing 404 compatibility - -Version 1.0.0 preserves existing endpoint-specific not-found behavior under both the default and `strict_http=False`. Depending on the endpoint, a 404 may still produce: - -```text -None -[] -{} -``` - -Not every 404 raises `MlbHttpError`, and the strict default does not change that. - -### HTTP behavior at a glance +from mlbstatsapi import Mlb -| Final response | Default 1.0 behavior | Explicit compatibility mode | -| -------------- | -------------------- | --------------------------- | -| Successful 2xx | Normal result | Normal result | -| Non-404 4xx | `MlbHttpError` | Warning and historical empty result | -| 404 | Existing endpoint behavior | Existing endpoint behavior | -| Final 429 | `MlbHttpError` after retries | Warning and historical empty result after retries | -| Final 5xx | `MlbHttpError` | `MlbHttpError` | - -See the [HTTP transport documentation](docs/http-transport.md) for the complete retry policy, Session ownership rules, warning behavior, cleanup behavior, and migration guidance, and the [1.0.0 release notes](docs/releases/1.0.0.md) for the release summary. - -## Working with Pydantic Models - -All returned objects are Pydantic models, giving you access to powerful serialization and validation features. - -### Convert to Dictionary -```python ->>> player = mlb.get_person(664034) ->>> player.model_dump() -{'id': 664034, 'full_name': 'Ty France', 'link': '/api/v1/people/664034', ...} - -# Exclude None values ->>> player.model_dump(exclude_none=True) -{'id': 664034, 'full_name': 'Ty France', 'link': '/api/v1/people/664034', ...} - -# Include only specific fields ->>> player.model_dump(include={'id', 'full_name', 'primary_position'}) -{'id': 664034, 'full_name': 'Ty France', 'primary_position': Position(...)} -``` - -### Convert to JSON -```python ->>> player = mlb.get_person(664034) ->>> player.model_dump_json() -'{"id": 664034, "full_name": "Ty France", "link": "/api/v1/people/664034", ...}' - -# Pretty print with indentation ->>> print(player.model_dump_json(indent=2)) -{ - "id": 664034, - "full_name": "Ty France", - "link": "/api/v1/people/664034", - ... -} -``` +with Mlb() as mlb: + player = mlb.get_person(664034) -### Access Fields with Snake Case Names -```python ->>> player = mlb.get_person(664034) ->>> player.full_name # Not fullName -'Ty France' ->>> player.primary_position # Not primaryPosition -Position(code='3', name='First Base', ...) ->>> player.bat_side # Not batSide -CodeDesc(code='R', description='Right') +print(player.full_name) # not fullName +print(player.model_dump(exclude_none=True)) +print(player.model_dump_json(indent=2)) ``` ## Documentation -### [People, Person, Players, Coaches](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-People) -* `Mlb.get_people_id(self, fullname: str, sport_id: int = 1, search_key: str = 'fullname', **params)` - Return Person Id(s) from fullname -* `Mlb.get_person(self, player_id: int, **params)` - Return Person Object from Id -* `Mlb.get_people(self, sport_id: int = 1, **params)` - Return all Players from Sport -### [Draft](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Draft(round)) -* `Mlb.get_draft(self, year_id: int, **params)` - Return a draft for a given year -### [Awards](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Award) -* `Mlb.get_awards(self, award_id: int, **params)` - Return award recipients for a given award -### [Teams](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Team) -* `Mlb.get_team_id(self, team_name: str, search_key: str = 'name', **params)` - Return Team Id(s) from name -* `Mlb.get_team(self, team_id: int, **params)` - Return Team Object from Team Id -* `Mlb.get_teams(self, sport_id: int = 1, **params)` - Return all Teams for Sport -* `Mlb.get_team_coaches(self, team_id: int, **params)` - Return coaching roster for team for current or specified season -* `Mlb.get_team_roster(self, team_id: int, **params)` - Return player roster for team for current or specified season -### [Stats](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Stats) -* `Mlb.get_player_stats(self, person_id: int, stats: list, groups: list, **params)` - Return stats by player id, stat type and groups -* `Mlb.get_team_stats(self, team_id: int, stats: list, groups: list, **params)` - Return stats by team id, stat types and groups -* `Mlb.get_stats(self, stats: list, groups: list, **params: dict)` - Return stats by stat type and group args -* `Mlb.get_players_stats_for_game(self, person_id: int, game_id: int, **params)` - Return player stats for a game -### [Gamepace](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Gamepace) -* `Mlb.get_gamepace(self, season: str, sport_id=1, **params)` - Return pace of game metrics for specific sport, league or team. -### [Venues](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Venue) -* `Mlb.get_venue_id(self, venue_name: str, search_key: str = 'name', **params)` - Return Venue Id(s) -* `Mlb.get_venue(self, venue_id: int, **params)` - Return Venue Object from venue Id -* `Mlb.get_venues(self, **params)` - Return all Venues -### [Sports](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Sport) -* `Mlb.get_sport(self, sport_id: int, **params)` - Return a Sport object from Id -* `Mlb.get_sports(self, **params)` - Return all Sports -* `Mlb.get_sport_id(self, sport_name: str, search_key: str = 'name', **params)`- Return Sport Id from name -### [Schedules](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Schedule) -* `Mlb.get_schedule(self, date: str, start_date: str, end_date: str, sport_id: int, team_id: int, **params)` - Return a Schedule -### [Divisions](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Division) -* `Mlb.get_division(self, division_id: int, **params)` - Return a Division -* `Mlb.get_divisions(self, **params)` - Return all Divisions -* `Mlb.get_division_id(self, division_name: str, search_key: str = 'name', **params)` - Return Division Id(s) from name -### [Leagues](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-League) -* `Mlb.get_league(self, league_id: int, **params)` - Return a League from Id -* `Mlb.get_leagues(self, **params)` - Return all Leagues -* `Mlb.get_league_id(self, league_name: str, search_key: str = 'name', **params)` - Return League Id(s) -### [Seasons](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Season) -* `Mlb.get_season(self, season_id: str, sport_id: int = None, **params)` - Return a season -* `Mlb.get_seasons(self, sportid: int = None, **params)` - Return all seasons -### [Standings](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Standings) -* `Mlb.get_standings(self, league_id: int, season: str, **params)` - Return standings -### [Schedules](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Schedule) -* `Mlb.get_schedule(self, date: str = None, start_date: str = None, end_date: str = None, sport_id: int = 1, team_id: int = None, **params)` - Return a Schedule from dates -* `Mlb.get_scheduled_games_by_date(self, date: str = None,start_date: str = None, end_date: str = None, sport_id: int = 1, **params)` - Return game ids from dates -### [Games](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Game) -* `Mlb.get_game(self, game_id: int, **params)` - Return the Game for a specific Game Id -* `Mlb.get_game_play_by_play(self, game_id: int, **params)` - Return Play by play data for a game -* `Mlb.get_game_line_score(self, game_id: int, **params)` - Return a Linescore for a game -* `Mlb.get_game_box_score(self, game_id: int, **params)` - Return a Boxscore for a game - +| Document | Contents | +| --- | --- | +| [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) | Endpoint reference, return objects, and model documentation | +| [Usage examples](docs/examples.md) | Extended synchronous examples | +| [Async usage](docs/async.md) | Async installation, lifecycle, concurrency, and examples | +| [HTTP transport](docs/http-transport.md) | Timeouts, retries, strict HTTP, exceptions, and ownership | +| [Public API contract](docs/public-api.md) | Supported symbols, signatures, endpoint methods, and stability policy | +| [Release notes](docs/releases/) | Release-specific changes and migration notes | ## Contributing -Contributions are welcome! Whether it's bug fixes, new features, or documentation improvements, we appreciate your help. - -### Getting Started - -1. Fork the repository -2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/python-mlb-statsapi.git` -3. Install dependencies: `poetry install` -4. Create a branch: `git checkout -b feat/your-feature` - -### Development - -Offline tests are deterministic and should run before every pull request: +Contributions, bug fixes, tests, and documentation improvements are welcome. ```bash -poetry run pytest \ - tests/ \ - --ignore=tests/external_tests +git clone https://github.com/YOUR_USERNAME/python-mlb-statsapi.git +cd python-mlb-statsapi +poetry install -E async ``` -External tests contact the live MLB API. They require internet access and are separate from normal offline CI: +Run the deterministic offline suite before a pull request: ```bash -poetry run pytest \ - tests/external_tests/ +poetry run pytest tests/ --ignore=tests/external_tests ``` -These live tests may fail because the MLB service is unavailable or because MLB changes undocumented payloads. - -Full local validation: +External tests contact the live MLB API and are separate from normal offline CI: ```bash -poetry run pytest tests/ -rm -rf dist -poetry build -python3 scripts/validate_release.py -poetry run twine check dist/* -``` - -`scripts/validate_release.py` is the same release check offline CI runs. It inspects the built wheel and source distribution, clean-installs each artifact into its own temporary virtual environment, and runs the same public-API smoke test against both installed artifacts. The smoke test verifies the declared metadata, the supported package-root imports, the strict HTTP default, explicit strict and compatibility modes, the versioned `User-Agent`, and injected-Session ownership. Every response it observes comes from an injected fake Session, so it never contacts the MLB API. - -Offline CI is the normal pull-request gate. External tests are available manually, on a weekly schedule, and before releases. - -### Pull Request Guidelines - -- Run offline tests before submitting a PR -- Use the [PR template](.github/pull_request_template.md) when creating your pull request -- Follow the branch naming convention: - - `feat/` - New features - - `fix/` - Bug fixes - - `docs/` - Documentation updates - - `refactor/` - Code improvements - -### Reporting Issues - -Found a bug or have a feature request? Please [open an issue](https://github.com/zero-sum-seattle/python-mlb-statsapi/issues/new) with: - -- A clear description of the problem or feature -- Steps to reproduce (for bugs) -- Expected vs actual behavior -- Python version and package version - - -## Examples - -Let's show some examples of getting stat objects from the API. What is baseball without stats, right? - -### Player Stats -Get the Id(s) of the players you want stats for and set stat types and groups. -```python ->>> mlb = mlbstatsapi.Mlb() ->>> player_id = mlb.get_people_id("Ty France")[0] ->>> stats = ['season', 'career'] ->>> groups = ['hitting', 'pitching'] ->>> params = {'season': 2022} -``` - -Use player id with stat types and groups to return a stats dictionary -```python ->>> stat_dict = mlb.get_player_stats(player_id, stats=stats, groups=groups, **params) ->>> season_hitting_stat = stat_dict['hitting']['season'] ->>> career_pitching_stat = stat_dict['pitching']['career'] -``` - -Print season hitting stats using Pydantic's `model_dump()` -```python ->>> for split in season_hitting_stat.splits: -... print(split.stat.model_dump(exclude_none=True)) -{'games_played': 140, 'groundouts': 163, 'airouts': 148, 'runs': 65, 'doubles': 27, ...} -``` - -Or access individual fields directly -```python ->>> for split in season_hitting_stat.splits: -... print(f"Games: {split.stat.games_played}") -... print(f"Home Runs: {split.stat.home_runs}") -... print(f"Batting Avg: {split.stat.avg}") -Games: 140 -Home Runs: 20 -Batting Avg: .274 -``` - -### Team Stats -Get the Team Id(s) -```python ->>> mlb = mlbstatsapi.Mlb() ->>> team_id = mlb.get_team_id('Seattle Mariners')[0] -``` - -Set the stat types and groups -```python ->>> stats = ['season', 'seasonAdvanced'] ->>> groups = ['hitting'] ->>> params = {'season': 2022} -``` - -Use team id and the stat types and groups to return season hitting stats -```python ->>> stats = mlb.get_team_stats(team_id, stats=stats, groups=groups, **params) ->>> season_hitting = stats['hitting']['season'] ->>> advanced_hitting = stats['hitting']['seasonAdvanced'] -``` - -Print stats as JSON -```python ->>> for split in season_hitting.splits: -... print(split.stat.model_dump_json(indent=2, exclude_none=True)) -{ - "games_played": 162, - "groundouts": 1273, - "runs": 690, - "doubles": 229, - ... -} -``` - -### Expected Stats -```python ->>> player_id = mlb.get_people_id('Ty France')[0] ->>> stats = ['expectedStatistics'] ->>> group = ['hitting'] ->>> params = {'season': 2022} - ->>> stats = mlb.get_player_stats(player_id, stats=stats, groups=group, **params) ->>> expected = stats['hitting']['expectedStatistics'] ->>> for split in expected.splits: -... print(f"Expected AVG: {split.stat.avg}") -... print(f"Expected SLG: {split.stat.slg}") -Expected AVG: .259 -Expected SLG: .394 -``` - -### vsPlayer Stats -Get pitcher and batter player Ids -```python ->>> ty_france_id = mlb.get_people_id('Ty France')[0] ->>> shohei_ohtani_id = mlb.get_people_id('Shohei Ohtani')[0] -``` - -Set stat type, stat groups, and params -```python ->>> stats = ['vsPlayer'] ->>> group = ['hitting'] ->>> params = {'opposingPlayerId': shohei_ohtani_id, 'season': 2022} -``` - -Get stats -```python ->>> stats = mlb.get_player_stats(ty_france_id, stats=stats, groups=group, **params) ->>> vs_player = stats['hitting']['vsPlayer'] ->>> for split in vs_player.splits: -... print(f"Games: {split.stat.games_played}, Hits: {split.stat.hits}") -Games: 2, Hits: 2 -``` - -### Hot/Cold Zones -```python ->>> ty_france_id = mlb.get_people_id('Ty France')[0] ->>> stats = ['hotColdZones'] ->>> hitting_group = ['hitting'] ->>> params = {'season': 2022} - ->>> hotcoldzones = mlb.get_player_stats(ty_france_id, stats=stats, groups=hitting_group, **params) ->>> zones = hotcoldzones['stats']['hotColdZones'] - ->>> for split in zones.splits: -... print(f"Stat: {split.stat.name}") -... for zone in split.stat.zones: -... print(f" Zone {zone.zone}: {zone.value}") -Stat: battingAverage - Zone 01: .226 - Zone 02: .400 - ... -``` - -### Schedule Examples -Get a schedule for a given date -```python ->>> mlb = mlbstatsapi.Mlb() ->>> schedule = mlb.get_schedule(date='2022-10-13') ->>> dates = schedule.dates - ->>> for date in dates: -... for game in date.games: -... print(f"Game: {game.game_pk}") -... print(f"Status: {game.status.detailed_state}") -... print(f"Home: {game.teams.home.team.name}") -... print(f"Away: {game.teams.away.team.name}") -``` - -### Game Examples -Get a Game for a given game id -```python ->>> mlb = mlbstatsapi.Mlb() ->>> game = mlb.get_game(662242) -``` - -Get the weather for a game -```python ->>> weather = game.game_data.weather ->>> print(f"Condition: {weather.condition}") ->>> print(f"Temperature: {weather.temp}") ->>> print(f"Wind: {weather.wind}") -``` - -Get the current status of a game -```python ->>> linescore = game.live_data.linescore ->>> home_info = game.game_data.teams.home ->>> away_info = game.game_data.teams.away ->>> home_status = linescore.teams.home ->>> away_status = linescore.teams.away - ->>> print(f"Home: {home_info.franchise_name} {home_info.club_name}") ->>> print(f" Runs: {home_status.runs}, Hits: {home_status.hits}, Errors: {home_status.errors}") ->>> print(f"Away: {away_info.franchise_name} {away_info.club_name}") ->>> print(f" Runs: {away_status.runs}, Hits: {away_status.hits}, Errors: {away_status.errors}") ->>> print(f"Inning: {linescore.inning_half} {linescore.current_inning_ordinal}") -``` - -Get play by play, line score, and box score objects -```python ->>> play_by_play = game.live_data.plays ->>> line_score = game.live_data.linescore ->>> box_score = game.live_data.boxscore -``` - -#### Play by Play -Get only the play by play for a given game id -```python ->>> playbyplay = mlb.get_game_play_by_play(662242) +poetry run pytest tests/external_tests/ ``` -#### Line Score -Get only the line score for a given game id -```python ->>> linescore = mlb.get_game_line_score(662242) -``` +See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development and pull request workflow. -#### Box Score -Get only the box score for a given game id -```python ->>> boxscore = mlb.get_game_box_score(662242) -``` - -### Gamepace Examples -Get pace of game metrics for a specific season -```python ->>> mlb = mlbstatsapi.Mlb() ->>> gamepace = mlb.get_gamepace(season=2021) ->>> print(f"Hits per game: {gamepace.sports[0].sport_game_pace.hits_per_game}") -``` - -### People Examples -Get all Players for a given sport id -```python ->>> mlb = mlbstatsapi.Mlb() ->>> players = mlb.get_people(sport_id=1) ->>> for player in players: -... print(f"{player.id}: {player.full_name}") -``` - -Get a player id -```python ->>> player_id = mlb.get_people_id("Ty France") ->>> print(player_id[0]) -664034 -``` - -### Team Examples -Get a Team -```python ->>> mlb = mlbstatsapi.Mlb() ->>> team_id = mlb.get_team_id("Seattle Mariners")[0] ->>> team = mlb.get_team(team_id) ->>> print(f"{team.id}: {team.name}") ->>> print(f"Venue: {team.venue.name}") -``` - -Get a Player Roster -```python ->>> mlb = mlbstatsapi.Mlb() ->>> players = mlb.get_team_roster(136) ->>> for player in players: -... print(f"#{player.jersey_number} {player.person.full_name}") -``` +## License -Get a Coach Roster -```python ->>> mlb = mlbstatsapi.Mlb() ->>> coaches = mlb.get_team_coaches(136) ->>> for coach in coaches: -... print(f"{coach.person.full_name}: {coach.title}") -``` - -### Draft Examples -Get a draft for a year -```python ->>> mlb = mlbstatsapi.Mlb() ->>> draft = mlb.get_draft('2019') -``` - -Get Players from Draft -```python ->>> draftpicks = draft[0].picks ->>> for pick in draftpicks: -... print(f"Round {pick.pick_round}, Pick {pick.pick_number}: {pick.person.full_name}") -``` - -### Award Examples -Get awards for a given award id -```python ->>> mlb = mlbstatsapi.Mlb() ->>> retired_numbers = mlb.get_awards(award_id='RETIREDUNI_108') ->>> for recipient in retired_numbers.awards: -... print(f"{recipient.player.full_name}: {recipient.name} ({recipient.date})") -``` - -### Venue Examples -Get a Venue -```python ->>> mlb = mlbstatsapi.Mlb() ->>> venue_id = mlb.get_venue_id('PNC Park')[0] ->>> venue = mlb.get_venue(venue_id) ->>> print(f"{venue.name} - {venue.location.city}, {venue.location.state}") -``` - -### Division Examples -Get a division -```python ->>> mlb = mlbstatsapi.Mlb() ->>> division = mlb.get_division(200) ->>> print(division.name) -American League West -``` - -### League Examples -Get a league -```python ->>> mlb = mlbstatsapi.Mlb() ->>> league = mlb.get_league(103) ->>> print(league.name) -American League -``` - -### Season Examples -Get a Season -```python ->>> mlb = mlbstatsapi.Mlb() ->>> season = mlb.get_season(2018) ->>> print(f"Season: {season.season_id}") ->>> print(f"Regular Season: {season.regular_season_start_date} to {season.regular_season_end_date}") -``` - -### Standings Examples -Get Standings -```python ->>> mlb = mlbstatsapi.Mlb() ->>> standings = mlb.get_standings(103, 2018) ->>> for record in standings: -... print(f"Division: {record.division.name}") -... for team in record.team_records: -... print(f" {team.team.name}: {team.wins}-{team.losses}") -``` +Released under the [MIT License](LICENSE). From 6992c36ddc6f52673ea5f44aba4f96a2b13f6223 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sun, 23 Aug 2026 12:58:39 -0700 Subject: [PATCH 57/81] docs: restore contributor workflow after rebase --- CONTRIBUTING.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 73cc3890..aaeacb10 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,11 +19,60 @@ Pull requests are the best way to propose changes to the codebase. We actively w 4. Ensure the test suite passes. 5. Issue that pull request! +## Development + +Install dependencies: + +```bash +poetry install -E async +``` + +Offline tests are deterministic and should run before every pull request: + +```bash +poetry run pytest \ + tests/ \ + --ignore=tests/external_tests +``` + +External tests contact the live MLB API. They require internet access and are separate from normal offline CI: + +```bash +poetry run pytest \ + tests/external_tests/ +``` + +These live tests may fail because the MLB service is unavailable or because MLB changes undocumented payloads. + +Full local validation: + +```bash +poetry run pytest tests/ +rm -rf dist +poetry build +python3 scripts/validate_release.py +poetry run twine check dist/* +``` + +`scripts/validate_release.py` is the same release check offline CI runs. It inspects the built wheel and source distribution, clean-installs each artifact into its own temporary virtual environment, and runs the same public-API smoke test against both installed artifacts. Every response it observes comes from injected fake HTTP clients, so it never contacts the MLB API. + +Offline CI is the normal pull-request gate. External tests are available manually, on a weekly schedule, and before releases. + +## Pull Request Guidelines + +- Run offline tests before submitting a PR +- Use the [PR template](.github/pull_request_template.md) when creating your pull request +- Follow the branch naming convention: + - `feat/` - New features + - `fix/` - Bug fixes + - `docs/` - Documentation updates + - `refactor/` - Code improvements + ## Any contributions you make will be under the MIT Software License In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. ## Report bugs using Github's [issues](https://github.com/zero-sum-seattle/python-mlb-statsapi/issues) -We use GitHub issues to track public bugs. Report a bug by [opening a new issue](); it's that easy! +We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/zero-sum-seattle/python-mlb-statsapi/issues/new). ## Write bug reports with detail, background, and sample code **Great Bug Reports** tend to have: @@ -37,7 +86,7 @@ We use GitHub issues to track public bugs. Report a bug by [opening a new issue] - Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) ## Use a Consistent Coding Style -* Adhere to this projects coding style +* Adhere to this project's coding style ## License -By contributing, you agree that your contributions will be licensed under its MIT License. \ No newline at end of file +By contributing, you agree that your contributions will be licensed under its MIT License. From 0ded087b03b38dd4fef3811f52dae44e9247d3e5 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Sun, 23 Aug 2026 14:14:25 -0700 Subject: [PATCH 58/81] docs: restore method reference from README --- README.md | 5 ++- docs/methods.md | 112 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 docs/methods.md diff --git a/README.md b/README.md index c23d18f4..4b18e792 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/python-mlb-statsapi) ![GitHub](https://img.shields.io/github/license/zero-sum-seattle/python-mlb-statsapi) -### [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) | [Examples](docs/examples.md) | [Async](docs/async.md) | [Public API](docs/public-api.md) | [MLB Stats API](https://statsapi.mlb.com/) +### [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) | [Methods](docs/methods.md) | [Examples](docs/examples.md) | [Async](docs/async.md) | [Public API](docs/public-api.md) | [MLB Stats API](https://statsapi.mlb.com/)
@@ -166,7 +166,7 @@ schedule = mlb.get_schedule(date="2022-10-13") `get_schedule` is available on both `Mlb` and `AsyncMlb`. -Longer runnable examples live in [docs/examples.md](docs/examples.md). +See the [method reference](docs/methods.md) for the full method documentation that previously lived in the README. Longer runnable examples live in [docs/examples.md](docs/examples.md). ## HTTP and Error Behavior @@ -213,6 +213,7 @@ print(player.model_dump_json(indent=2)) | Document | Contents | | --- | --- | | [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) | Endpoint reference, return objects, and model documentation | +| [Method reference](docs/methods.md) | Method signatures and short descriptions from the original README reference | | [Usage examples](docs/examples.md) | Extended synchronous examples | | [Async usage](docs/async.md) | Async installation, lifecycle, concurrency, and examples | | [HTTP transport](docs/http-transport.md) | Timeouts, retries, strict HTTP, exceptions, and ownership | diff --git a/docs/methods.md b/docs/methods.md new file mode 100644 index 00000000..1b33569b --- /dev/null +++ b/docs/methods.md @@ -0,0 +1,112 @@ +# Method Reference + +This page contains the method reference that previously lived in the README. + +For detailed return-object and model documentation, follow the linked Wiki pages. For the stable 1.x public API contract and current async endpoint coverage, see [public-api.md](public-api.md). + +## People, Person, Players, Coaches + +[Wiki: People](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-People) + +* `Mlb.get_people_id(self, fullname: str, sport_id: int = 1, search_key: str = 'fullname', **params)` - Return Person Id(s) from fullname +* `Mlb.get_person(self, player_id: int, **params)` - Return Person Object from Id +* `Mlb.get_people(self, sport_id: int = 1, **params)` - Return all Players from Sport + +## Draft + +[Wiki: Draft](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Draft(round)) + +* `Mlb.get_draft(self, year_id: int, **params)` - Return a draft for a given year + +## Awards + +[Wiki: Award](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Award) + +* `Mlb.get_awards(self, award_id: int, **params)` - Return award recipients for a given award + +## Teams + +[Wiki: Team](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Team) + +* `Mlb.get_team_id(self, team_name: str, search_key: str = 'name', **params)` - Return Team Id(s) from name +* `Mlb.get_team(self, team_id: int, **params)` - Return Team Object from Team Id +* `Mlb.get_teams(self, sport_id: int = 1, **params)` - Return all Teams for Sport +* `Mlb.get_team_coaches(self, team_id: int, **params)` - Return coaching roster for team for current or specified season +* `Mlb.get_team_roster(self, team_id: int, **params)` - Return player roster for team for current or specified season + +## Stats + +[Wiki: Stats](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Stats) + +* `Mlb.get_player_stats(self, person_id: int, stats: list, groups: list, **params)` - Return stats by player id, stat type and groups +* `Mlb.get_team_stats(self, team_id: int, stats: list, groups: list, **params)` - Return stats by team id, stat types and groups +* `Mlb.get_stats(self, stats: list, groups: list, **params: dict)` - Return stats by stat type and group args +* `Mlb.get_players_stats_for_game(self, person_id: int, game_id: int, **params)` - Return player stats for a game + +## Gamepace + +[Wiki: Gamepace](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Gamepace) + +* `Mlb.get_gamepace(self, season: str, sport_id=1, **params)` - Return pace of game metrics for specific sport, league or team. + +## Venues + +[Wiki: Venue](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Venue) + +* `Mlb.get_venue_id(self, venue_name: str, search_key: str = 'name', **params)` - Return Venue Id(s) +* `Mlb.get_venue(self, venue_id: int, **params)` - Return Venue Object from venue Id +* `Mlb.get_venues(self, **params)` - Return all Venues + +## Sports + +[Wiki: Sport](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Sport) + +* `Mlb.get_sport(self, sport_id: int, **params)` - Return a Sport object from Id +* `Mlb.get_sports(self, **params)` - Return all Sports +* `Mlb.get_sport_id(self, sport_name: str, search_key: str = 'name', **params)` - Return Sport Id from name + +## Schedules + +[Wiki: Schedule](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Schedule) + +* `Mlb.get_schedule(self, date: str, start_date: str, end_date: str, sport_id: int, team_id: int, **params)` - Return a Schedule +* `Mlb.get_schedule(self, date: str = None, start_date: str = None, end_date: str = None, sport_id: int = 1, team_id: int = None, **params)` - Return a Schedule from dates +* `Mlb.get_scheduled_games_by_date(self, date: str = None, start_date: str = None, end_date: str = None, sport_id: int = 1, **params)` - Return game ids from dates + +## Divisions + +[Wiki: Division](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Division) + +* `Mlb.get_division(self, division_id: int, **params)` - Return a Division +* `Mlb.get_divisions(self, **params)` - Return all Divisions +* `Mlb.get_division_id(self, division_name: str, search_key: str = 'name', **params)` - Return Division Id(s) from name + +## Leagues + +[Wiki: League](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-League) + +* `Mlb.get_league(self, league_id: int, **params)` - Return a League from Id +* `Mlb.get_leagues(self, **params)` - Return all Leagues +* `Mlb.get_league_id(self, league_name: str, search_key: str = 'name', **params)` - Return League Id(s) + +## Seasons + +[Wiki: Season](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Season) + +* `Mlb.get_season(self, season_id: str, sport_id: int = None, **params)` - Return a season +* `Mlb.get_seasons(self, sportid: int = None, **params)` - Return all seasons + +## Standings + +[Wiki: Standings](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Standings) + +* `Mlb.get_standings(self, league_id: int, season: str, **params)` - Return standings + +## Games + +[Wiki: Game](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Game) + +* `Mlb.get_game(self, game_id: int, **params)` - Return the Game for a specific Game Id +* `Mlb.get_game_play_by_play(self, game_id: int, **params)` - Return Play by play data for a game +* `Mlb.get_game_line_score(self, game_id: int, **params)` - Return a Linescore for a game +* `Mlb.get_game_box_score(self, game_id: int, **params)` - Return a Boxscore for a game From 55ed8f9df1ca375b40a5c156e7a52f58c22911a3 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Mon, 24 Aug 2026 17:53:30 -0700 Subject: [PATCH 59/81] docs: add explicit client cleanup examples --- README.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/README.md b/README.md index 4b18e792..0f1502ee 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,49 @@ async def main(): print(team.name) +asyncio.run(main()) +``` + +### Without a context manager + +Context managers are recommended, but both clients can also be created directly. When doing that, close library-owned HTTP resources explicitly. + +#### Sync + +```python +from mlbstatsapi import Mlb + +mlb = Mlb() +try: + player = mlb.get_person(664034) + team = mlb.get_team(136) + + print(player.full_name) + print(team.name) +finally: + mlb.close() +``` + +#### Async + +```python +import asyncio + +from mlbstatsapi import AsyncMlb + + +async def main(): + mlb = AsyncMlb() + try: + player = await mlb.get_person(664034) + team = await mlb.get_team(136) + + print(player.full_name) + print(team.name) + finally: + await mlb.aclose() + + asyncio.run(main()) ``` From 23b0e55648f3caa3c3318d42d6a879e5ecd79856 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Mon, 24 Aug 2026 17:53:50 -0700 Subject: [PATCH 60/81] docs: make async explicit cleanup example runnable --- docs/async.md | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/docs/async.md b/docs/async.md index fc59625b..85eefa89 100644 --- a/docs/async.md +++ b/docs/async.md @@ -32,15 +32,32 @@ asyncio.run(main()) ``` Use `async with` when possible so library-owned HTTP resources are closed when -the block exits. If a context manager is not practical, explicit cleanup is -also supported: +the block exits. + +## Without a context manager + +If a context manager is not practical, create `AsyncMlb` directly and call +`await mlb.aclose()` when finished: ```python -mlb = AsyncMlb() -try: - player = await mlb.get_person(664034) -finally: - await mlb.aclose() +import asyncio + +from mlbstatsapi import AsyncMlb + + +async def main(): + mlb = AsyncMlb() + try: + player = await mlb.get_person(664034) + team = await mlb.get_team(136) + + print(player.full_name) + print(team.name) + finally: + await mlb.aclose() + + +asyncio.run(main()) ``` Repeated `aclose()` calls are safe. From 09870301401e913f8cfd7e757bd64a23b14d58b9 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Mon, 24 Aug 2026 17:54:12 -0700 Subject: [PATCH 61/81] docs: add sync explicit cleanup example --- docs/examples.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/examples.md b/docs/examples.md index 817afd89..6c2ce5fa 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -6,6 +6,24 @@ Every example in this file uses the synchronous `Mlb` client. Async usage is doc For return-object structure and endpoint details see the [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki). For the supported method list, parameters, and return shapes see the [public API contract](public-api.md). For transport behavior see the [HTTP transport documentation](http-transport.md). +## Without a context manager + +A context manager is recommended, but `Mlb` can also be created directly. Call `mlb.close()` when finished so library-owned HTTP resources are released. + +```python +from mlbstatsapi import Mlb + +mlb = Mlb() +try: + player = mlb.get_person(664034) + team = mlb.get_team(136) + + print(player.full_name) + print(team.name) +finally: + mlb.close() +``` + ## Working with Pydantic Models All returned objects are Pydantic models, giving you access to serialization and validation helpers. From a6c92cedf373f45cbeefe38eebd3232a806a55da Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Mon, 24 Aug 2026 18:02:04 -0700 Subject: [PATCH 62/81] docs: clean up method reference formatting --- docs/methods.md | 97 ++++++++++++++++++++++++++++++------------------- 1 file changed, 60 insertions(+), 37 deletions(-) diff --git a/docs/methods.md b/docs/methods.md index 1b33569b..7a17286a 100644 --- a/docs/methods.md +++ b/docs/methods.md @@ -8,105 +8,128 @@ For detailed return-object and model documentation, follow the linked Wiki pages [Wiki: People](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-People) -* `Mlb.get_people_id(self, fullname: str, sport_id: int = 1, search_key: str = 'fullname', **params)` - Return Person Id(s) from fullname -* `Mlb.get_person(self, player_id: int, **params)` - Return Person Object from Id -* `Mlb.get_people(self, sport_id: int = 1, **params)` - Return all Players from Sport +`Mlb.get_people_id(self, fullname: str, sport_id: int = 1, search_key: str = 'fullname', **params)` - Return Person Id(s) from fullname + +`Mlb.get_person(self, player_id: int, **params)` - Return Person Object from Id + +`Mlb.get_people(self, sport_id: int = 1, **params)` - Return all Players from Sport ## Draft [Wiki: Draft](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Draft(round)) -* `Mlb.get_draft(self, year_id: int, **params)` - Return a draft for a given year +`Mlb.get_draft(self, year_id: int, **params)` - Return a draft for a given year ## Awards [Wiki: Award](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Award) -* `Mlb.get_awards(self, award_id: int, **params)` - Return award recipients for a given award +`Mlb.get_awards(self, award_id: int, **params)` - Return award recipients for a given award ## Teams [Wiki: Team](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Team) -* `Mlb.get_team_id(self, team_name: str, search_key: str = 'name', **params)` - Return Team Id(s) from name -* `Mlb.get_team(self, team_id: int, **params)` - Return Team Object from Team Id -* `Mlb.get_teams(self, sport_id: int = 1, **params)` - Return all Teams for Sport -* `Mlb.get_team_coaches(self, team_id: int, **params)` - Return coaching roster for team for current or specified season -* `Mlb.get_team_roster(self, team_id: int, **params)` - Return player roster for team for current or specified season +`Mlb.get_team_id(self, team_name: str, search_key: str = 'name', **params)` - Return Team Id(s) from name + +`Mlb.get_team(self, team_id: int, **params)` - Return Team Object from Team Id + +`Mlb.get_teams(self, sport_id: int = 1, **params)` - Return all Teams for Sport + +`Mlb.get_team_coaches(self, team_id: int, **params)` - Return coaching roster for team for current or specified season + +`Mlb.get_team_roster(self, team_id: int, **params)` - Return player roster for team for current or specified season ## Stats [Wiki: Stats](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Stats) -* `Mlb.get_player_stats(self, person_id: int, stats: list, groups: list, **params)` - Return stats by player id, stat type and groups -* `Mlb.get_team_stats(self, team_id: int, stats: list, groups: list, **params)` - Return stats by team id, stat types and groups -* `Mlb.get_stats(self, stats: list, groups: list, **params: dict)` - Return stats by stat type and group args -* `Mlb.get_players_stats_for_game(self, person_id: int, game_id: int, **params)` - Return player stats for a game +`Mlb.get_player_stats(self, person_id: int, stats: list, groups: list, **params)` - Return stats by player id, stat type and groups + +`Mlb.get_team_stats(self, team_id: int, stats: list, groups: list, **params)` - Return stats by team id, stat types and groups + +`Mlb.get_stats(self, stats: list, groups: list, **params: dict)` - Return stats by stat type and group args + +`Mlb.get_players_stats_for_game(self, person_id: int, game_id: int, **params)` - Return player stats for a game ## Gamepace [Wiki: Gamepace](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Gamepace) -* `Mlb.get_gamepace(self, season: str, sport_id=1, **params)` - Return pace of game metrics for specific sport, league or team. +`Mlb.get_gamepace(self, season: str, sport_id=1, **params)` - Return pace of game metrics for specific sport, league or team. ## Venues [Wiki: Venue](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Venue) -* `Mlb.get_venue_id(self, venue_name: str, search_key: str = 'name', **params)` - Return Venue Id(s) -* `Mlb.get_venue(self, venue_id: int, **params)` - Return Venue Object from venue Id -* `Mlb.get_venues(self, **params)` - Return all Venues +`Mlb.get_venue_id(self, venue_name: str, search_key: str = 'name', **params)` - Return Venue Id(s) + +`Mlb.get_venue(self, venue_id: int, **params)` - Return Venue Object from venue Id + +`Mlb.get_venues(self, **params)` - Return all Venues ## Sports [Wiki: Sport](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Sport) -* `Mlb.get_sport(self, sport_id: int, **params)` - Return a Sport object from Id -* `Mlb.get_sports(self, **params)` - Return all Sports -* `Mlb.get_sport_id(self, sport_name: str, search_key: str = 'name', **params)` - Return Sport Id from name +`Mlb.get_sport(self, sport_id: int, **params)` - Return a Sport object from Id + +`Mlb.get_sports(self, **params)` - Return all Sports + +`Mlb.get_sport_id(self, sport_name: str, search_key: str = 'name', **params)` - Return Sport Id from name ## Schedules [Wiki: Schedule](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Schedule) -* `Mlb.get_schedule(self, date: str, start_date: str, end_date: str, sport_id: int, team_id: int, **params)` - Return a Schedule -* `Mlb.get_schedule(self, date: str = None, start_date: str = None, end_date: str = None, sport_id: int = 1, team_id: int = None, **params)` - Return a Schedule from dates -* `Mlb.get_scheduled_games_by_date(self, date: str = None, start_date: str = None, end_date: str = None, sport_id: int = 1, **params)` - Return game ids from dates +`Mlb.get_schedule(self, date: str, start_date: str, end_date: str, sport_id: int, team_id: int, **params)` - Return a Schedule + +`Mlb.get_schedule(self, date: str = None, start_date: str = None, end_date: str = None, sport_id: int = 1, team_id: int = None, **params)` - Return a Schedule from dates + +`Mlb.get_scheduled_games_by_date(self, date: str = None, start_date: str = None, end_date: str = None, sport_id: int = 1, **params)` - Return game ids from dates ## Divisions [Wiki: Division](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Division) -* `Mlb.get_division(self, division_id: int, **params)` - Return a Division -* `Mlb.get_divisions(self, **params)` - Return all Divisions -* `Mlb.get_division_id(self, division_name: str, search_key: str = 'name', **params)` - Return Division Id(s) from name +`Mlb.get_division(self, division_id: int, **params)` - Return a Division + +`Mlb.get_divisions(self, **params)` - Return all Divisions + +`Mlb.get_division_id(self, division_name: str, search_key: str = 'name', **params)` - Return Division Id(s) from name ## Leagues [Wiki: League](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-League) -* `Mlb.get_league(self, league_id: int, **params)` - Return a League from Id -* `Mlb.get_leagues(self, **params)` - Return all Leagues -* `Mlb.get_league_id(self, league_name: str, search_key: str = 'name', **params)` - Return League Id(s) +`Mlb.get_league(self, league_id: int, **params)` - Return a League from Id + +`Mlb.get_leagues(self, **params)` - Return all Leagues + +`Mlb.get_league_id(self, league_name: str, search_key: str = 'name', **params)` - Return League Id(s) ## Seasons [Wiki: Season](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Season) -* `Mlb.get_season(self, season_id: str, sport_id: int = None, **params)` - Return a season -* `Mlb.get_seasons(self, sportid: int = None, **params)` - Return all seasons +`Mlb.get_season(self, season_id: str, sport_id: int = None, **params)` - Return a season + +`Mlb.get_seasons(self, sportid: int = None, **params)` - Return all seasons ## Standings [Wiki: Standings](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Standings) -* `Mlb.get_standings(self, league_id: int, season: str, **params)` - Return standings +`Mlb.get_standings(self, league_id: int, season: str, **params)` - Return standings ## Games [Wiki: Game](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Game) -* `Mlb.get_game(self, game_id: int, **params)` - Return the Game for a specific Game Id -* `Mlb.get_game_play_by_play(self, game_id: int, **params)` - Return Play by play data for a game -* `Mlb.get_game_line_score(self, game_id: int, **params)` - Return a Linescore for a game -* `Mlb.get_game_box_score(self, game_id: int, **params)` - Return a Boxscore for a game +`Mlb.get_game(self, game_id: int, **params)` - Return the Game for a specific Game Id + +`Mlb.get_game_play_by_play(self, game_id: int, **params)` - Return Play by play data for a game + +`Mlb.get_game_line_score(self, game_id: int, **params)` - Return a Linescore for a game + +`Mlb.get_game_box_score(self, game_id: int, **params)` - Return a Boxscore for a game From 9b8e88849af3ad0c2dce5345b631d7a7568c5fa7 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Mon, 24 Aug 2026 18:13:51 -0700 Subject: [PATCH 63/81] docs: make method reference easier to scan --- docs/methods.md | 217 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 151 insertions(+), 66 deletions(-) diff --git a/docs/methods.md b/docs/methods.md index 7a17286a..51c5d3ac 100644 --- a/docs/methods.md +++ b/docs/methods.md @@ -4,132 +4,217 @@ This page contains the method reference that previously lived in the README. For detailed return-object and model documentation, follow the linked Wiki pages. For the stable 1.x public API contract and current async endpoint coverage, see [public-api.md](public-api.md). +**Jump to:** [People](#people-person-players-coaches) · [Teams](#teams) · [Stats](#stats) · [Games](#games) · [Schedules](#schedules) · [Venues](#venues) · [Sports](#sports) · [Leagues](#leagues) · [Divisions](#divisions) · [Seasons](#seasons) · [Standings](#standings) · [Draft](#draft) · [Awards](#awards) · [Gamepace](#gamepace) + ## People, Person, Players, Coaches [Wiki: People](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-People) -`Mlb.get_people_id(self, fullname: str, sport_id: int = 1, search_key: str = 'fullname', **params)` - Return Person Id(s) from fullname - -`Mlb.get_person(self, player_id: int, **params)` - Return Person Object from Id - -`Mlb.get_people(self, sport_id: int = 1, **params)` - Return all Players from Sport - -## Draft - -[Wiki: Draft](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Draft(round)) +| Method | Description | +| --- | --- | +| `get_people_id()` | Return person ID(s) from a full name | +| `get_person()` | Return a person from an ID | +| `get_people()` | Return all players for a sport | -`Mlb.get_draft(self, year_id: int, **params)` - Return a draft for a given year - -## Awards - -[Wiki: Award](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Award) - -`Mlb.get_awards(self, award_id: int, **params)` - Return award recipients for a given award +```text +Mlb.get_people_id(fullname: str, sport_id: int = 1, search_key: str = 'fullname', **params) +Mlb.get_person(player_id: int, **params) +Mlb.get_people(sport_id: int = 1, **params) +``` ## Teams [Wiki: Team](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Team) -`Mlb.get_team_id(self, team_name: str, search_key: str = 'name', **params)` - Return Team Id(s) from name - -`Mlb.get_team(self, team_id: int, **params)` - Return Team Object from Team Id +| Method | Description | +| --- | --- | +| `get_team_id()` | Return team ID(s) from a name | +| `get_team()` | Return a team from a team ID | +| `get_teams()` | Return all teams for a sport | +| `get_team_coaches()` | Return the coaching roster for a team | +| `get_team_roster()` | Return the player roster for a team | + +```text +Mlb.get_team_id(team_name: str, search_key: str = 'name', **params) +Mlb.get_team(team_id: int, **params) +Mlb.get_teams(sport_id: int = 1, **params) +Mlb.get_team_coaches(team_id: int, **params) +Mlb.get_team_roster(team_id: int, **params) +``` -`Mlb.get_teams(self, sport_id: int = 1, **params)` - Return all Teams for Sport +## Stats -`Mlb.get_team_coaches(self, team_id: int, **params)` - Return coaching roster for team for current or specified season +[Wiki: Stats](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Stats) -`Mlb.get_team_roster(self, team_id: int, **params)` - Return player roster for team for current or specified season +| Method | Description | +| --- | --- | +| `get_player_stats()` | Return stats for a player | +| `get_team_stats()` | Return stats for a team | +| `get_stats()` | Return stats by stat type and group | +| `get_players_stats_for_game()` | Return player stats for a game | -## Stats +```text +Mlb.get_player_stats(person_id: int, stats: list, groups: list, **params) +Mlb.get_team_stats(team_id: int, stats: list, groups: list, **params) +Mlb.get_stats(stats: list, groups: list, **params: dict) +Mlb.get_players_stats_for_game(person_id: int, game_id: int, **params) +``` -[Wiki: Stats](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Stats) +## Games -`Mlb.get_player_stats(self, person_id: int, stats: list, groups: list, **params)` - Return stats by player id, stat type and groups +[Wiki: Game](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Game) -`Mlb.get_team_stats(self, team_id: int, stats: list, groups: list, **params)` - Return stats by team id, stat types and groups +| Method | Description | +| --- | --- | +| `get_game()` | Return a game for a game ID | +| `get_game_play_by_play()` | Return play-by-play data for a game | +| `get_game_line_score()` | Return a linescore for a game | +| `get_game_box_score()` | Return a boxscore for a game | -`Mlb.get_stats(self, stats: list, groups: list, **params: dict)` - Return stats by stat type and group args +```text +Mlb.get_game(game_id: int, **params) +Mlb.get_game_play_by_play(game_id: int, **params) +Mlb.get_game_line_score(game_id: int, **params) +Mlb.get_game_box_score(game_id: int, **params) +``` -`Mlb.get_players_stats_for_game(self, person_id: int, game_id: int, **params)` - Return player stats for a game +## Schedules -## Gamepace +[Wiki: Schedule](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Schedule) -[Wiki: Gamepace](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Gamepace) +| Method | Description | +| --- | --- | +| `get_schedule()` | Return a schedule from a date or date range | +| `get_scheduled_games_by_date()` | Return scheduled games from dates | -`Mlb.get_gamepace(self, season: str, sport_id=1, **params)` - Return pace of game metrics for specific sport, league or team. +```text +Mlb.get_schedule(date: str, start_date: str, end_date: str, sport_id: int, team_id: int, **params) +Mlb.get_schedule(date: str = None, start_date: str = None, end_date: str = None, sport_id: int = 1, team_id: int = None, **params) +Mlb.get_scheduled_games_by_date(date: str = None, start_date: str = None, end_date: str = None, sport_id: int = 1, **params) +``` ## Venues [Wiki: Venue](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Venue) -`Mlb.get_venue_id(self, venue_name: str, search_key: str = 'name', **params)` - Return Venue Id(s) - -`Mlb.get_venue(self, venue_id: int, **params)` - Return Venue Object from venue Id +| Method | Description | +| --- | --- | +| `get_venue_id()` | Return venue ID(s) from a name | +| `get_venue()` | Return a venue from an ID | +| `get_venues()` | Return all venues | -`Mlb.get_venues(self, **params)` - Return all Venues +```text +Mlb.get_venue_id(venue_name: str, search_key: str = 'name', **params) +Mlb.get_venue(venue_id: int, **params) +Mlb.get_venues(**params) +``` ## Sports [Wiki: Sport](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Sport) -`Mlb.get_sport(self, sport_id: int, **params)` - Return a Sport object from Id - -`Mlb.get_sports(self, **params)` - Return all Sports +| Method | Description | +| --- | --- | +| `get_sport()` | Return a sport from an ID | +| `get_sports()` | Return all sports | +| `get_sport_id()` | Return sport ID(s) from a name | -`Mlb.get_sport_id(self, sport_name: str, search_key: str = 'name', **params)` - Return Sport Id from name +```text +Mlb.get_sport(sport_id: int, **params) +Mlb.get_sports(**params) +Mlb.get_sport_id(sport_name: str, search_key: str = 'name', **params) +``` -## Schedules - -[Wiki: Schedule](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Schedule) +## Leagues -`Mlb.get_schedule(self, date: str, start_date: str, end_date: str, sport_id: int, team_id: int, **params)` - Return a Schedule +[Wiki: League](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-League) -`Mlb.get_schedule(self, date: str = None, start_date: str = None, end_date: str = None, sport_id: int = 1, team_id: int = None, **params)` - Return a Schedule from dates +| Method | Description | +| --- | --- | +| `get_league()` | Return a league from an ID | +| `get_leagues()` | Return all leagues | +| `get_league_id()` | Return league ID(s) from a name | -`Mlb.get_scheduled_games_by_date(self, date: str = None, start_date: str = None, end_date: str = None, sport_id: int = 1, **params)` - Return game ids from dates +```text +Mlb.get_league(league_id: int, **params) +Mlb.get_leagues(**params) +Mlb.get_league_id(league_name: str, search_key: str = 'name', **params) +``` ## Divisions [Wiki: Division](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Division) -`Mlb.get_division(self, division_id: int, **params)` - Return a Division +| Method | Description | +| --- | --- | +| `get_division()` | Return a division from an ID | +| `get_divisions()` | Return all divisions | +| `get_division_id()` | Return division ID(s) from a name | -`Mlb.get_divisions(self, **params)` - Return all Divisions +```text +Mlb.get_division(division_id: int, **params) +Mlb.get_divisions(**params) +Mlb.get_division_id(division_name: str, search_key: str = 'name', **params) +``` -`Mlb.get_division_id(self, division_name: str, search_key: str = 'name', **params)` - Return Division Id(s) from name +## Seasons -## Leagues +[Wiki: Season](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Season) -[Wiki: League](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-League) +| Method | Description | +| --- | --- | +| `get_season()` | Return a season | +| `get_seasons()` | Return all seasons | -`Mlb.get_league(self, league_id: int, **params)` - Return a League from Id +```text +Mlb.get_season(season_id: str, sport_id: int = None, **params) +Mlb.get_seasons(sportid: int = None, **params) +``` -`Mlb.get_leagues(self, **params)` - Return all Leagues +## Standings -`Mlb.get_league_id(self, league_name: str, search_key: str = 'name', **params)` - Return League Id(s) +[Wiki: Standings](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Standings) -## Seasons +| Method | Description | +| --- | --- | +| `get_standings()` | Return standings for a league and season | -[Wiki: Season](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Season) +```text +Mlb.get_standings(league_id: int, season: str, **params) +``` + +## Draft -`Mlb.get_season(self, season_id: str, sport_id: int = None, **params)` - Return a season +[Wiki: Draft](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Draft(round)) -`Mlb.get_seasons(self, sportid: int = None, **params)` - Return all seasons +| Method | Description | +| --- | --- | +| `get_draft()` | Return a draft for a given year | -## Standings +```text +Mlb.get_draft(year_id: int, **params) +``` -[Wiki: Standings](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Standings) +## Awards -`Mlb.get_standings(self, league_id: int, season: str, **params)` - Return standings +[Wiki: Award](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Award) -## Games +| Method | Description | +| --- | --- | +| `get_awards()` | Return award recipients for an award | -[Wiki: Game](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Game) +```text +Mlb.get_awards(award_id: int, **params) +``` -`Mlb.get_game(self, game_id: int, **params)` - Return the Game for a specific Game Id +## Gamepace -`Mlb.get_game_play_by_play(self, game_id: int, **params)` - Return Play by play data for a game +[Wiki: Gamepace](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Gamepace) -`Mlb.get_game_line_score(self, game_id: int, **params)` - Return a Linescore for a game +| Method | Description | +| --- | --- | +| `get_gamepace()` | Return pace-of-game metrics for a sport, league, or team | -`Mlb.get_game_box_score(self, game_id: int, **params)` - Return a Boxscore for a game +```text +Mlb.get_gamepace(season: str, sport_id=1, **params) +``` From d416124d8be4f2eacc05d5a5becb8b422f9d7178 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Mon, 24 Aug 2026 18:23:05 -0700 Subject: [PATCH 64/81] docs: add dedicated stats usage guide --- docs/stats.md | 272 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 docs/stats.md diff --git a/docs/stats.md b/docs/stats.md new file mode 100644 index 00000000..0a301842 --- /dev/null +++ b/docs/stats.md @@ -0,0 +1,272 @@ +# Stats Guide + +The stats methods return MLB statistics grouped by **stat group** and then by **stat type**. Both `Mlb` and `AsyncMlb` return the same structure. + +## Stats methods + +| Method | Use | +| --- | --- | +| `get_player_stats()` | Stats for one player | +| `get_team_stats()` | Stats for one team | +| `get_stats()` | General stats query across the Stats API | +| `get_players_stats_for_game()` | Stats for one player in one game | + +The synchronous and asynchronous signatures match. With `AsyncMlb`, await the method call. + +## Understanding the return value + +The four stats methods return a nested dictionary: + +```text +stats[group][type] -> Stat +``` + +For example: + +```python +stats = mlb.get_player_stats( + 664034, + stats=["season"], + groups=["hitting"], + season=2022, +) + +season_hitting = stats["hitting"]["season"] +``` + +`season_hitting` is a `Stat` model. Its `splits` field contains the returned stat splits. + +```python +for split in season_hitting.splits: + print(split.stat.model_dump(exclude_none=True)) +``` + +A query can request multiple groups and stat types at once: + +```python +stats = mlb.get_player_stats( + 664034, + stats=["season", "career"], + groups=["hitting", "fielding"], + season=2022, +) + +for group_name, group_stats in stats.items(): + for stat_type, stat in group_stats.items(): + print(group_name, stat_type, stat.total_splits) +``` + +If the API response contains no usable stats, these methods return `{}`. + +## Player stats + +Use `get_player_stats()` when you know the MLB person ID and want one or more stat types for that player. + +### Sync + +```python +from mlbstatsapi import Mlb + +with Mlb() as mlb: + stats = mlb.get_player_stats( + 664034, + stats=["season", "career"], + groups=["hitting"], + season=2022, + ) + +season = stats["hitting"]["season"] +for split in season.splits: + print(split.stat.model_dump(exclude_none=True)) +``` + +### Async + +```python +import asyncio + +from mlbstatsapi import AsyncMlb + + +async def main(): + async with AsyncMlb() as mlb: + stats = await mlb.get_player_stats( + 664034, + stats=["season", "career"], + groups=["hitting"], + season=2022, + ) + + season = stats["hitting"]["season"] + for split in season.splits: + print(split.stat.model_dump(exclude_none=True)) + + +asyncio.run(main()) +``` + +## Team stats + +Use `get_team_stats()` for stat data scoped to one team. + +### Sync + +```python +from mlbstatsapi import Mlb + +with Mlb() as mlb: + stats = mlb.get_team_stats( + 136, + stats=["season", "seasonAdvanced"], + groups=["hitting"], + season=2022, + ) + +for stat_type, stat in stats["hitting"].items(): + print(stat_type) + for split in stat.splits: + print(split.stat.model_dump(exclude_none=True)) +``` + +### Async + +```python +import asyncio + +from mlbstatsapi import AsyncMlb + + +async def main(): + async with AsyncMlb() as mlb: + stats = await mlb.get_team_stats( + 136, + stats=["season", "seasonAdvanced"], + groups=["hitting"], + season=2022, + ) + + for stat_type, stat in stats["hitting"].items(): + print(stat_type) + for split in stat.splits: + print(split.stat.model_dump(exclude_none=True)) + + +asyncio.run(main()) +``` + +## General stats queries + +`get_stats()` queries the general `/stats` endpoint. Additional keyword arguments can narrow the request by season, team, league, game type, sport, and other Stats API parameters. + +### Sync + +```python +from mlbstatsapi import Mlb + +with Mlb() as mlb: + stats = mlb.get_stats( + stats=["season"], + groups=["hitting"], + season=2022, + sportIds=1, + ) + +for group_name, group_stats in stats.items(): + for stat_type, stat in group_stats.items(): + print(group_name, stat_type) + for split in stat.splits: + print(split.stat.model_dump(exclude_none=True)) +``` + +### Async + +```python +import asyncio + +from mlbstatsapi import AsyncMlb + + +async def main(): + async with AsyncMlb() as mlb: + stats = await mlb.get_stats( + stats=["season"], + groups=["hitting"], + season=2022, + sportIds=1, + ) + + for group_name, group_stats in stats.items(): + for stat_type, stat in group_stats.items(): + print(group_name, stat_type) + for split in stat.splits: + print(split.stat.model_dump(exclude_none=True)) + + +asyncio.run(main()) +``` + +## Player stats for a game + +Use `get_players_stats_for_game()` when you have both the player's MLB person ID and the game's `gamePk`. + +### Sync + +```python +from mlbstatsapi import Mlb + +with Mlb() as mlb: + stats = mlb.get_players_stats_for_game( + person_id=663728, + game_id=715757, + ) + +for group_name, group_stats in stats.items(): + for stat_type, stat in group_stats.items(): + print(group_name, stat_type) + for split in stat.splits: + print(split.stat.model_dump(exclude_none=True)) +``` + +### Async + +```python +import asyncio + +from mlbstatsapi import AsyncMlb + + +async def main(): + async with AsyncMlb() as mlb: + stats = await mlb.get_players_stats_for_game( + person_id=663728, + game_id=715757, + ) + + for group_name, group_stats in stats.items(): + for stat_type, stat in group_stats.items(): + print(group_name, stat_type) + for split in stat.splits: + print(split.stat.model_dump(exclude_none=True)) + + +asyncio.run(main()) +``` + +## Finding valid stat types and groups + +The MLB Stats API publishes the available values directly: + +- Stat types: +- Stat groups: +- Event types: +- Game types: + +Common stat groups include `hitting`, `pitching`, and `fielding`. Available stat types depend on the group and endpoint. Examples include `season`, `career`, `seasonAdvanced`, `gameLog`, and `playLog`. + +## Related documentation + +- [Method reference](methods.md) +- [General usage examples](examples.md) +- [Async usage](async.md) +- [Public API contract](public-api.md) +- [Stats model Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Stats) From bafb12580cd96425adaffaac69ccbb953344517c Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Mon, 24 Aug 2026 18:23:40 -0700 Subject: [PATCH 65/81] docs: point stat examples to dedicated guide --- docs/examples.md | 40 +++------------------------------------- 1 file changed, 3 insertions(+), 37 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index 6c2ce5fa..442dfb77 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -2,7 +2,7 @@ This document collects the longer usage examples that previously lived in the README. The README keeps a short quick start; this guide is the extended tour. -Every example in this file uses the synchronous `Mlb` client. Async usage is documented separately in [async.md](async.md). +Every example in this file uses the synchronous `Mlb` client. Async usage is documented separately in [async.md](async.md). Stats have their own detailed [stats guide](stats.md) with both sync and async examples. For return-object structure and endpoint details see the [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki). For the supported method list, parameters, and return shapes see the [public API contract](public-api.md). For transport behavior see the [HTTP transport documentation](http-transport.md). @@ -55,43 +55,9 @@ print(player.full_name) print(team.name) ``` -## Player stats +## Stats -```python -from mlbstatsapi import Mlb - -with Mlb() as mlb: - player_id = mlb.get_people_id("Ty France")[0] - stats = mlb.get_player_stats( - player_id, - stats=["season", "career"], - groups=["hitting", "pitching"], - season=2022, - ) - -season_hitting = stats["hitting"]["season"] -for split in season_hitting.splits: - print(split.stat.model_dump(exclude_none=True)) -``` - -## Team stats - -```python -from mlbstatsapi import Mlb - -with Mlb() as mlb: - team_id = mlb.get_team_id("Seattle Mariners")[0] - stats = mlb.get_team_stats( - team_id, - stats=["season", "seasonAdvanced"], - groups=["hitting"], - season=2022, - ) - -season_hitting = stats["hitting"]["season"] -for split in season_hitting.splits: - print(split.stat.model_dump_json(indent=2, exclude_none=True)) -``` +Player, team, general, and per-game stat examples live in the dedicated [Stats Guide](stats.md). It also explains the nested `stats[group][type]` return structure and includes matching `Mlb` and `AsyncMlb` examples. ## Schedule From 225f10e7c56ff0bb0601e44460bf1790138d9510 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Mon, 24 Aug 2026 18:24:06 -0700 Subject: [PATCH 66/81] docs: link method reference to stats guide --- docs/methods.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/methods.md b/docs/methods.md index 51c5d3ac..5b26f392 100644 --- a/docs/methods.md +++ b/docs/methods.md @@ -44,7 +44,7 @@ Mlb.get_team_roster(team_id: int, **params) ## Stats -[Wiki: Stats](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Stats) +[Wiki: Stats](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Stats) · [Stats Guide](stats.md) | Method | Description | | --- | --- | @@ -60,6 +60,8 @@ Mlb.get_stats(stats: list, groups: list, **params: dict) Mlb.get_players_stats_for_game(person_id: int, game_id: int, **params) ``` +The [Stats Guide](stats.md) includes runnable `Mlb` and `AsyncMlb` examples and explains the nested `stats[group][type]` return structure. + ## Games [Wiki: Game](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki/Data-Types:-Game) From 77ead307eac25208c723ee9644ea6b3f8b321ebc Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Mon, 24 Aug 2026 18:24:34 -0700 Subject: [PATCH 67/81] docs: link dedicated stats guide from README --- README.md | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 0f1502ee..fb5af28e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/python-mlb-statsapi) ![GitHub](https://img.shields.io/github/license/zero-sum-seattle/python-mlb-statsapi) -### [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) | [Methods](docs/methods.md) | [Examples](docs/examples.md) | [Async](docs/async.md) | [Public API](docs/public-api.md) | [MLB Stats API](https://statsapi.mlb.com/) +### [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) | [Methods](docs/methods.md) | [Examples](docs/examples.md) | [Stats](docs/stats.md) | [Async](docs/async.md) | [Public API](docs/public-api.md) | [MLB Stats API](https://statsapi.mlb.com/)
@@ -143,7 +143,7 @@ See [Async usage](docs/async.md) for lifecycle, concurrency, custom HTTPX client Where an async endpoint is supported, both clients return the same Pydantic models and follow the same public HTTP/error behavior. -The async surface is still smaller than the full synchronous API while 1.1 coverage is expanded. The [public API contract](docs/public-api.md#asyncmlb-public-client) is the authoritative list of supported async methods. +The [public API contract](docs/public-api.md#asyncmlb-public-client) is the authoritative list of supported async methods. ## Concurrent Async Requests @@ -190,16 +190,7 @@ team_ids = mlb.get_team_id("Seattle Mariners") ### Stats -```python -stats = mlb.get_player_stats( - 664034, - stats=["season", "career"], - groups=["hitting"], - season=2022, -) -``` - -Higher-level stats helpers remain on the synchronous `Mlb` client in the current 1.1 async surface. +The stats API has several entry points and returns a nested `stats[group][type]` structure. See the dedicated [Stats Guide](docs/stats.md) for `get_player_stats()`, `get_team_stats()`, `get_stats()`, and `get_players_stats_for_game()` examples using both `Mlb` and `AsyncMlb`. ### Schedule @@ -258,6 +249,7 @@ print(player.model_dump_json(indent=2)) | [Wiki](https://github.com/zero-sum-seattle/python-mlb-statsapi/wiki) | Endpoint reference, return objects, and model documentation | | [Method reference](docs/methods.md) | Method signatures and short descriptions from the original README reference | | [Usage examples](docs/examples.md) | Extended synchronous examples | +| [Stats guide](docs/stats.md) | Player, team, general, and per-game stat queries with sync and async examples | | [Async usage](docs/async.md) | Async installation, lifecycle, concurrency, and examples | | [HTTP transport](docs/http-transport.md) | Timeouts, retries, strict HTTP, exceptions, and ownership | | [Public API contract](docs/public-api.md) | Supported symbols, signatures, endpoint methods, and stability policy | From 509567c4db1e85cef3d860fc4cb1fd983e6bbbeb Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Mon, 24 Aug 2026 21:00:09 -0700 Subject: [PATCH 68/81] docs: align README with release validation --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fb5af28e..2ea65689 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ The async extra installs HTTPX. Python 3.10 or newer is required. | Claim | Value | | --- | --- | | Minimum Python version | `>=3.10` | -| CI-validated versions | 3.10, 3.11, 3.12, 3.13, 3.14 | +| CI-validated versions | Python 3.10 through 3.14 | See [Python support](docs/public-api.md#python-support) for the complete policy. @@ -206,6 +206,8 @@ See the [method reference](docs/methods.md) for the full method documentation th Both clients use explicit timeouts, structured exceptions, and pooled HTTP connections. `strict_http=True` is the default. Final non-404 4xx responses raise `MlbHttpError`, while existing endpoint-specific 404 behavior is preserved. +Library-created clients send a versioned User-Agent. The current package version sends `python-mlb-statsapi/1.0.1`. See the [HTTP transport documentation](docs/http-transport.md) for the full transport contract. + The main transport exceptions are: * `MlbHttpError` From 7bef3133fb91b8a10e40e3cb30b0489f4ceabd1d Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:24:01 +0000 Subject: [PATCH 69/81] docs: avoid main()-shaped entry point in custom HTTPX client example Wraps the reusable async logic in a plain function so it stays valid Python without prescribing a main() entry point; the asyncio.run() wrapper is now clearly marked as just one way to invoke it. Co-authored-by: Matthew Spah <2068393+Mattsface@users.noreply.github.com> --- docs/async.md | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/docs/async.md b/docs/async.md index 85eefa89..79079bbf 100644 --- a/docs/async.md +++ b/docs/async.md @@ -152,15 +152,37 @@ import httpx from mlbstatsapi import AsyncMlb -client = httpx.AsyncClient() -try: +async def get_person_with_custom_client(client: httpx.AsyncClient, person_id: int): async with AsyncMlb(client=client) as mlb: - player = await mlb.get_person(664034) -finally: - await client.aclose() + return await mlb.get_person(person_id) +``` + +`async with` and `await` are only valid inside an `async def`, so this is +written as a plain, reusable function rather than a top-level script. Call it +however your application already enters async code — `asyncio.run(...)`, a +web framework's request handler, an existing event loop, and so on. Nothing +here requires restructuring your application around a `main()` entry point; +`get_person_with_custom_client()` itself has no opinion on how it is invoked. + +For a minimal, runnable entry point: + +```python +import asyncio + + +async def main(): + async with httpx.AsyncClient() as client: + return await get_person_with_custom_client(client, 664034) + + +asyncio.run(main()) ``` -An injected client remains caller-owned and is not closed by `AsyncMlb`. +An injected client remains caller-owned and is not closed by `AsyncMlb`. In a +real application the client is typically created once, reused across calls, +and closed by whatever code owns its lifecycle — the entry point above is +only there to show one way to run the example, not the required shape of +your application. ## Documentation boundaries From 7f5b796fad842bec0de75214aecfb935a1bb7af6 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:01:44 +0000 Subject: [PATCH 70/81] docs: add multiple invocation examples for custom HTTPX client section Wrapping every advanced async example in a main()/asyncio.run() entry point isn't practical for readers integrating into an existing app. Show a script entry point alongside patterns for an already-running event loop, FastAPI, and interactive/notebook use with top-level await. Co-authored-by: Matthew Spah <2068393+Mattsface@users.noreply.github.com> --- docs/async.md | 43 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/docs/async.md b/docs/async.md index 79079bbf..16af8974 100644 --- a/docs/async.md +++ b/docs/async.md @@ -164,7 +164,10 @@ web framework's request handler, an existing event loop, and so on. Nothing here requires restructuring your application around a `main()` entry point; `get_person_with_custom_client()` itself has no opinion on how it is invoked. -For a minimal, runnable entry point: +Below are a few ways to invoke it, depending on how your application already +enters async code. + +**Script entry point** ```python import asyncio @@ -178,11 +181,43 @@ async def main(): asyncio.run(main()) ``` +**Inside an application that already runs on an event loop** — a web +framework's request handler, a worker task, and so on — just `await` it +directly with a client your application already owns: + +```python +async def handle_request(client: httpx.AsyncClient, person_id: int): + return await get_person_with_custom_client(client, person_id) +``` + +**FastAPI (or another ASGI framework)** + +```python +from fastapi import FastAPI + +app = FastAPI() +http_client = httpx.AsyncClient() + + +@app.get("/players/{person_id}") +async def read_player(person_id: int): + return await get_person_with_custom_client(http_client, person_id) +``` + +**Interactively, with no wrapper at all** — Jupyter/IPython and the +`python -m asyncio` REPL both support top-level `await`: + +```pycon +>>> import httpx +>>> client = httpx.AsyncClient() +>>> player = await get_person_with_custom_client(client, 664034) +>>> await client.aclose() +``` + An injected client remains caller-owned and is not closed by `AsyncMlb`. In a real application the client is typically created once, reused across calls, -and closed by whatever code owns its lifecycle — the entry point above is -only there to show one way to run the example, not the required shape of -your application. +and closed by whatever code owns its lifecycle — the examples above show a +few ways to run this, not the required shape of your application. ## Documentation boundaries From 779b3acb7644feafaedd2b5453325cf771457735 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Tue, 25 Aug 2026 14:29:41 -0700 Subject: [PATCH 71/81] docs: fix async endpoint coverage wording --- docs/async.md | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/docs/async.md b/docs/async.md index 16af8974..17b647fa 100644 --- a/docs/async.md +++ b/docs/async.md @@ -91,23 +91,11 @@ Cross-event-loop use of the same client is not promised. ## Supported endpoints -The async surface is intentionally smaller than the synchronous `Mlb` surface -while 1.1 support is being expanded. The currently supported awaitable endpoint -methods on `release/1.1.0` are: - -```text -get_team(...) -get_teams(...) -get_person(...) -get_people(...) -get_schedule(...) -``` - -Where an async endpoint is supported, it returns the same Pydantic model types -and follows the same public HTTP/error behavior as the matching synchronous -method. +`AsyncMlb` mirrors the endpoint surface exposed by `Mlb`. Its endpoint methods +are asynchronous and return the same parsed Pydantic model types while following +the same public HTTP/error behavior as their synchronous counterparts. -For the authoritative list and signatures, see the +For the authoritative method list and signatures, see the [public API contract](public-api.md#asyncmlb-public-client). ## Error handling From ffe4bd72241d1ec70a335b2a3677e01c29af5264 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Tue, 25 Aug 2026 15:00:42 -0700 Subject: [PATCH 72/81] docs: remove duplicate schedule signature --- docs/methods.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/methods.md b/docs/methods.md index 5b26f392..a0f03978 100644 --- a/docs/methods.md +++ b/docs/methods.md @@ -90,7 +90,6 @@ Mlb.get_game_box_score(game_id: int, **params) | `get_scheduled_games_by_date()` | Return scheduled games from dates | ```text -Mlb.get_schedule(date: str, start_date: str, end_date: str, sport_id: int, team_id: int, **params) Mlb.get_schedule(date: str = None, start_date: str = None, end_date: str = None, sport_id: int = 1, team_id: int = None, **params) Mlb.get_scheduled_games_by_date(date: str = None, start_date: str = None, end_date: str = None, sport_id: int = 1, **params) ``` From b8b7ebb7ce873e8f0bc320283b962627a7a51f8b Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Tue, 25 Aug 2026 15:38:40 -0700 Subject: [PATCH 73/81] docs: clarify common method examples --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 2ea65689..8adef444 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,8 @@ player, team = asyncio.run(main()) ## Common Methods +The examples below assume an initialized `Mlb` client named `mlb`, as shown in [Quick Start](#quick-start). + ### Players ```python From 86dc00e06d1fece9f3cdccba9826dc172dbb7d00 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:09:56 +0000 Subject: [PATCH 74/81] docs: reconcile async coverage claims and fix method signatures - README now states plainly that AsyncMlb mirrors the full Mlb endpoint surface, matching docs/async.md and docs/public-api.md, instead of hedged wording that implied partial coverage. - Drop the README callout singling out get_schedule as available on both clients, since that only made sense under partial coverage. - Fix get_awards, get_season, get_seasons, and get_people_id signatures in docs/methods.md to match mlbstatsapi/mlb_api.py. Co-authored-by: Matthew Spah <2068393+Mattsface@users.noreply.github.com> --- README.md | 6 ++---- docs/methods.md | 8 ++++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8adef444..7bd9ad6c 100644 --- a/README.md +++ b/README.md @@ -141,9 +141,9 @@ See [Async usage](docs/async.md) for lifecycle, concurrency, custom HTTPX client | Request | `mlb.get_team(...)` | `await mlb.get_team(...)` | | Explicit cleanup | `mlb.close()` | `await mlb.aclose()` | -Where an async endpoint is supported, both clients return the same Pydantic models and follow the same public HTTP/error behavior. +`AsyncMlb` mirrors the full endpoint surface of `Mlb`. Both clients return the same Pydantic models and follow the same public HTTP/error behavior. -The [public API contract](docs/public-api.md#asyncmlb-public-client) is the authoritative list of supported async methods. +See the [public API contract](docs/public-api.md#asyncmlb-public-client) for the authoritative method list and signatures. ## Concurrent Async Requests @@ -200,8 +200,6 @@ The stats API has several entry points and returns a nested `stats[group][type]` schedule = mlb.get_schedule(date="2022-10-13") ``` -`get_schedule` is available on both `Mlb` and `AsyncMlb`. - See the [method reference](docs/methods.md) for the full method documentation that previously lived in the README. Longer runnable examples live in [docs/examples.md](docs/examples.md). ## HTTP and Error Behavior diff --git a/docs/methods.md b/docs/methods.md index a0f03978..3c11b2d3 100644 --- a/docs/methods.md +++ b/docs/methods.md @@ -17,7 +17,7 @@ For detailed return-object and model documentation, follow the linked Wiki pages | `get_people()` | Return all players for a sport | ```text -Mlb.get_people_id(fullname: str, sport_id: int = 1, search_key: str = 'fullname', **params) +Mlb.get_people_id(fullname: str, sport_id: int = 1, search_key: str = 'fullName', **params) Mlb.get_person(player_id: int, **params) Mlb.get_people(sport_id: int = 1, **params) ``` @@ -168,8 +168,8 @@ Mlb.get_division_id(division_name: str, search_key: str = 'name', **params) | `get_seasons()` | Return all seasons | ```text -Mlb.get_season(season_id: str, sport_id: int = None, **params) -Mlb.get_seasons(sportid: int = None, **params) +Mlb.get_season(season_id: str, sport_id: int = 1, **params) +Mlb.get_seasons(sport_id: int = 1, **params) ``` ## Standings @@ -205,7 +205,7 @@ Mlb.get_draft(year_id: int, **params) | `get_awards()` | Return award recipients for an award | ```text -Mlb.get_awards(award_id: int, **params) +Mlb.get_awards(award_id: str, **params) ``` ## Gamepace From 0159a60f2e19d7f202d0845d252d5f22c12edc8f Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Tue, 25 Aug 2026 16:22:52 -0700 Subject: [PATCH 75/81] docs: list CI-validated Python versions --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7bd9ad6c..12030909 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ The async extra installs HTTPX. Python 3.10 or newer is required. | Claim | Value | | --- | --- | | Minimum Python version | `>=3.10` | -| CI-validated versions | Python 3.10 through 3.14 | +| CI-validated versions | Python 3.10 through 3.14 (`3.10`, `3.11`, `3.12`, `3.13`, `3.14`) | See [Python support](docs/public-api.md#python-support) for the complete policy. From 8ecb8c6c2c3adaaf14a3937a5acad98be930c678 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 26 Aug 2026 12:18:01 -0700 Subject: [PATCH 76/81] fix: honor environment proxies for library-created async clients PR #323 moved async retries into MlbAsyncRetryTransport, mounted via AsyncClient(transport=...). HTTPX only builds its own env-proxy mounts when the caller leaves transport=None (allow_env_proxies = trust_env and transport is None in Client.__init__), so passing a transport silently disabled HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY support, leaving callers behind a proxy with an unexplained hang. create_library_async_client() now rebuilds that proxy discovery from the stdlib (mlbstatsapi/_env_proxies.py, no private httpx APIs) and passes it through HTTPX's public mounts= argument, wrapping every proxy transport in the same retry transport used for direct requests so retries still apply behind a proxy. A caller-injected client is untouched. Fixes #324. --- docs/async.md | 16 ++ docs/http-transport.md | 29 ++- docs/releases/1.1.0.md | 47 +++++ mlbstatsapi/_async_transport.py | 41 ++++- mlbstatsapi/_env_proxies.py | 77 ++++++++ tests/test_env_proxies.py | 307 +++++++++++++++++++++++++++++++ tests/test_release_validation.py | 6 + 7 files changed, 518 insertions(+), 5 deletions(-) create mode 100644 docs/releases/1.1.0.md create mode 100644 mlbstatsapi/_env_proxies.py create mode 100644 tests/test_env_proxies.py diff --git a/docs/async.md b/docs/async.md index 17b647fa..0103a069 100644 --- a/docs/async.md +++ b/docs/async.md @@ -207,6 +207,22 @@ real application the client is typically created once, reused across calls, and closed by whatever code owns its lifecycle — the examples above show a few ways to run this, not the required shape of your application. +## Environment proxies + +A library-created client (the default — no `client=` passed) honors +`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY` from the environment, +the same variables a plain `httpx.AsyncClient()` discovers on its own. + +An injected client keeps whatever proxy configuration its caller gave it — +`httpx.AsyncClient()` reads those variables itself by default, or a caller +may pass `trust_env=False` or an explicit `proxy=`/`mounts=` to opt out or +override. The library does not add or remove proxy configuration on an +injected client. + +See [HTTP transport: async client environment +proxies](http-transport.md#async-client-environment-proxies) for the full +behavior. + ## Documentation boundaries - [README](../README.md) — installation and quick-start examples diff --git a/docs/http-transport.md b/docs/http-transport.md index 4b246953..999e3de3 100644 --- a/docs/http-transport.md +++ b/docs/http-transport.md @@ -710,8 +710,31 @@ bodies. The client has no default response cache. -## No async support +## Async client environment proxies -The client remains synchronous. +Library-created `AsyncMlb` / `AsyncMlbDataAdapter` clients honor +`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY` (any case), the same +environment variables HTTPX itself discovers for a plain `httpx.AsyncClient()`. -Async support is not part of version 1.0.0. +```text +Library-created async client + Reads HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY from the environment + Routes matching requests through the proxy + Applies the library retry policy to proxied and direct requests alike + +Caller-injected async client + Keeps exactly whatever transport and mounts its caller configured + The library never reads proxy environment variables for it +``` + +This mirrors [Session ownership](#session-ownership) on the sync side: the +library only ever configures a client it created itself. See +[async.md](async.md#custom-httpx-client) for injecting a client, including one +configured with its own proxy settings. + +## No async support in this section + +The retry, timeout, User-Agent, and strict-HTTP behavior documented above +apply to the synchronous `Mlb` client. For the asynchronous client, see +[async.md](async.md); it shares this document's retry, timeout, and +error-handling contract except where noted above. diff --git a/docs/releases/1.1.0.md b/docs/releases/1.1.0.md new file mode 100644 index 00000000..51a2675a --- /dev/null +++ b/docs/releases/1.1.0.md @@ -0,0 +1,47 @@ +# python-mlb-statsapi 1.1.0 + +Version 1.1.0 adds an asynchronous client, `AsyncMlb` (and the underlying +`AsyncMlbDataAdapter`), behind the optional `async` extra. See +[async.md](../async.md) for usage and [public-api.md](../public-api.md) for +the supported async surface. + +## Fixed + +* Environment proxies (`HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / + `NO_PROXY`) are honored for async clients the library creates. A custom + transport installed for async retries (added ahead of this release) had the + side effect of disabling HTTPX's own environment-proxy discovery, since + HTTPX only builds it when no transport is passed in. Library-created async + clients now rebuild that discovery themselves and mount it explicitly, so + callers behind a corporate or `NO_PROXY`-configured proxy get correct + behavior instead of a silent hang. A caller-injected `httpx.AsyncClient` + keeps whatever transport and proxy configuration its caller mounted; the + library never touches it. + +No code changes are required to pick up the fix; a library-created client +already reads the environment on every construction: + +```python +import asyncio + +from mlbstatsapi import AsyncMlb + + +async def main(): + # HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY, if set, are honored here. + async with AsyncMlb() as mlb: + return await mlb.get_person(664034) + + +asyncio.run(main()) +``` + +## Python support + +python-mlb-statsapi requires Python >=3.10. + +CI validates Python 3.10, 3.11, 3.12, 3.13, and 3.14. + +## Related issue + +Fixes #324. diff --git a/mlbstatsapi/_async_transport.py b/mlbstatsapi/_async_transport.py index 816e34e4..5f5252eb 100644 --- a/mlbstatsapi/_async_transport.py +++ b/mlbstatsapi/_async_transport.py @@ -25,6 +25,7 @@ import asyncio from ._async_support import import_httpx +from ._env_proxies import environment_proxy_map from .mlb_dataadapter import _build_user_agent, create_retry_policy httpx = import_httpx() @@ -151,15 +152,51 @@ async def aclose(self) -> None: await self._inner.aclose() -def create_library_async_client() -> httpx.AsyncClient: +def create_library_async_client(*, trust_env: bool = True) -> httpx.AsyncClient: """Build the async client the library creates and owns. The counterpart of ``_configure_library_session()`` on the sync side: library defaults are applied here, at creation, and only to clients the library creates. Passing headers to the constructor replaces just the User-Agent, so HTTPX's other default headers survive. + + HTTPX only builds its own environment-proxy mounts when the caller leaves + ``transport=None`` (``allow_env_proxies = trust_env and transport is + None`` in ``httpx.Client.__init__``). Passing ``transport=`` here, which + is required to install the retry transport, would otherwise silently + disable ``HTTP_PROXY`` / ``HTTPS_PROXY`` / ``ALL_PROXY`` / ``NO_PROXY`` + support for every library-created async client (issue #324). This + rebuilds that discovery from the stdlib (see ``_env_proxies.py``) and + passes it through HTTPX's public ``mounts=`` argument instead, wrapping + every proxy transport in the same retry transport the direct path uses, + so a request routed through a proxy still gets library retries. + + One retry policy instance is shared by the direct transport and every + proxy transport, mirroring the sync side sharing one Session across the + v1 and v1.1 adapters: retries are a property of the client, not of any + one transport within it. """ + retry_policy = create_retry_policy() + direct = MlbAsyncRetryTransport( + httpx.AsyncHTTPTransport(), retry_policy=retry_policy + ) + + mounts: dict[str, httpx.AsyncBaseTransport | None] = {} + for pattern, proxy in environment_proxy_map(trust_env=trust_env).items(): + if proxy is None: + # None tells HTTPX to fall back to client._transport for this + # pattern (see AsyncClient._transport_for_url), i.e. bypass the + # proxy rather than route through a second transport instance. + # aclose() also skips a None mount, so this never gets closed + # twice via both the direct transport and a mount entry. + mounts[pattern] = None + else: + mounts[pattern] = MlbAsyncRetryTransport( + httpx.AsyncHTTPTransport(proxy=proxy), retry_policy=retry_policy + ) + return httpx.AsyncClient( headers={"User-Agent": _build_user_agent()}, - transport=MlbAsyncRetryTransport(), + transport=direct, + mounts=mounts, ) diff --git a/mlbstatsapi/_env_proxies.py b/mlbstatsapi/_env_proxies.py new file mode 100644 index 00000000..4da4b87a --- /dev/null +++ b/mlbstatsapi/_env_proxies.py @@ -0,0 +1,77 @@ +"""Build an HTTPX-compatible proxy mount map from the environment. + +HTTPX only discovers ``HTTP_PROXY`` / ``HTTPS_PROXY`` / ``ALL_PROXY`` / +``NO_PROXY`` for itself when it builds its own transport, which happens only +when the caller does not pass ``transport=`` (see ``allow_env_proxies = +trust_env and transport is None`` in ``httpx.Client.__init__``). The async +retry transport (``_async_transport.py``) always passes ``transport=``, so +that discovery never runs, and environment proxy support silently disappears +for library-created async clients (issue #324). + +This module reimplements that discovery from the stdlib and hands the result +to HTTPX's public ``mounts=`` argument instead, so the library stays off +HTTPX's private ``httpx._utils.get_environment_proxies``. The parsing here +intentionally mirrors that private function's semantics, verified against +installed httpx 0.28.1, so the two must be updated together if a manual +recheck against a newer httpx ever turns up drift. + +No httpx import here: environment variables in, a plain ``dict`` out. +""" + +from __future__ import annotations + +import ipaddress +from urllib.request import getproxies + + +def environment_proxy_map(*, trust_env: bool = True) -> dict[str, str | None]: + """Return an HTTPX ``mounts=``-shaped map of proxies from the environment. + + Keys are URL patterns such as ``"https://"`` or ``"all://*mlb.com"``; a + ``None`` value means "bypass the proxy for this pattern" and is meaningful + only when a broader pattern (from ``ALL_PROXY``) would otherwise match. + """ + if not trust_env: + return {} + + proxy_info = getproxies() + mounts: dict[str, str | None] = {} + + for scheme in ("http", "https", "all"): + value = proxy_info.get(scheme) + if value: + mounts[f"{scheme}://"] = value if "://" in value else f"http://{value}" + + no_proxy_hosts = [host.strip() for host in proxy_info.get("no", "").split(",")] + for hostname in no_proxy_hosts: + if hostname == "*": + return {} + elif hostname: + if "://" in hostname: + mounts[hostname] = None + elif _is_ipv4(hostname): + mounts[f"all://{hostname}"] = None + elif _is_ipv6(hostname): + mounts[f"all://[{hostname}]"] = None + elif hostname.lower() == "localhost": + mounts[f"all://{hostname}"] = None + else: + mounts[f"all://*{hostname}"] = None + + return mounts + + +def _is_ipv4(hostname: str) -> bool: + try: + ipaddress.IPv4Address(hostname.split("/")[0]) + except ValueError: + return False + return True + + +def _is_ipv6(hostname: str) -> bool: + try: + ipaddress.IPv6Address(hostname.split("/")[0]) + except ValueError: + return False + return True diff --git a/tests/test_env_proxies.py b/tests/test_env_proxies.py new file mode 100644 index 00000000..b4834900 --- /dev/null +++ b/tests/test_env_proxies.py @@ -0,0 +1,307 @@ +"""Tests for environment proxy support in the async transport (issue #324). + +PR #323 moved async retries into a custom HTTPX transport. HTTPX only builds +its own environment-proxy mounts when the caller leaves ``transport=None`` +(``allow_env_proxies = trust_env and transport is None`` in +``httpx.Client.__init__``), so passing a transport to install retries +silently disabled ``HTTP_PROXY`` / ``HTTPS_PROXY`` / ``ALL_PROXY`` / +``NO_PROXY`` support for every library-created async client. + +Two layers are covered: + +* ``mlbstatsapi._env_proxies.environment_proxy_map`` is pure stdlib parsing, + tested directly against fixtures for every documented ``NO_PROXY`` form. +* ``create_library_async_client`` wires that map into HTTPX's public + ``mounts=`` argument. The differential test at the bottom pins that wiring + to HTTPX's own environment-proxy discovery, so a semantic change in a + future HTTPX release (a patch bump is allowed by the ``httpx>=0.28.1,<1.0`` + pin) shows up as a failing test instead of silent drift. + +These tests must not contact the live MLB API. +""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest + +from mlbstatsapi._env_proxies import environment_proxy_map + +# Every test below that touches HTTPX skips as a unit when the optional +# ``async`` extra is not installed, matching the guard used throughout the +# async test suite (see tests/test_async_optional_dependency.py). +httpx = pytest.importorskip("httpx", reason="requires the async extra (HTTPX)") + +from mlbstatsapi._async_transport import ( # noqa: E402 + MlbAsyncRetryTransport, + create_library_async_client, +) +from mlbstatsapi.async_mlb_dataadapter import AsyncMlbDataAdapter # noqa: E402 + +SLEEP_TARGET = "mlbstatsapi._async_transport.asyncio.sleep" +INNER_TRANSPORT_TARGET = "mlbstatsapi._async_transport.httpx.AsyncHTTPTransport" + +# Both cases of every proxy variable urllib.request.getproxies() reads, so a +# proxy set in the developer's own shell can never leak into a fixture. +_PROXY_ENV_VARS = ( + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + "NO_PROXY", + "no_proxy", +) + + +def _clear_proxy_env(monkeypatch) -> None: + for name in _PROXY_ENV_VARS: + monkeypatch.delenv(name, raising=False) + + +def _set_env(monkeypatch, env: dict) -> None: + _clear_proxy_env(monkeypatch) + for key, value in env.items(): + monkeypatch.setenv(key, value) + + +# --------------------------------------------------------------------------- +# environment_proxy_map(): pure stdlib parsing +# --------------------------------------------------------------------------- + + +def test_https_proxy_only(monkeypatch): + _set_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"}) + assert environment_proxy_map() == {"https://": "http://corp:8080"} + + +def test_http_proxy_only(monkeypatch): + _set_env(monkeypatch, {"HTTP_PROXY": "http://corp:8080"}) + assert environment_proxy_map() == {"http://": "http://corp:8080"} + + +def test_all_proxy(monkeypatch): + _set_env(monkeypatch, {"ALL_PROXY": "http://corp:9"}) + assert environment_proxy_map() == {"all://": "http://corp:9"} + + +def test_bare_host_port_normalizes_to_http(monkeypatch): + _set_env(monkeypatch, {"HTTPS_PROXY": "corp:8080"}) + assert environment_proxy_map() == {"https://": "http://corp:8080"} + + +def test_no_proxy_subdomain_wildcard(monkeypatch): + _set_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080", "NO_PROXY": "mlb.com"}) + assert environment_proxy_map() == { + "https://": "http://corp:8080", + "all://*mlb.com": None, + } + + +def test_no_proxy_localhost_ipv4_ipv6(monkeypatch): + _set_env( + monkeypatch, + {"HTTPS_PROXY": "http://corp:8080", "NO_PROXY": "localhost,127.0.0.1,::1"}, + ) + assert environment_proxy_map() == { + "https://": "http://corp:8080", + "all://localhost": None, + "all://127.0.0.1": None, + "all://[::1]": None, + } + + +def test_no_proxy_star_disables_every_proxy(monkeypatch): + _set_env(monkeypatch, {"ALL_PROXY": "http://corp:8080", "NO_PROXY": "*"}) + assert environment_proxy_map() == {} + + +def test_empty_env(monkeypatch): + _set_env(monkeypatch, {}) + assert environment_proxy_map() == {} + + +def test_trust_env_false_ignores_everything(monkeypatch): + _set_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"}) + assert environment_proxy_map(trust_env=False) == {} + + +# --------------------------------------------------------------------------- +# create_library_async_client(): wiring the map into HTTPX +# --------------------------------------------------------------------------- + + +def test_one_retry_policy_shared_across_direct_and_proxy_transports(monkeypatch): + _set_env( + monkeypatch, + {"HTTPS_PROXY": "http://corp:8080", "HTTP_PROXY": "http://corp:9090"}, + ) + + async def scenario(): + client = create_library_async_client() + try: + transports = [client._transport] + [ + mount for mount in client._mounts.values() if mount is not None + ] + assert len(transports) == 3 + assert all(isinstance(t, MlbAsyncRetryTransport) for t in transports) + + policy = transports[0]._retry_policy + assert all(t._retry_policy is policy for t in transports) + finally: + await client.aclose() + + asyncio.run(scenario()) + + +def test_aclose_closes_every_proxy_transport(monkeypatch): + _set_env( + monkeypatch, + {"HTTPS_PROXY": "http://corp:8080", "HTTP_PROXY": "http://corp:9090"}, + ) + + async def scenario(): + with patch.object( + httpx.AsyncHTTPTransport, "aclose", new_callable=AsyncMock + ) as mock_aclose: + client = create_library_async_client() + proxy_mounts = [m for m in client._mounts.values() if m is not None] + assert len(proxy_mounts) == 2 + + await client.aclose() + + # The direct transport plus every proxy transport, none skipped and + # none closed twice. + assert mock_aclose.call_count == 1 + len(proxy_mounts) + + asyncio.run(scenario()) + + +def test_injected_client_is_unmodified_by_proxy_env(monkeypatch): + _set_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"}) + + transport = httpx.MockTransport(lambda request: httpx.Response(200)) + client = httpx.AsyncClient(transport=transport) + original_mounts = dict(client._mounts) + + adapter = AsyncMlbDataAdapter(client=client) + + assert adapter._owns_client is False + assert adapter._client is client + assert adapter._client._transport is transport + assert adapter._client._mounts == original_mounts + + +def test_retry_fires_through_a_proxied_transport(monkeypatch): + _set_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"}) + + call_count = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal call_count + call_count += 1 + return httpx.Response(503) if call_count == 1 else httpx.Response(200) + + async def scenario(): + with ( + patch(INNER_TRANSPORT_TARGET, lambda **kwargs: httpx.MockTransport(handler)), + patch(SLEEP_TARGET, new_callable=AsyncMock), + ): + client = create_library_async_client() + try: + return await client.get("https://statsapi.mlb.com/api/v1/sports") + finally: + await client.aclose() + + response = asyncio.run(scenario()) + assert response.status_code == 200 + assert call_count == 2 + + +# --------------------------------------------------------------------------- +# Differential test: pin our wiring to HTTPX's own env-proxy discovery. +# +# Each case was hand-verified against stock httpx 0.28.1 discovery +# (httpx.AsyncClient() with no transport=). If this starts failing against a +# newer 0.x httpx, treat it as a signal that NO_PROXY / proxy semantics moved +# out from under us, not as a test to loosen. +# --------------------------------------------------------------------------- + + +def proxy_target(transport): + inner = getattr(transport, "_inner", transport) + pool = getattr(inner, "_pool", None) + url = getattr(pool, "_proxy_url", None) + return str(url) if url is not None else None + + +DIFFERENTIAL_CASES = [ + ( + {"HTTPS_PROXY": "http://corp:8080"}, + ["https://statsapi.mlb.com/api", "http://statsapi.mlb.com/api"], + ), + ( + {"HTTP_PROXY": "http://corp:8080"}, + ["https://statsapi.mlb.com/api", "http://statsapi.mlb.com/api"], + ), + ( + {"ALL_PROXY": "http://corp:9"}, + ["https://statsapi.mlb.com/api", "http://anything.test/"], + ), + ( + {"HTTPS_PROXY": "corp:8080"}, + ["https://statsapi.mlb.com/api"], + ), + ( + { + "HTTPS_PROXY": "http://corp:8080", + "NO_PROXY": "mlb.com,localhost,127.0.0.1,::1", + }, + [ + "https://statsapi.mlb.com/api", + "https://mlb.com/", + "https://other.test/", + "http://localhost:8000/", + "https://127.0.0.1/", + "https://[::1]/", + ], + ), + ( + {"HTTPS_PROXY": "http://corp:8080", "NO_PROXY": "*"}, + ["https://statsapi.mlb.com/api"], + ), + ( + {}, + ["https://statsapi.mlb.com/api"], + ), +] + + +@pytest.mark.parametrize( + "env, urls", + DIFFERENTIAL_CASES, + ids=[",".join(env) or "empty" for env, _ in DIFFERENTIAL_CASES], +) +def test_matches_stock_httpx_env_proxy_resolution(monkeypatch, env, urls): + _set_env(monkeypatch, env) + + async def scenario(): + stock = httpx.AsyncClient() + ours = create_library_async_client() + try: + for url in urls: + stock_transport = stock._transport_for_url(httpx.URL(url)) + our_transport = ours._transport_for_url(httpx.URL(url)) + + assert isinstance(our_transport, MlbAsyncRetryTransport), url + assert proxy_target(our_transport) == proxy_target( + stock_transport + ), url + finally: + await stock.aclose() + await ours.aclose() + + asyncio.run(scenario()) diff --git a/tests/test_release_validation.py b/tests/test_release_validation.py index a37cd41e..a20aaac3 100644 --- a/tests/test_release_validation.py +++ b/tests/test_release_validation.py @@ -44,12 +44,18 @@ # Historical notes keep their own version-specific statements and must not be # rewritten to match the current release. +# +# 1.1.0.md documents a release that has landed on this branch but is not yet +# the pyproject-declared version (that bump is the separate issue referenced +# above), so it is validated the same way as an already-shipped release +# rather than promoted to CURRENT_RELEASE_NOTES. HISTORICAL_RELEASE_NOTES = ( RELEASE_NOTES_DIR / "0.7.1.md", RELEASE_NOTES_DIR / "0.8.0.md", RELEASE_NOTES_DIR / "0.9.0.md", RELEASE_NOTES_DIR / "1.0.0.md", + RELEASE_NOTES_DIR / "1.1.0.md", ) # Deterministic CI contract for the 1.0 release. From 648b29784e79c0c0aa64044c4dc46d22846dd106 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 26 Aug 2026 12:48:46 -0700 Subject: [PATCH 77/81] test: address PR #331 review feedback on env-proxy tests Test and docs fixes only, no changes to _env_proxies.py logic or create_library_async_client() wiring: - The aclose() test's fixture had no NO_PROXY entry, so its mount map had no None value and the "none closed twice" assertion held regardless of whether the bypass branch mounted None or reused `direct`. Added a NO_PROXY fixture and pinned proxy_mounts to an explicit length so the test now fails if that branch regresses (verified locally, then reverted). - The proxied-retry test asserted nothing about which transport actually served the request; both the direct transport and the https:// mount wrapped the same MockTransport, so resolution could have silently fallen back to direct. Added an explicit _transport_for_url() assertion before the request. - Split the module: tests/test_env_proxies.py now covers only environment_proxy_map()'s pure stdlib parsing and carries no httpx import, so it runs in the no-httpx CI job instead of skipping with everything else. tests/test_async_env_proxies.py keeps the client-wiring, cleanup, and differential tests behind the module-level httpx importorskip guard. - Renamed the "No async support in this section" heading in docs/http-transport.md to "Scope of this document". - Reworded the _env_proxies.py docstring to point at the differential test as the automated drift check, rather than implying a manual recheck is needed. - Closed the httpx.AsyncClient left open in the injected-client test. --- docs/http-transport.md | 2 +- mlbstatsapi/_env_proxies.py | 8 +- tests/test_async_env_proxies.py | 253 +++++++++++++++++++++++++++++++ tests/test_env_proxies.py | 260 ++++---------------------------- 4 files changed, 288 insertions(+), 235 deletions(-) create mode 100644 tests/test_async_env_proxies.py diff --git a/docs/http-transport.md b/docs/http-transport.md index 999e3de3..26884c80 100644 --- a/docs/http-transport.md +++ b/docs/http-transport.md @@ -732,7 +732,7 @@ library only ever configures a client it created itself. See [async.md](async.md#custom-httpx-client) for injecting a client, including one configured with its own proxy settings. -## No async support in this section +## Scope of this document The retry, timeout, User-Agent, and strict-HTTP behavior documented above apply to the synchronous `Mlb` client. For the asynchronous client, see diff --git a/mlbstatsapi/_env_proxies.py b/mlbstatsapi/_env_proxies.py index 4da4b87a..70d411d4 100644 --- a/mlbstatsapi/_env_proxies.py +++ b/mlbstatsapi/_env_proxies.py @@ -12,8 +12,12 @@ to HTTPX's public ``mounts=`` argument instead, so the library stays off HTTPX's private ``httpx._utils.get_environment_proxies``. The parsing here intentionally mirrors that private function's semantics, verified against -installed httpx 0.28.1, so the two must be updated together if a manual -recheck against a newer httpx ever turns up drift. +installed httpx 0.28.1. The differential test in +``tests/test_async_env_proxies.py`` (``test_matches_stock_httpx_env_proxy_resolution``) +is the drift alarm: it resolves the same URLs against a stock +``httpx.AsyncClient()`` and against this module's output on every run, so a +future httpx release changing ``NO_PROXY`` or proxy semantics fails that test +instead of silently diverging. No httpx import here: environment variables in, a plain ``dict`` out. """ diff --git a/tests/test_async_env_proxies.py b/tests/test_async_env_proxies.py new file mode 100644 index 00000000..5744b2b7 --- /dev/null +++ b/tests/test_async_env_proxies.py @@ -0,0 +1,253 @@ +"""Tests for async client env-proxy wiring in _async_transport.py (issue #324). + +PR #323 moved async retries into a custom HTTPX transport, mounted onto +library-created clients via ``AsyncClient(transport=...)``. httpx 0.28.1 only +builds its own environment-proxy mounts when the caller leaves +``transport=None`` (``allow_env_proxies = trust_env and transport is None`` in +``httpx.Client.__init__``), so passing a transport silently disabled +``HTTP_PROXY`` / ``HTTPS_PROXY`` / ``ALL_PROXY`` / ``NO_PROXY`` support for +every library-created async client. + +``create_library_async_client`` (in ``mlbstatsapi/_async_transport.py``) +rebuilds that discovery via ``mlbstatsapi._env_proxies.environment_proxy_map`` +and wires it through HTTPX's public ``mounts=`` argument instead. This module +covers that wiring: shared retry policy, transport cleanup, injected-client +isolation, retries through a proxy, and — at the bottom — a differential test +against HTTPX's own env-proxy discovery. The pure parsing behind the map is +covered separately in tests/test_env_proxies.py, which has no HTTPX +dependency and runs even without the ``async`` extra. + +These tests must not contact the live MLB API. +""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest + +# The whole module needs a real HTTPX-backed client, so it skips as a unit +# when the optional ``async`` extra is not installed, matching the guard used +# throughout the async test suite (see tests/test_async_optional_dependency.py). +httpx = pytest.importorskip("httpx", reason="requires the async extra (HTTPX)") + +from mlbstatsapi._async_transport import ( # noqa: E402 + MlbAsyncRetryTransport, + create_library_async_client, +) +from mlbstatsapi.async_mlb_dataadapter import AsyncMlbDataAdapter # noqa: E402 + +from test_env_proxies import set_proxy_env # noqa: E402 + +SLEEP_TARGET = "mlbstatsapi._async_transport.asyncio.sleep" +INNER_TRANSPORT_TARGET = "mlbstatsapi._async_transport.httpx.AsyncHTTPTransport" + + +# --------------------------------------------------------------------------- +# create_library_async_client(): wiring the map into HTTPX +# --------------------------------------------------------------------------- + + +def test_one_retry_policy_shared_across_direct_and_proxy_transports(monkeypatch): + set_proxy_env( + monkeypatch, + {"HTTPS_PROXY": "http://corp:8080", "HTTP_PROXY": "http://corp:9090"}, + ) + + async def scenario(): + client = create_library_async_client() + try: + transports = [client._transport] + [ + mount for mount in client._mounts.values() if mount is not None + ] + assert len(transports) == 3 + assert all(isinstance(t, MlbAsyncRetryTransport) for t in transports) + + policy = transports[0]._retry_policy + assert all(t._retry_policy is policy for t in transports) + finally: + await client.aclose() + + asyncio.run(scenario()) + + +def test_aclose_closes_every_proxy_transport(monkeypatch): + # NO_PROXY adds a bypass mount. That is what makes this a real regression + # test: the bypass branch mounts None specifically so HTTPX falls back to + # client._transport for that pattern instead of routing through (and + # later double-closing) a second reference to the same `direct` object. + # Without a bypass entry in the fixture, `len(proxy_mounts) == 2` below + # would still hold even if the bypass branch mounted `direct` instead of + # None, since there would be nothing to tell the two apart. + set_proxy_env( + monkeypatch, + { + "HTTPS_PROXY": "http://corp:8080", + "HTTP_PROXY": "http://corp:9090", + "NO_PROXY": "mlb.com", + }, + ) + + async def scenario(): + with patch.object( + httpx.AsyncHTTPTransport, "aclose", new_callable=AsyncMock + ) as mock_aclose: + client = create_library_async_client() + assert len(client._mounts) == 3 + + proxy_mounts = [m for m in client._mounts.values() if m is not None] + # Pinned to 2, not derived after the fact: if the bypass branch + # ever mounts `direct` instead of None, this becomes 3 and fails + # here, before the tautological count below could paper over it. + assert len(proxy_mounts) == 2 + + await client.aclose() + + # The direct transport plus the two real proxy transports; the + # NO_PROXY bypass mount is None and contributes no separate close. + assert mock_aclose.call_count == 1 + len(proxy_mounts) + + asyncio.run(scenario()) + + +def test_injected_client_is_unmodified_by_proxy_env(monkeypatch): + set_proxy_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"}) + + transport = httpx.MockTransport(lambda request: httpx.Response(200)) + client = httpx.AsyncClient(transport=transport) + original_mounts = dict(client._mounts) + + async def scenario(): + adapter = AsyncMlbDataAdapter(client=client) + + assert adapter._owns_client is False + assert adapter._client is client + assert adapter._client._transport is transport + assert adapter._client._mounts == original_mounts + + await client.aclose() + + asyncio.run(scenario()) + + +def test_retry_fires_through_a_proxied_transport(monkeypatch): + set_proxy_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"}) + + call_count = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal call_count + call_count += 1 + return httpx.Response(503) if call_count == 1 else httpx.Response(200) + + async def scenario(): + with ( + patch( + INNER_TRANSPORT_TARGET, lambda **kwargs: httpx.MockTransport(handler) + ), + patch(SLEEP_TARGET, new_callable=AsyncMock), + ): + client = create_library_async_client() + try: + # Pin resolution to the proxy mount rather than the direct + # fallback, so a request to statsapi.mlb.com with HTTPS_PROXY + # set is guaranteed to exercise the proxied transport below, + # not just happen to because both wrap the same handler. + target = httpx.URL("https://statsapi.mlb.com/api/v1/sports") + assert client._transport_for_url(target) is not client._transport + + return await client.get(str(target)) + finally: + await client.aclose() + + response = asyncio.run(scenario()) + assert response.status_code == 200 + assert call_count == 2 + + +# --------------------------------------------------------------------------- +# Differential test: pin our wiring to HTTPX's own env-proxy discovery. +# +# Each case was hand-verified against stock httpx 0.28.1 discovery +# (httpx.AsyncClient() with no transport=). If this starts failing against a +# newer 0.x httpx, that is the drift alarm: it means NO_PROXY / proxy +# semantics moved out from under environment_proxy_map's stdlib +# reimplementation, and the two need to be reconciled, not the test loosened. +# --------------------------------------------------------------------------- + + +def proxy_target(transport): + inner = getattr(transport, "_inner", transport) + pool = getattr(inner, "_pool", None) + url = getattr(pool, "_proxy_url", None) + return str(url) if url is not None else None + + +DIFFERENTIAL_CASES = [ + ( + {"HTTPS_PROXY": "http://corp:8080"}, + ["https://statsapi.mlb.com/api", "http://statsapi.mlb.com/api"], + ), + ( + {"HTTP_PROXY": "http://corp:8080"}, + ["https://statsapi.mlb.com/api", "http://statsapi.mlb.com/api"], + ), + ( + {"ALL_PROXY": "http://corp:9"}, + ["https://statsapi.mlb.com/api", "http://anything.test/"], + ), + ( + {"HTTPS_PROXY": "corp:8080"}, + ["https://statsapi.mlb.com/api"], + ), + ( + { + "HTTPS_PROXY": "http://corp:8080", + "NO_PROXY": "mlb.com,localhost,127.0.0.1,::1", + }, + [ + "https://statsapi.mlb.com/api", + "https://mlb.com/", + "https://other.test/", + "http://localhost:8000/", + "https://127.0.0.1/", + "https://[::1]/", + ], + ), + ( + {"HTTPS_PROXY": "http://corp:8080", "NO_PROXY": "*"}, + ["https://statsapi.mlb.com/api"], + ), + ( + {}, + ["https://statsapi.mlb.com/api"], + ), +] + + +@pytest.mark.parametrize( + "env, urls", + DIFFERENTIAL_CASES, + ids=[",".join(env) or "empty" for env, _ in DIFFERENTIAL_CASES], +) +def test_matches_stock_httpx_env_proxy_resolution(monkeypatch, env, urls): + set_proxy_env(monkeypatch, env) + + async def scenario(): + stock = httpx.AsyncClient() + ours = create_library_async_client() + try: + for url in urls: + stock_transport = stock._transport_for_url(httpx.URL(url)) + our_transport = ours._transport_for_url(httpx.URL(url)) + + assert isinstance(our_transport, MlbAsyncRetryTransport), url + assert proxy_target(our_transport) == proxy_target( + stock_transport + ), url + finally: + await stock.aclose() + await ours.aclose() + + asyncio.run(scenario()) diff --git a/tests/test_env_proxies.py b/tests/test_env_proxies.py index b4834900..c6dfbc55 100644 --- a/tests/test_env_proxies.py +++ b/tests/test_env_proxies.py @@ -1,51 +1,28 @@ -"""Tests for environment proxy support in the async transport (issue #324). +"""Tests for ``mlbstatsapi._env_proxies.environment_proxy_map`` (issue #324). -PR #323 moved async retries into a custom HTTPX transport. HTTPX only builds -its own environment-proxy mounts when the caller leaves ``transport=None`` -(``allow_env_proxies = trust_env and transport is None`` in -``httpx.Client.__init__``), so passing a transport to install retries -silently disabled ``HTTP_PROXY`` / ``HTTPS_PROXY`` / ``ALL_PROXY`` / -``NO_PROXY`` support for every library-created async client. +PR #323 moved async retries into a custom HTTPX transport, which had the side +effect of disabling HTTPX's own environment-proxy discovery for library-created +async clients (see ``mlbstatsapi/_env_proxies.py`` for the full story). +``environment_proxy_map`` is the stdlib-only replacement for that discovery. -Two layers are covered: - -* ``mlbstatsapi._env_proxies.environment_proxy_map`` is pure stdlib parsing, - tested directly against fixtures for every documented ``NO_PROXY`` form. -* ``create_library_async_client`` wires that map into HTTPX's public - ``mounts=`` argument. The differential test at the bottom pins that wiring - to HTTPX's own environment-proxy discovery, so a semantic change in a - future HTTPX release (a patch bump is allowed by the ``httpx>=0.28.1,<1.0`` - pin) shows up as a failing test instead of silent drift. +This module covers only the pure parsing in ``environment_proxy_map`` itself +and imports nothing from HTTPX, so it runs — and is meant to run — in the +no-httpx CI job: a stdlib-only helper is exactly where that job's coverage +matters most. The tests that exercise how the map is wired into an HTTPX +client (``create_library_async_client``) live in +tests/test_async_env_proxies.py, which skips as a whole without the ``async`` +extra. These tests must not contact the live MLB API. """ from __future__ import annotations -import asyncio -from unittest.mock import AsyncMock, patch - -import pytest - from mlbstatsapi._env_proxies import environment_proxy_map -# Every test below that touches HTTPX skips as a unit when the optional -# ``async`` extra is not installed, matching the guard used throughout the -# async test suite (see tests/test_async_optional_dependency.py). -httpx = pytest.importorskip("httpx", reason="requires the async extra (HTTPX)") - -from mlbstatsapi._async_transport import ( # noqa: E402 - MlbAsyncRetryTransport, - create_library_async_client, -) -from mlbstatsapi.async_mlb_dataadapter import AsyncMlbDataAdapter # noqa: E402 - -SLEEP_TARGET = "mlbstatsapi._async_transport.asyncio.sleep" -INNER_TRANSPORT_TARGET = "mlbstatsapi._async_transport.httpx.AsyncHTTPTransport" - # Both cases of every proxy variable urllib.request.getproxies() reads, so a # proxy set in the developer's own shell can never leak into a fixture. -_PROXY_ENV_VARS = ( +PROXY_ENV_VARS = ( "HTTP_PROXY", "http_proxy", "HTTPS_PROXY", @@ -57,44 +34,41 @@ ) -def _clear_proxy_env(monkeypatch) -> None: - for name in _PROXY_ENV_VARS: +def clear_proxy_env(monkeypatch) -> None: + for name in PROXY_ENV_VARS: monkeypatch.delenv(name, raising=False) -def _set_env(monkeypatch, env: dict) -> None: - _clear_proxy_env(monkeypatch) +def set_proxy_env(monkeypatch, env: dict) -> None: + clear_proxy_env(monkeypatch) for key, value in env.items(): monkeypatch.setenv(key, value) -# --------------------------------------------------------------------------- -# environment_proxy_map(): pure stdlib parsing -# --------------------------------------------------------------------------- - - def test_https_proxy_only(monkeypatch): - _set_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"}) + set_proxy_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"}) assert environment_proxy_map() == {"https://": "http://corp:8080"} def test_http_proxy_only(monkeypatch): - _set_env(monkeypatch, {"HTTP_PROXY": "http://corp:8080"}) + set_proxy_env(monkeypatch, {"HTTP_PROXY": "http://corp:8080"}) assert environment_proxy_map() == {"http://": "http://corp:8080"} def test_all_proxy(monkeypatch): - _set_env(monkeypatch, {"ALL_PROXY": "http://corp:9"}) + set_proxy_env(monkeypatch, {"ALL_PROXY": "http://corp:9"}) assert environment_proxy_map() == {"all://": "http://corp:9"} def test_bare_host_port_normalizes_to_http(monkeypatch): - _set_env(monkeypatch, {"HTTPS_PROXY": "corp:8080"}) + set_proxy_env(monkeypatch, {"HTTPS_PROXY": "corp:8080"}) assert environment_proxy_map() == {"https://": "http://corp:8080"} def test_no_proxy_subdomain_wildcard(monkeypatch): - _set_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080", "NO_PROXY": "mlb.com"}) + set_proxy_env( + monkeypatch, {"HTTPS_PROXY": "http://corp:8080", "NO_PROXY": "mlb.com"} + ) assert environment_proxy_map() == { "https://": "http://corp:8080", "all://*mlb.com": None, @@ -102,7 +76,7 @@ def test_no_proxy_subdomain_wildcard(monkeypatch): def test_no_proxy_localhost_ipv4_ipv6(monkeypatch): - _set_env( + set_proxy_env( monkeypatch, {"HTTPS_PROXY": "http://corp:8080", "NO_PROXY": "localhost,127.0.0.1,::1"}, ) @@ -115,193 +89,15 @@ def test_no_proxy_localhost_ipv4_ipv6(monkeypatch): def test_no_proxy_star_disables_every_proxy(monkeypatch): - _set_env(monkeypatch, {"ALL_PROXY": "http://corp:8080", "NO_PROXY": "*"}) + set_proxy_env(monkeypatch, {"ALL_PROXY": "http://corp:8080", "NO_PROXY": "*"}) assert environment_proxy_map() == {} def test_empty_env(monkeypatch): - _set_env(monkeypatch, {}) + set_proxy_env(monkeypatch, {}) assert environment_proxy_map() == {} def test_trust_env_false_ignores_everything(monkeypatch): - _set_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"}) + set_proxy_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"}) assert environment_proxy_map(trust_env=False) == {} - - -# --------------------------------------------------------------------------- -# create_library_async_client(): wiring the map into HTTPX -# --------------------------------------------------------------------------- - - -def test_one_retry_policy_shared_across_direct_and_proxy_transports(monkeypatch): - _set_env( - monkeypatch, - {"HTTPS_PROXY": "http://corp:8080", "HTTP_PROXY": "http://corp:9090"}, - ) - - async def scenario(): - client = create_library_async_client() - try: - transports = [client._transport] + [ - mount for mount in client._mounts.values() if mount is not None - ] - assert len(transports) == 3 - assert all(isinstance(t, MlbAsyncRetryTransport) for t in transports) - - policy = transports[0]._retry_policy - assert all(t._retry_policy is policy for t in transports) - finally: - await client.aclose() - - asyncio.run(scenario()) - - -def test_aclose_closes_every_proxy_transport(monkeypatch): - _set_env( - monkeypatch, - {"HTTPS_PROXY": "http://corp:8080", "HTTP_PROXY": "http://corp:9090"}, - ) - - async def scenario(): - with patch.object( - httpx.AsyncHTTPTransport, "aclose", new_callable=AsyncMock - ) as mock_aclose: - client = create_library_async_client() - proxy_mounts = [m for m in client._mounts.values() if m is not None] - assert len(proxy_mounts) == 2 - - await client.aclose() - - # The direct transport plus every proxy transport, none skipped and - # none closed twice. - assert mock_aclose.call_count == 1 + len(proxy_mounts) - - asyncio.run(scenario()) - - -def test_injected_client_is_unmodified_by_proxy_env(monkeypatch): - _set_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"}) - - transport = httpx.MockTransport(lambda request: httpx.Response(200)) - client = httpx.AsyncClient(transport=transport) - original_mounts = dict(client._mounts) - - adapter = AsyncMlbDataAdapter(client=client) - - assert adapter._owns_client is False - assert adapter._client is client - assert adapter._client._transport is transport - assert adapter._client._mounts == original_mounts - - -def test_retry_fires_through_a_proxied_transport(monkeypatch): - _set_env(monkeypatch, {"HTTPS_PROXY": "http://corp:8080"}) - - call_count = 0 - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal call_count - call_count += 1 - return httpx.Response(503) if call_count == 1 else httpx.Response(200) - - async def scenario(): - with ( - patch(INNER_TRANSPORT_TARGET, lambda **kwargs: httpx.MockTransport(handler)), - patch(SLEEP_TARGET, new_callable=AsyncMock), - ): - client = create_library_async_client() - try: - return await client.get("https://statsapi.mlb.com/api/v1/sports") - finally: - await client.aclose() - - response = asyncio.run(scenario()) - assert response.status_code == 200 - assert call_count == 2 - - -# --------------------------------------------------------------------------- -# Differential test: pin our wiring to HTTPX's own env-proxy discovery. -# -# Each case was hand-verified against stock httpx 0.28.1 discovery -# (httpx.AsyncClient() with no transport=). If this starts failing against a -# newer 0.x httpx, treat it as a signal that NO_PROXY / proxy semantics moved -# out from under us, not as a test to loosen. -# --------------------------------------------------------------------------- - - -def proxy_target(transport): - inner = getattr(transport, "_inner", transport) - pool = getattr(inner, "_pool", None) - url = getattr(pool, "_proxy_url", None) - return str(url) if url is not None else None - - -DIFFERENTIAL_CASES = [ - ( - {"HTTPS_PROXY": "http://corp:8080"}, - ["https://statsapi.mlb.com/api", "http://statsapi.mlb.com/api"], - ), - ( - {"HTTP_PROXY": "http://corp:8080"}, - ["https://statsapi.mlb.com/api", "http://statsapi.mlb.com/api"], - ), - ( - {"ALL_PROXY": "http://corp:9"}, - ["https://statsapi.mlb.com/api", "http://anything.test/"], - ), - ( - {"HTTPS_PROXY": "corp:8080"}, - ["https://statsapi.mlb.com/api"], - ), - ( - { - "HTTPS_PROXY": "http://corp:8080", - "NO_PROXY": "mlb.com,localhost,127.0.0.1,::1", - }, - [ - "https://statsapi.mlb.com/api", - "https://mlb.com/", - "https://other.test/", - "http://localhost:8000/", - "https://127.0.0.1/", - "https://[::1]/", - ], - ), - ( - {"HTTPS_PROXY": "http://corp:8080", "NO_PROXY": "*"}, - ["https://statsapi.mlb.com/api"], - ), - ( - {}, - ["https://statsapi.mlb.com/api"], - ), -] - - -@pytest.mark.parametrize( - "env, urls", - DIFFERENTIAL_CASES, - ids=[",".join(env) or "empty" for env, _ in DIFFERENTIAL_CASES], -) -def test_matches_stock_httpx_env_proxy_resolution(monkeypatch, env, urls): - _set_env(monkeypatch, env) - - async def scenario(): - stock = httpx.AsyncClient() - ours = create_library_async_client() - try: - for url in urls: - stock_transport = stock._transport_for_url(httpx.URL(url)) - our_transport = ours._transport_for_url(httpx.URL(url)) - - assert isinstance(our_transport, MlbAsyncRetryTransport), url - assert proxy_target(our_transport) == proxy_target( - stock_transport - ), url - finally: - await stock.aclose() - await ours.aclose() - - asyncio.run(scenario()) From ca0ef5f7898b318395fd5935060f279aa28d1b26 Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 26 Aug 2026 19:05:37 -0700 Subject: [PATCH 78/81] cleanup: drop 1.1.0 release notes and trust_env param from PR #331 Two review findings from #331, no proxy logic or test coverage changed: - docs/releases/1.1.0.md and its release-validation classification are #307's responsibility (version bump, release notes, final release validation), not #324's. Removed the file and reverted tests/test_release_validation.py to its release/1.1.0 state so 1.1.0 is not prematurely classified as historical release notes. - create_library_async_client(*, trust_env=True) only threaded trust_env into environment_proxy_map(); it never reached AsyncClient or AsyncHTTPTransport, so it did not represent full HTTPX trust_env semantics and wasn't exposed by any public constructor. Removed the parameter; the factory now always runs environment discovery, matching the trust_env=True default a caller gets from a plain httpx.AsyncClient(). environment_proxy_map() keeps its own trust_env parameter and test, since it is a pure helper. --- docs/releases/1.1.0.md | 47 -------------------------------- mlbstatsapi/_async_transport.py | 11 ++++++-- tests/test_release_validation.py | 6 ---- 3 files changed, 9 insertions(+), 55 deletions(-) delete mode 100644 docs/releases/1.1.0.md diff --git a/docs/releases/1.1.0.md b/docs/releases/1.1.0.md deleted file mode 100644 index 51a2675a..00000000 --- a/docs/releases/1.1.0.md +++ /dev/null @@ -1,47 +0,0 @@ -# python-mlb-statsapi 1.1.0 - -Version 1.1.0 adds an asynchronous client, `AsyncMlb` (and the underlying -`AsyncMlbDataAdapter`), behind the optional `async` extra. See -[async.md](../async.md) for usage and [public-api.md](../public-api.md) for -the supported async surface. - -## Fixed - -* Environment proxies (`HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / - `NO_PROXY`) are honored for async clients the library creates. A custom - transport installed for async retries (added ahead of this release) had the - side effect of disabling HTTPX's own environment-proxy discovery, since - HTTPX only builds it when no transport is passed in. Library-created async - clients now rebuild that discovery themselves and mount it explicitly, so - callers behind a corporate or `NO_PROXY`-configured proxy get correct - behavior instead of a silent hang. A caller-injected `httpx.AsyncClient` - keeps whatever transport and proxy configuration its caller mounted; the - library never touches it. - -No code changes are required to pick up the fix; a library-created client -already reads the environment on every construction: - -```python -import asyncio - -from mlbstatsapi import AsyncMlb - - -async def main(): - # HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY, if set, are honored here. - async with AsyncMlb() as mlb: - return await mlb.get_person(664034) - - -asyncio.run(main()) -``` - -## Python support - -python-mlb-statsapi requires Python >=3.10. - -CI validates Python 3.10, 3.11, 3.12, 3.13, and 3.14. - -## Related issue - -Fixes #324. diff --git a/mlbstatsapi/_async_transport.py b/mlbstatsapi/_async_transport.py index 5f5252eb..64a954ff 100644 --- a/mlbstatsapi/_async_transport.py +++ b/mlbstatsapi/_async_transport.py @@ -152,7 +152,7 @@ async def aclose(self) -> None: await self._inner.aclose() -def create_library_async_client(*, trust_env: bool = True) -> httpx.AsyncClient: +def create_library_async_client() -> httpx.AsyncClient: """Build the async client the library creates and owns. The counterpart of ``_configure_library_session()`` on the sync side: @@ -171,6 +171,13 @@ def create_library_async_client(*, trust_env: bool = True) -> httpx.AsyncClient: every proxy transport in the same retry transport the direct path uses, so a request routed through a proxy still gets library retries. + Environment discovery always runs here, matching the ``trust_env=True`` + default a caller gets from a plain ``httpx.AsyncClient()``. Neither + ``AsyncMlb`` nor ``AsyncMlbDataAdapter`` exposes a ``trust_env`` toggle; + a caller who needs one injects their own client instead, the same way + they would opt into any other HTTPX-level setting this factory does not + surface. + One retry policy instance is shared by the direct transport and every proxy transport, mirroring the sync side sharing one Session across the v1 and v1.1 adapters: retries are a property of the client, not of any @@ -182,7 +189,7 @@ def create_library_async_client(*, trust_env: bool = True) -> httpx.AsyncClient: ) mounts: dict[str, httpx.AsyncBaseTransport | None] = {} - for pattern, proxy in environment_proxy_map(trust_env=trust_env).items(): + for pattern, proxy in environment_proxy_map().items(): if proxy is None: # None tells HTTPX to fall back to client._transport for this # pattern (see AsyncClient._transport_for_url), i.e. bypass the diff --git a/tests/test_release_validation.py b/tests/test_release_validation.py index a20aaac3..a37cd41e 100644 --- a/tests/test_release_validation.py +++ b/tests/test_release_validation.py @@ -44,18 +44,12 @@ # Historical notes keep their own version-specific statements and must not be # rewritten to match the current release. -# -# 1.1.0.md documents a release that has landed on this branch but is not yet -# the pyproject-declared version (that bump is the separate issue referenced -# above), so it is validated the same way as an already-shipped release -# rather than promoted to CURRENT_RELEASE_NOTES. HISTORICAL_RELEASE_NOTES = ( RELEASE_NOTES_DIR / "0.7.1.md", RELEASE_NOTES_DIR / "0.8.0.md", RELEASE_NOTES_DIR / "0.9.0.md", RELEASE_NOTES_DIR / "1.0.0.md", - RELEASE_NOTES_DIR / "1.1.0.md", ) # Deterministic CI contract for the 1.0 release. From 12ff4d3a37149d04a98d003b1271e26e9388150f Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Wed, 26 Aug 2026 20:27:25 -0700 Subject: [PATCH 79/81] docs: define strict_http compatibility lifecycle (#309) --- docs/http-transport.md | 7 ++++--- docs/public-api.md | 5 +++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/http-transport.md b/docs/http-transport.md index 26884c80..763de152 100644 --- a/docs/http-transport.md +++ b/docs/http-transport.md @@ -386,9 +386,10 @@ is an explicit compatibility opt-out. It: * Does not alter timeout, transport, or decode failures * Runs only after retry exhaustion -Compatibility mode is a temporary migration path and an explicit request for -historical 0.9 behavior. It is not the recommended long-term 1.0 -configuration. +`strict_http=False` is a compatibility mode for users migrating from pre-1.0 +behavior. It will remain available throughout the 1.x release series and may +be removed in 2.0. New code should use the default `strict_http=True` behavior +and handle `MlbHttpError`. ## Compatibility warnings diff --git a/docs/public-api.md b/docs/public-api.md index 260094c2..1e04e090 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -29,6 +29,11 @@ During the 1.x series: * Documented Session ownership behavior will remain compatible * Documented endpoint-level 404 return shapes will remain compatible +`strict_http=False` is a compatibility mode for users migrating from pre-1.0 +behavior. It will remain available throughout the 1.x release series and may +be removed in 2.0. New code should use the default `strict_http=True` behavior +and handle `MlbHttpError`. + The following may still evolve in a compatible way: * New optional parameters From 4ad49480a382aa41419d91f7bfd71153d01658bd Mon Sep 17 00:00:00 2001 From: Matthew Spah Date: Thu, 27 Aug 2026 09:35:06 -0700 Subject: [PATCH 80/81] release: prepare 1.1.0 validation --- README.md | 2 +- docs/http-transport.md | 53 +++-- docs/releases/1.1.0.md | 69 +++++++ pyproject.toml | 2 +- scripts/validate_release.py | 343 ++++++++++++++++++++++++++++++- tests/test_release_validation.py | 275 ++++++++++++++++++++++--- 6 files changed, 685 insertions(+), 59 deletions(-) create mode 100644 docs/releases/1.1.0.md diff --git a/README.md b/README.md index 12030909..34b2c15a 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ See the [method reference](docs/methods.md) for the full method documentation th Both clients use explicit timeouts, structured exceptions, and pooled HTTP connections. `strict_http=True` is the default. Final non-404 4xx responses raise `MlbHttpError`, while existing endpoint-specific 404 behavior is preserved. -Library-created clients send a versioned User-Agent. The current package version sends `python-mlb-statsapi/1.0.1`. See the [HTTP transport documentation](docs/http-transport.md) for the full transport contract. +Library-created clients send a versioned User-Agent. The current package version sends `python-mlb-statsapi/1.1.0`. See the [HTTP transport documentation](docs/http-transport.md) for the full transport contract. The main transport exceptions are: diff --git a/docs/http-transport.md b/docs/http-transport.md index 763de152..5760613c 100644 --- a/docs/http-transport.md +++ b/docs/http-transport.md @@ -1,18 +1,19 @@ # HTTP Transport This document describes the HTTP transport behavior of the current release, -version 1.0.0. +version 1.1.0. Version 0.8.0 introduced shared Sessions, explicit timeouts, bounded retries, and structured exceptions. Version 0.9.0 introduced configurable strict behavior and compatibility warnings. Version 1.0.0 makes strict handling the default and defines the stable public contract. -The public client remains synchronous. Ordinary usage does not need to -configure sessions or retries. +Version 1.1.0 adds the optional asynchronous `AsyncMlb` and +`AsyncMlbDataAdapter` clients while preserving the existing synchronous API. +Ordinary usage does not need to configure sessions, clients, or retries. -See [the 1.0.0 release notes](releases/1.0.0.md) for a shorter summary of what -changed. For the authoritative public API boundary see +See [the 1.1.0 release notes](releases/1.1.0.md) for a shorter summary of what +changed in the current release. For the authoritative public API boundary see [the public API contract](public-api.md). ## Public transport API @@ -21,6 +22,8 @@ Everything this document describes is reachable from the package root: ```python from mlbstatsapi import ( + AsyncMlb, + AsyncMlbDataAdapter, Mlb, MlbDataAdapter, MlbDecodeError, @@ -33,6 +36,9 @@ from mlbstatsapi import ( ) ``` +The async symbols require the optional `async` installation extra. The +synchronous symbols remain available without HTTPX. + Names that are not exported from `mlbstatsapi` are internal and may change without a deprecation cycle. See [public-api.md](public-api.md) for the complete stability classification. @@ -48,8 +54,10 @@ mlb = mlbstatsapi.Mlb() player = mlb.get_person(664034) ``` -In version 1.0.0 that construction uses strict HTTP handling by default. The -client remains synchronous. Async support is not part of version 1.0.0. +Version 1.0.0 made strict HTTP handling the default for this construction. +That synchronous behavior is unchanged in 1.1.0, and existing synchronous +users require no code changes. Version 1.1.0 also provides the optional +`AsyncMlb` client; see [Async usage](async.md). ## Context manager @@ -189,7 +197,7 @@ to the library's tested retry policy. ## User-Agent -Library-created Sessions send a package-specific User-Agent: +Library-created Sessions and async clients send a package-specific User-Agent: ```text python-mlb-statsapi/ @@ -198,7 +206,7 @@ python-mlb-statsapi/ With the package version currently declared in project metadata that resolves to: ```text -python-mlb-statsapi/1.0.1 +python-mlb-statsapi/1.1.0 ``` The version comes from the installed package metadata, so it always matches @@ -207,10 +215,10 @@ the installed release without a separately maintained version string. Notes: * The header helps identify package traffic while debugging -* Other Requests default headers such as `Accept-Encoding`, `Accept`, and `Connection` remain intact +* Other transport default headers remain intact * Only `User-Agent` is set; the full header mapping is never replaced -* Caller-injected Sessions are never modified -* Applications using an injected Session may set their own User-Agent +* Caller-injected Sessions and HTTPX clients are never modified +* Applications using an injected Session or client may set their own User-Agent * The header contains no machine identifiers, installation identifiers, hostnames, or user tracking data * This is not telemetry and sends no analytics @@ -243,12 +251,12 @@ finally: ## Default retry policy -Library-created Sessions mount a bounded retry policy for GET requests -automatically. +Library-created Sessions and async clients use a bounded retry policy for GET +requests automatically. -Caller-injected Sessions are never automatically reconfigured. Retry settings -on an injected Session remain under the caller's control unless the caller -opts in. +Caller-injected Sessions and HTTPX clients are never automatically +reconfigured. Retry settings on injected Sessions and clients remain under +the caller's control. ```text Initial request: 1 @@ -638,7 +646,7 @@ Notes: * `MlbTimeoutError` is a subtype of `MlbTransportError` * All new errors inherit from `TheMlbStatsApiException` * Existing broad exception handling remains valid -* Original Requests or JSON decoding failures are preserved through exception chaining +* Original Requests, HTTPX, or JSON decoding failures are preserved through exception chaining ## HTTP exception attributes @@ -735,7 +743,8 @@ configured with its own proxy settings. ## Scope of this document -The retry, timeout, User-Agent, and strict-HTTP behavior documented above -apply to the synchronous `Mlb` client. For the asynchronous client, see -[async.md](async.md); it shares this document's retry, timeout, and -error-handling contract except where noted above. +The retry, timeout, User-Agent, strict-HTTP, and error-handling contract applies +to both `Mlb` and `AsyncMlb`. Session-specific sections describe the +synchronous Requests transport; `AsyncMlb` uses a caller-owned or +library-created HTTPX client with the corresponding ownership rules. See +[async.md](async.md) for async lifecycle, concurrency, and client injection. diff --git a/docs/releases/1.1.0.md b/docs/releases/1.1.0.md new file mode 100644 index 00000000..de51ffee --- /dev/null +++ b/docs/releases/1.1.0.md @@ -0,0 +1,69 @@ +# python-mlb-statsapi 1.1.0 + +Version 1.1.0 adds first-class asynchronous access to the MLB Stats API while +preserving the existing synchronous API. Applications upgrading from 1.0.x +that use `Mlb` or `MlbDataAdapter` require no code changes. + +## Async support + +Install the optional `async` extra to add HTTPX, the asynchronous transport +dependency: + +```bash +python3 -m pip install "python-mlb-statsapi[async]" +``` + +The extra provides the public `AsyncMlb` and `AsyncMlbDataAdapter` classes. +`AsyncMlb` covers the full endpoint surface exposed by `Mlb`. Sync and async +endpoints share the same parsing functions and return matching public Pydantic +models, values, and endpoint-specific empty-result shapes. + +```python +from mlbstatsapi import AsyncMlb + + +async def get_player(person_id: int): + async with AsyncMlb() as mlb: + return await mlb.get_person(person_id) +``` + +`async with AsyncMlb(...)` returns the client and closes library-owned HTTPX +resources when the block exits. Directly constructed clients support explicit +`await mlb.aclose()`, and repeated `aclose()` calls are safe. An injected +`httpx.AsyncClient` remains caller-owned and is never closed or reconfigured by +`AsyncMlb`. + +One `AsyncMlb` instance supports caller-controlled concurrent requests on the +same event loop. It does not create hidden request fanout or background tasks, +and cross-event-loop use is not promised. Caller cancellation propagates +without blocking unrelated concurrent requests. + +Library-created HTTPX clients honor `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, +and `NO_PROXY` from the environment while retaining the library's bounded +retry policy. Injected clients keep their caller-provided proxy and transport +configuration. + +## HTTP compatibility + +`strict_http=True` remains the default for both synchronous and asynchronous +clients. New code should use `strict_http=True` and handle `MlbHttpError`. + +`strict_http=False` remains supported throughout the 1.x release series and +may be removed in 2.0. It continues to provide the documented compatibility +path for final non-404 4xx responses; it is not removed or deprecated in +1.1.0. + +The base installation remains synchronous-only and does not require HTTPX. +Existing 1.0.x synchronous users require zero code changes for 1.1.0. + +## Python and release validation + +python-mlb-statsapi requires Python >=3.10. CI validates Python 3.10 through 3.14 +(`3.10`, `3.11`, `3.12`, `3.13`, and `3.14`) for the deterministic offline sync +and async suites. + +Release validation now checks both wheel and source-distribution installs in +separate clean environments. Each artifact retains its existing synchronous +smoke validation and is also installed with the `async` extra to verify the +public async imports, lifecycle, ownership, strict/compatibility behavior, and +versioned User-Agent without contacting the live MLB API. diff --git a/pyproject.toml b/pyproject.toml index 42cb075a..dd6458c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-mlb-statsapi" -version = "1.0.1" +version = "1.1.0" description = "mlbstatsapi python wrapper" authors = [ "Matthew Spah ", diff --git a/scripts/validate_release.py b/scripts/validate_release.py index 956d8ae2..166d1ec9 100644 --- a/scripts/validate_release.py +++ b/scripts/validate_release.py @@ -1,8 +1,8 @@ """Validate the built python-mlb-statsapi distributions before a release. Checks the artifacts in ``dist/``, then clean-installs each distribution -artifact into its own throwaway virtual environment and runs a public-API -smoke test against the *installed* package. +artifact into throwaway virtual environments and runs synchronous and async +public-API smoke tests against the *installed* package. Both the wheel and the source distribution are installed separately so a broken sdist build, a missing runtime dependency, or an omitted package file @@ -12,12 +12,12 @@ checkout cannot shadow the installed distribution artifact. Nothing here contacts the MLB API. Every HTTP response exercised by the smoke -test is produced by an injected fake Session. +tests is produced by an injected fake Session or HTTPX MockTransport. Usage:: python scripts/validate_release.py - python scripts/validate_release.py --expected-version 1.0.0 + python scripts/validate_release.py --expected-version 1.1.0 python scripts/validate_release.py --dist dist Without ``--expected-version`` the expected artifact version is read from the @@ -57,6 +57,12 @@ "mlbstatsapi/__init__.py", "mlbstatsapi/exceptions.py", "mlbstatsapi/warnings.py", + "mlbstatsapi/_async_support.py", + "mlbstatsapi/_async_transport.py", + "mlbstatsapi/_env_proxies.py", + "mlbstatsapi/_http.py", + "mlbstatsapi/async_mlb.py", + "mlbstatsapi/async_mlb_dataadapter.py", "mlbstatsapi/mlb_api.py", "mlbstatsapi/mlb_dataadapter.py", "mlbstatsapi/mlb_module.py", @@ -70,6 +76,12 @@ ADAPTER_STRICT_DEFAULT_MESSAGE = ( "MlbDataAdapter.strict_http must default to True for the 1.0 contract" ) +ASYNC_MLB_STRICT_DEFAULT_MESSAGE = ( + "AsyncMlb.strict_http must default to True for the 1.1 contract" +) +ASYNC_ADAPTER_STRICT_DEFAULT_MESSAGE = ( + "AsyncMlbDataAdapter.strict_http must default to True for the 1.1 contract" +) SMOKE_TEST_SOURCE = ''' """Public API smoke test for an installed python-mlb-statsapi artifact. @@ -504,6 +516,262 @@ def close(self): ''' +ASYNC_SMOKE_TEST_SOURCE = ''' +"""Async public API smoke test for an installed artifact with its async extra. + +Runs inside a throwaway virtual environment against the installed +distribution, never against a repository checkout. Every exercised HTTP +response comes from HTTPX MockTransport, so this test performs no network I/O +and never reaches the MLB API. +""" + +import asyncio +import importlib.metadata +import inspect +import logging +import sys +import sysconfig +import warnings +from pathlib import Path + +import httpx + +import mlbstatsapi +from mlbstatsapi import ( + AsyncMlb, + AsyncMlbDataAdapter, + MlbHttpCompatibilityWarning, + MlbHttpError, +) + +expected_version = sys.argv[1] + +# Final 403 responses exercise both strict and 1.x compatibility behavior +# without contacting the live service. +FORBIDDEN_PAYLOAD = {"messageNumber": 403, "message": "Forbidden"} +SPORTS_URL = "https://statsapi.mlb.com/api/v1/sports" +ASYNC_MLB_STRICT_DEFAULT_MESSAGE = ( + "AsyncMlb.strict_http must default to True for the 1.1 contract" +) +ASYNC_ADAPTER_STRICT_DEFAULT_MESSAGE = ( + "AsyncMlbDataAdapter.strict_http must default to True for the 1.1 contract" +) + +# Expected final 403s are logged by the adapter. Keep release output concise +# without configuring logging from inside the installed package. +package_logger = logging.getLogger("mlbstatsapi") +package_logger.addHandler(logging.NullHandler()) +package_logger.propagate = False + + +# --- The installed artifact and its optional dependency --- + +assert sys.prefix != sys.base_prefix, ( + "the async smoke test must run inside the throwaway virtual environment" +) + +site_packages = Path(sysconfig.get_paths()["purelib"]).resolve() +package_file = Path(mlbstatsapi.__file__).resolve() +assert package_file.is_relative_to(site_packages), ( + f"mlbstatsapi was imported from {package_file}, not from the installed " + f"distribution artifact under {site_packages}" +) + +installed_version = importlib.metadata.version("python-mlb-statsapi") +assert installed_version == expected_version, ( + f"installed metadata reports {installed_version}, expected {expected_version}" +) + +# In this otherwise-clean environment, importing HTTPX and reading its +# distribution metadata proves that installing the local artifact's [async] +# extra installed the optional transport dependency. +installed_httpx_version = importlib.metadata.version("httpx") +assert installed_httpx_version, "the async extra did not install HTTPX metadata" +assert httpx.__version__ == installed_httpx_version + +for name in ("AsyncMlb", "AsyncMlbDataAdapter"): + assert hasattr(mlbstatsapi, name), f"mlbstatsapi.{name} is not importable" + assert getattr(mlbstatsapi, name) is not None, f"mlbstatsapi.{name} is None" + + +# --- Public constructor and lifecycle contracts --- + +async_mlb_init = inspect.signature(AsyncMlb.__init__).parameters +async_adapter_init = inspect.signature(AsyncMlbDataAdapter.__init__).parameters + +assert list(async_mlb_init) == [ + "self", + "hostname", + "logger", + "timeout", + "client", + "strict_http", +] +assert async_mlb_init["hostname"].default == "statsapi.mlb.com" +assert async_mlb_init["logger"].default is None +assert async_mlb_init["timeout"].default == (3.05, 30.0) +assert async_mlb_init["client"].default is None +assert async_mlb_init["strict_http"].default is True, ( + ASYNC_MLB_STRICT_DEFAULT_MESSAGE +) +assert async_mlb_init["strict_http"].kind is inspect.Parameter.KEYWORD_ONLY + +assert list(async_adapter_init) == [ + "self", + "hostname", + "ver", + "logger", + "timeout", + "client", + "strict_http", +] +assert async_adapter_init["hostname"].default == "statsapi.mlb.com" +assert async_adapter_init["ver"].default == "v1" +assert async_adapter_init["logger"].default is None +assert async_adapter_init["timeout"].default == (3.05, 30.0) +assert async_adapter_init["client"].default is None +assert async_adapter_init["strict_http"].default is True, ( + ASYNC_ADAPTER_STRICT_DEFAULT_MESSAGE +) +assert async_adapter_init["strict_http"].kind is inspect.Parameter.KEYWORD_ONLY +assert inspect.iscoroutinefunction(AsyncMlb.aclose) +assert inspect.iscoroutinefunction(AsyncMlbDataAdapter.aclose) + + +def forbidden_response(request: httpx.Request) -> httpx.Response: + """Return one deterministic final 403 through HTTPX's fake transport.""" + return httpx.Response( + 403, + headers={"Content-Type": "application/json"}, + json=FORBIDDEN_PAYLOAD, + request=request, + ) + + +def assert_forbidden_error(exc: MlbHttpError, *, label: str) -> None: + assert exc.status_code == 403, f"{label}: status_code={exc.status_code}" + assert exc.reason == "Forbidden", f"{label}: reason={exc.reason!r}" + assert exc.method == "GET", f"{label}: method={exc.method!r}" + assert exc.url == SPORTS_URL, f"{label}: url={exc.url!r}" + assert isinstance(exc.response_data, dict), ( + f"{label}: response_data={exc.response_data!r}" + ) + for key, value in FORBIDDEN_PAYLOAD.items(): + assert exc.response_data.get(key) == value, ( + f"{label}: response_data={exc.response_data!r}" + ) + + +def compatibility_warnings(caught): + return [ + record + for record in caught + if issubclass(record.category, MlbHttpCompatibilityWarning) + ] + + +async def check_library_owned_lifecycle_and_user_agent() -> None: + expected_user_agent = f"python-mlb-statsapi/{expected_version}" + + # Construction plus async context-manager cleanup. No request is made with + # this library-created client; its configuration is inspected directly. + client = AsyncMlb() + owned_httpx_client = client._client + assert owned_httpx_client.headers["User-Agent"] == expected_user_agent + assert client._mlb_adapter_v1._strict_http is True, ( + ASYNC_MLB_STRICT_DEFAULT_MESSAGE + ) + async with client as entered: + assert entered is client + assert owned_httpx_client.is_closed is False + assert owned_httpx_client.is_closed is True + + # Explicit cleanup is supported and idempotent for a library-owned client. + explicitly_closed = AsyncMlb() + explicitly_owned_httpx_client = explicitly_closed._client + await explicitly_closed.aclose() + assert explicitly_owned_httpx_client.is_closed is True + await explicitly_closed.aclose() + assert explicitly_owned_httpx_client.is_closed is True + + +async def check_strict_http_and_caller_ownership() -> None: + transport = httpx.MockTransport(forbidden_response) + caller_client = httpx.AsyncClient( + transport=transport, + headers={ + "User-Agent": "release-async-smoke-test/1.0", + "X-Release-Test": "preserved", + }, + ) + headers_before = dict(caller_client.headers) + + try: + # Omitting strict_http exercises the real True default. The context + # manager must leave the injected HTTPX client caller-owned and open. + async with AsyncMlb(client=caller_client) as strict_client: + assert strict_client._client is caller_client + assert strict_client._mlb_adapter_v1._strict_http is True, ( + ASYNC_MLB_STRICT_DEFAULT_MESSAGE + ) + try: + await strict_client.get_sports() + except MlbHttpError as exc: + assert_forbidden_error( + exc, + label="AsyncMlb(strict_http=True).get_sports()", + ) + else: + raise AssertionError( + "AsyncMlb strict_http=True did not raise MlbHttpError" + ) + + assert caller_client.is_closed is False, ( + "AsyncMlb must not close a caller-injected httpx.AsyncClient" + ) + assert dict(caller_client.headers) == headers_before + + # Compatibility mode remains available through 1.x and returns the + # endpoint's historical empty result with its compatibility warning. + async with AsyncMlb( + client=caller_client, + strict_http=False, + ) as compatibility_client: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + sports = await compatibility_client.get_sports() + + assert sports == [], ( + f"AsyncMlb(strict_http=False).get_sports() returned {sports!r}" + ) + captured = compatibility_warnings(caught) + assert len(captured) == 1, ( + "AsyncMlb(strict_http=False) expected exactly one " + f"MlbHttpCompatibilityWarning, captured {captured!r}" + ) + assert "strict_http=False" in str(captured[0].message) + assert caller_client.is_closed is False, ( + "compatibility mode closed a caller-injected httpx.AsyncClient" + ) + finally: + await caller_client.aclose() + + assert caller_client.is_closed is True + + +async def main() -> None: + await check_library_owned_lifecycle_and_user_agent() + await check_strict_http_and_caller_ownership() + + +asyncio.run(main()) +print( + f"async smoke test passed for python-mlb-statsapi {installed_version} " + f"with HTTPX {installed_httpx_version}" +) +''' + + class ValidationError(Exception): """A release validation check failed.""" @@ -648,7 +916,7 @@ def _create_clean_environment(venv_dir: Path) -> Path: def _check_clean_install(artifact: Path, expected_version: str, *, label: str) -> None: - """Clean-install one distribution artifact and smoke test the result. + """Clean-install one distribution artifact and smoke test the sync result. Each artifact gets its own virtual environment so the wheel and the source distribution are never validated against a shared install. @@ -686,6 +954,66 @@ def _check_clean_install(artifact: Path, expected_version: str, *, label: str) - ) +def _check_async_clean_install( + artifact: Path, + expected_version: str, + *, + label: str, +) -> None: + """Clean-install one artifact with ``[async]`` and smoke test the result. + + This environment is separate from both sync artifact environments. That + separation proves HTTPX arrives through the exact local wheel or sdist's + optional extra rather than being left over from another validation phase. + """ + with tempfile.TemporaryDirectory( + prefix="python-mlb-statsapi-release-async-" + ) as tmp: + workspace = Path(tmp) + venv_dir = workspace / "venv" + + _log( + f" creating clean async virtual environment for the {label} " + f"in {venv_dir}" + ) + python = _create_clean_environment(venv_dir) + + _run( + [str(python), "-m", "pip", "install", "--upgrade", "--quiet", "pip"], + cwd=workspace, + label=f"pip upgrade for the {label} async environment", + ) + + artifact_with_extra = f"{artifact.resolve()}[async]" + _log(f" installing {label} with async extra: {artifact.name}") + _run( + [ + str(python), + "-m", + "pip", + "install", + "--quiet", + artifact_with_extra, + ], + cwd=workspace, + label=f"{label} async-extra installation of {artifact.name}", + ) + + smoke_test = workspace / "release_async_smoke_test.py" + smoke_test.write_text(ASYNC_SMOKE_TEST_SOURCE, encoding="utf-8") + + # Running from the temporary workspace keeps the repository checkout + # off sys.path, exactly as the sync installed-artifact phase does. + _log( + f" running {label} async smoke test against the installed artifact" + ) + _run( + [str(python), str(smoke_test), expected_version], + cwd=workspace, + label=f"{label} async smoke test for {artifact.name}", + ) + + def validate(dist_dir: Path, expected_version: str) -> None: _log(f"Validating release {expected_version} in {dist_dir}") @@ -711,6 +1039,11 @@ def validate(dist_dir: Path, expected_version: str) -> None: _check_clean_install(wheel, expected_version, label=WHEEL_LABEL) _check_clean_install(sdist, expected_version, label=SDIST_LABEL) + # The optional dependency must be resolved from each exact artifact in a + # fresh environment; a working wheel must not mask a broken sdist extra. + _check_async_clean_install(wheel, expected_version, label=WHEEL_LABEL) + _check_async_clean_install(sdist, expected_version, label=SDIST_LABEL) + _log(f"Release validation passed for {DISTRIBUTION_NAME} {expected_version}") diff --git a/tests/test_release_validation.py b/tests/test_release_validation.py index a37cd41e..198e8dc9 100644 --- a/tests/test_release_validation.py +++ b/tests/test_release_validation.py @@ -38,9 +38,9 @@ EXTERNAL_WORKFLOW = PROJECT_ROOT / ".github" / "workflows" / "external-tests.yml" # Release notes for the version this branch is preparing. Kept explicit so the -# current-document checks do not depend on the pyproject version bump, which is -# owned by a separate issue. -CURRENT_RELEASE_NOTES = RELEASE_NOTES_DIR / "1.0.1.md" +# current-document checks cannot silently classify an unreviewed notes file as +# the current release merely because the declared version changed. +CURRENT_RELEASE_NOTES = RELEASE_NOTES_DIR / "1.1.0.md" # Historical notes keep their own version-specific statements and must not be # rewritten to match the current release. @@ -50,9 +50,9 @@ RELEASE_NOTES_DIR / "0.8.0.md", RELEASE_NOTES_DIR / "0.9.0.md", RELEASE_NOTES_DIR / "1.0.0.md", + RELEASE_NOTES_DIR / "1.0.1.md", ) -# Deterministic CI contract for the 1.0 release. # Deterministic CI contract for maintained release branches. RELEASE_BRANCH_PATTERN = 'release/**' SUPPORTED_PYTHON_VERSIONS = ("3.10", "3.11", "3.12", "3.13", "3.14") @@ -320,12 +320,16 @@ def _write_sdist( def _classify_command(command) -> str: parts = [str(part) for part in command] joined = " ".join(parts) + if "release_async_smoke_test.py" in joined: + return "async-smoke" if "release_smoke_test.py" in joined: - return "smoke" + return "sync-smoke" if "--upgrade" in parts: return "pip-upgrade" if "install" in parts: - return "install" + if any(part.endswith("[async]") for part in parts): + return "async-install" + return "sync-install" return "other" @@ -337,10 +341,10 @@ def __init__(self, returncode: int): def _stub_clean_install(monkeypatch, *, failing: str | None = None) -> list[list[str]]: """Stub environment creation and subprocess execution for install tests. - ``failing`` selects the step that returns a non-zero exit code: ``install`` - for the artifact installation or ``smoke`` for the installed-package smoke - test. Only the validator's own ``subprocess`` reference is replaced, so no - real interpreter, environment, or download is involved. + ``failing`` selects the classified step that returns a non-zero exit code, + such as ``sync-install``, ``sync-smoke``, ``async-install``, or + ``async-smoke``. Only the validator's own ``subprocess`` reference is + replaced, so no real interpreter, environment, or download is involved. """ commands: list[list[str]] = [] @@ -576,12 +580,22 @@ def test_missing_required_source_distribution_path_is_reported( def test_required_source_distribution_paths_cover_the_package_entry_points() -> None: - """The required list must include the files needed to rebuild and import.""" + """The required list must cover both public clients and async support.""" required = set(validator.REQUIRED_SDIST_PATHS) assert {"README.md", "pyproject.toml", "mlbstatsapi/__init__.py"} <= required - assert "mlbstatsapi/mlb_api.py" in required - assert "mlbstatsapi/mlb_dataadapter.py" in required + assert { + "mlbstatsapi/mlb_api.py", + "mlbstatsapi/mlb_dataadapter.py", + "mlbstatsapi/async_mlb.py", + "mlbstatsapi/async_mlb_dataadapter.py", + } <= required + assert { + "mlbstatsapi/_async_support.py", + "mlbstatsapi/_async_transport.py", + "mlbstatsapi/_env_proxies.py", + "mlbstatsapi/_http.py", + } <= required # Tests, docs, and scripts are intentionally absent from the sdist. assert not any(path.startswith(("tests/", "docs/", "scripts/")) for path in required) @@ -596,7 +610,7 @@ def test_wheel_installation_failure_identifies_the_artifact( tmp_path: Path, ) -> None: wheel = _write_wheel(tmp_path) - _stub_clean_install(monkeypatch, failing="install") + _stub_clean_install(monkeypatch, failing="sync-install") with pytest.raises(validator.ValidationError) as exc_info: validator._check_clean_install( @@ -616,7 +630,7 @@ def test_source_distribution_installation_failure_identifies_the_artifact( tmp_path: Path, ) -> None: sdist = _write_sdist(tmp_path) - _stub_clean_install(monkeypatch, failing="install") + _stub_clean_install(monkeypatch, failing="sync-install") with pytest.raises(validator.ValidationError) as exc_info: validator._check_clean_install( @@ -645,7 +659,7 @@ def test_smoke_test_failure_identifies_the_artifact( if label == validator.WHEEL_LABEL else _write_sdist(tmp_path) ) - _stub_clean_install(monkeypatch, failing="smoke") + _stub_clean_install(monkeypatch, failing="sync-smoke") with pytest.raises(validator.ValidationError) as exc_info: validator._check_clean_install(artifact, SYNTHETIC_VERSION, label=label) @@ -655,6 +669,66 @@ def test_smoke_test_failure_identifies_the_artifact( assert "exit code 1" in message +@pytest.mark.parametrize( + "label", + (validator.WHEEL_LABEL, validator.SDIST_LABEL), +) +def test_async_extra_installation_failure_identifies_the_artifact_and_phase( + monkeypatch, + tmp_path: Path, + label: str, +) -> None: + artifact = ( + _write_wheel(tmp_path) + if label == validator.WHEEL_LABEL + else _write_sdist(tmp_path) + ) + _stub_clean_install(monkeypatch, failing="async-install") + + with pytest.raises(validator.ValidationError) as exc_info: + validator._check_async_clean_install( + artifact, + SYNTHETIC_VERSION, + label=label, + ) + + message = str(exc_info.value) + assert label in message + assert artifact.name in message + assert "async-extra installation" in message + assert "exit code 1" in message + + +@pytest.mark.parametrize( + "label", + (validator.WHEEL_LABEL, validator.SDIST_LABEL), +) +def test_async_smoke_failure_identifies_the_artifact_and_phase( + monkeypatch, + tmp_path: Path, + label: str, +) -> None: + artifact = ( + _write_wheel(tmp_path) + if label == validator.WHEEL_LABEL + else _write_sdist(tmp_path) + ) + _stub_clean_install(monkeypatch, failing="async-smoke") + + with pytest.raises(validator.ValidationError) as exc_info: + validator._check_async_clean_install( + artifact, + SYNTHETIC_VERSION, + label=label, + ) + + message = str(exc_info.value) + assert label in message + assert artifact.name in message + assert "async smoke test" in message + assert "exit code 1" in message + + def test_clean_install_runs_the_artifact_and_smoke_test_from_a_temp_workspace( monkeypatch, tmp_path: Path, @@ -669,12 +743,12 @@ def test_clean_install_runs_the_artifact_and_smoke_test_from_a_temp_workspace( ) steps = [_classify_command(command) for command in commands] - assert steps == ["pip-upgrade", "install", "smoke"] + assert steps == ["pip-upgrade", "sync-install", "sync-smoke"] - install_command = commands[steps.index("install")] + install_command = commands[steps.index("sync-install")] assert str(wheel.resolve()) in install_command - smoke_command = commands[steps.index("smoke")] + smoke_command = commands[steps.index("sync-smoke")] assert smoke_command[-1] == SYNTHETIC_VERSION smoke_script = Path(smoke_command[-2]) # The script is written into a throwaway workspace, never the checkout. @@ -682,6 +756,41 @@ def test_clean_install_runs_the_artifact_and_smoke_test_from_a_temp_workspace( assert PROJECT_ROOT not in smoke_script.parents +@pytest.mark.parametrize( + "label", + (validator.WHEEL_LABEL, validator.SDIST_LABEL), +) +def test_async_clean_install_requests_the_local_artifact_extra( + monkeypatch, + tmp_path: Path, + label: str, +) -> None: + artifact = ( + _write_wheel(tmp_path) + if label == validator.WHEEL_LABEL + else _write_sdist(tmp_path) + ) + commands = _stub_clean_install(monkeypatch) + + validator._check_async_clean_install( + artifact, + SYNTHETIC_VERSION, + label=label, + ) + + steps = [_classify_command(command) for command in commands] + assert steps == ["pip-upgrade", "async-install", "async-smoke"] + + install_command = commands[steps.index("async-install")] + assert f"{artifact.resolve()}[async]" in install_command + + smoke_command = commands[steps.index("async-smoke")] + assert smoke_command[-1] == SYNTHETIC_VERSION + smoke_script = Path(smoke_command[-2]) + assert smoke_script.name == "release_async_smoke_test.py" + assert PROJECT_ROOT not in smoke_script.parents + + def test_each_artifact_is_installed_into_its_own_environment( monkeypatch, tmp_path: Path, @@ -711,28 +820,62 @@ def record_environment(venv_dir: Path) -> Path: SYNTHETIC_VERSION, label=validator.SDIST_LABEL, ) + validator._check_async_clean_install( + wheel, + SYNTHETIC_VERSION, + label=validator.WHEEL_LABEL, + ) + validator._check_async_clean_install( + sdist, + SYNTHETIC_VERSION, + label=validator.SDIST_LABEL, + ) - assert len(created) == 2 - assert created[0] != created[1] + assert len(created) == 4 + assert len(set(created)) == 4 -def test_validate_clean_installs_both_artifacts(monkeypatch, tmp_path: Path) -> None: - """validate() must clean-install the wheel and the source distribution.""" +def test_validate_runs_sync_and_async_clean_installs_for_both_artifacts( + monkeypatch, + tmp_path: Path, +) -> None: + """validate() must exercise both install modes for wheel and sdist.""" wheel = _write_wheel(tmp_path) sdist = _write_sdist(tmp_path) - installs: list[tuple[Path, str, str]] = [] - - def record_install(artifact: Path, expected_version: str, *, label: str) -> None: - installs.append((artifact, expected_version, label)) - - monkeypatch.setattr(validator, "_check_clean_install", record_install) + sync_installs: list[tuple[Path, str, str]] = [] + async_installs: list[tuple[Path, str, str]] = [] + + def record_sync_install( + artifact: Path, + expected_version: str, + *, + label: str, + ) -> None: + sync_installs.append((artifact, expected_version, label)) + + def record_async_install( + artifact: Path, + expected_version: str, + *, + label: str, + ) -> None: + async_installs.append((artifact, expected_version, label)) + + monkeypatch.setattr(validator, "_check_clean_install", record_sync_install) + monkeypatch.setattr( + validator, + "_check_async_clean_install", + record_async_install, + ) validator.validate(tmp_path, SYNTHETIC_VERSION) - assert installs == [ + expected = [ (wheel, SYNTHETIC_VERSION, validator.WHEEL_LABEL), (sdist, SYNTHETIC_VERSION, validator.SDIST_LABEL), ] + assert sync_installs == expected + assert async_installs == expected def test_validate_reports_success_for_both_artifacts( @@ -751,6 +894,10 @@ def test_validate_reports_success_for_both_artifacts( assert f"running {validator.WHEEL_LABEL} smoke test" in output assert f"installing {validator.SDIST_LABEL}" in output assert f"running {validator.SDIST_LABEL} smoke test" in output + assert f"installing {validator.WHEEL_LABEL} with async extra" in output + assert f"running {validator.WHEEL_LABEL} async smoke test" in output + assert f"installing {validator.SDIST_LABEL} with async extra" in output + assert f"running {validator.SDIST_LABEL} async smoke test" in output assert "Release validation passed" in output @@ -768,6 +915,14 @@ def test_smoke_test_source_is_valid_python() -> None: compile(validator.SMOKE_TEST_SOURCE, "release_smoke_test.py", "exec") +def test_async_smoke_test_source_is_valid_python() -> None: + compile( + validator.ASYNC_SMOKE_TEST_SOURCE, + "release_async_smoke_test.py", + "exec", + ) + + def test_smoke_test_labels_reverted_strict_defaults() -> None: """A reverted strict default must fail with an explanatory message. @@ -798,6 +953,24 @@ def test_smoke_test_labels_reverted_strict_defaults() -> None: assert message in source +def test_async_smoke_test_labels_reverted_strict_defaults() -> None: + assert validator.ASYNC_MLB_STRICT_DEFAULT_MESSAGE == ( + "AsyncMlb.strict_http must default to True for the 1.1 contract" + ) + assert validator.ASYNC_ADAPTER_STRICT_DEFAULT_MESSAGE == ( + "AsyncMlbDataAdapter.strict_http must default to True for the 1.1 contract" + ) + + source = validator.ASYNC_SMOKE_TEST_SOURCE + assert 'async_mlb_init["strict_http"].default is True' in source + assert 'async_adapter_init["strict_http"].default is True' in source + for message in ( + validator.ASYNC_MLB_STRICT_DEFAULT_MESSAGE, + validator.ASYNC_ADAPTER_STRICT_DEFAULT_MESSAGE, + ): + assert message in source + + def test_smoke_test_asserts_strict_http_default() -> None: """The installed-artifact smoke test must match the 1.0 strict default.""" text = VALIDATE_RELEASE.read_text(encoding="utf-8") @@ -846,6 +1019,48 @@ def test_smoke_test_checks_library_created_session_configuration() -> None: assert "create_retry_policy() must return a new Retry instance per call" in source +def test_async_smoke_test_checks_optional_public_surface_and_httpx_metadata() -> None: + source = validator.ASYNC_SMOKE_TEST_SOURCE + + assert "import httpx" in source + assert 'importlib.metadata.version("httpx")' in source + assert " AsyncMlb,\n" in source + assert " AsyncMlbDataAdapter,\n" in source + assert 'for name in ("AsyncMlb", "AsyncMlbDataAdapter")' in source + + +def test_async_smoke_test_checks_lifecycle_strict_modes_and_ownership() -> None: + source = validator.ASYNC_SMOKE_TEST_SOURCE + + assert "async with client as entered:" in source + assert "assert entered is client" in source + assert source.count("await explicitly_closed.aclose()") == 2 + assert "AsyncMlb(client=caller_client)" in source + assert "strict_http=False" in source + assert "MlbHttpError" in source + assert "MlbHttpCompatibilityWarning" in source + assert "AsyncMlb must not close a caller-injected httpx.AsyncClient" in source + assert 'f"python-mlb-statsapi/{expected_version}"' in source + + +def test_async_smoke_test_runs_against_the_installed_distribution() -> None: + source = validator.ASYNC_SMOKE_TEST_SOURCE + + assert "sys.prefix != sys.base_prefix" in source + assert 'sysconfig.get_paths()["purelib"]' in source + assert "is_relative_to(site_packages)" in source + assert 'importlib.metadata.version("python-mlb-statsapi")' in source + + +def test_async_smoke_test_makes_no_live_mlb_request() -> None: + source = validator.ASYNC_SMOKE_TEST_SOURCE + + assert "httpx.get(" not in source + assert "httpx.request(" not in source + assert "httpx.MockTransport(forbidden_response)" in source + assert "never reaches the MLB API" in source + + @pytest.mark.parametrize( "symbol", ( From c54f23faa48fa8f424a868298d711de769ff5252 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 19:09:58 +0000 Subject: [PATCH 81/81] test: validate standalone async adapter artifact lifecycle The async installed-artifact smoke test exercised AsyncMlbDataAdapter only indirectly through AsyncMlb(), which owns and passes its own client. Add a direct check that constructs the public standalone AsyncMlbDataAdapter() with client=None, confirming its default strict_http=True, its library-owned httpx.AsyncClient, and safe/ idempotent cleanup via aclose(). --- scripts/validate_release.py | 31 +++++++++++++++++++++++++++++++ tests/test_release_validation.py | 16 ++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/scripts/validate_release.py b/scripts/validate_release.py index 166d1ec9..e05bb45f 100644 --- a/scripts/validate_release.py +++ b/scripts/validate_release.py @@ -759,9 +759,40 @@ async def check_strict_http_and_caller_ownership() -> None: assert caller_client.is_closed is True +async def check_standalone_data_adapter_library_owned_lifecycle() -> None: + """Directly exercise AsyncMlbDataAdapter() and its own library-owned client. + + AsyncMlb() only ever constructs AsyncMlbDataAdapter through its own client, + so this constructs the public standalone adapter with client=None to prove + the library-owned-client construction and cleanup path independently. + """ + expected_user_agent = f"python-mlb-statsapi/{expected_version}" + + adapter = AsyncMlbDataAdapter() + owned_httpx_client = adapter._client + assert adapter._owns_client is True, ( + "AsyncMlbDataAdapter() must own the httpx.AsyncClient it creates" + ) + assert adapter._strict_http is True, ASYNC_ADAPTER_STRICT_DEFAULT_MESSAGE + assert owned_httpx_client.headers["User-Agent"] == expected_user_agent, ( + f"library-created httpx.AsyncClient sends User-Agent " + f"{owned_httpx_client.headers['User-Agent']!r}, expected " + f"{expected_user_agent!r}" + ) + assert owned_httpx_client.is_closed is False + + await adapter.aclose() + assert owned_httpx_client.is_closed is True + + # Repeated cleanup of a library-owned client is safe/idempotent. + await adapter.aclose() + assert owned_httpx_client.is_closed is True + + async def main() -> None: await check_library_owned_lifecycle_and_user_agent() await check_strict_http_and_caller_ownership() + await check_standalone_data_adapter_library_owned_lifecycle() asyncio.run(main()) diff --git a/tests/test_release_validation.py b/tests/test_release_validation.py index 198e8dc9..444983a7 100644 --- a/tests/test_release_validation.py +++ b/tests/test_release_validation.py @@ -1043,6 +1043,22 @@ def test_async_smoke_test_checks_lifecycle_strict_modes_and_ownership() -> None: assert 'f"python-mlb-statsapi/{expected_version}"' in source +def test_async_smoke_test_checks_standalone_data_adapter_lifecycle() -> None: + """AsyncMlbDataAdapter()'s own library-owned client path must be exercised + directly, not only indirectly through AsyncMlb().""" + source = validator.ASYNC_SMOKE_TEST_SOURCE + + assert ( + "async def check_standalone_data_adapter_library_owned_lifecycle" + in source + ) + assert "AsyncMlbDataAdapter()" in source + assert "adapter._owns_client is True" in source + assert "adapter._strict_http is True" in source + assert source.count("await adapter.aclose()") == 2 + assert "check_standalone_data_adapter_library_owned_lifecycle()" in source + + def test_async_smoke_test_runs_against_the_installed_distribution() -> None: source = validator.ASYNC_SMOKE_TEST_SOURCE