From b71e60544cb7083b9afacaf5166223c847ca0f40 Mon Sep 17 00:00:00 2001 From: ***** <721466+soodoku@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:36:30 -0700 Subject: [PATCH 1/5] Add innings fours/sixes and over-by-over runs Both come from data already on the match page. ESPN's innings fours and sixes equal the batters' sum where both exist (15/4 on the fixture); older matches carry only the batters' figures, so fall back to those, counting batters who batted. inningOvers per-over runs sum to the innings total; early Tests have none, so those innings return an empty list. Refs #54, #42 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 +++ README.md | 2 +- docs/api/matches.md | 5 ++++ espncricinfo/match.py | 59 ++++++++++++++++++++++++++++++++++++++++++- tests/test_match.py | 41 ++++++++++++++++++++++++++++++ 5 files changed, 109 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7788fe1..bac2102 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ - `Match(match_id)` works without a series ID: the match page is found from the match ID and `series_id` is read from it (#50). +- Each `Match.innings` entry has `fours` and `sixes`: ESPN's innings total, + else the sum over batters who batted, else None (#54). +- `Match.over_by_over`: runs and wickets per over for each innings, typed as + `OverEntry` rows; empty where ESPN has no over data (#42). - `Series.season(key)` finds a season by year or by ESPNcricinfo's two-year label (`"2024/25"`), and `Season.label` gives that label. Multi-year seasons live under their own series ID, now reachable as `season.series_id` (#76). diff --git a/README.md b/README.md index 7815bd9..72ec0ac 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ A match needs only its match ID, the number in a match page URL. Passing the ser ('D Brown', 0) ``` -`batting_scorecard` and `bowling_scorecard` hold one list per innings and one dict per player. +`batting_scorecard` and `bowling_scorecard` hold one list per innings and one dict per player. `over_by_over` gives runs and wickets for every over, and each `m.innings` entry has `fours` and `sixes`. ## Players diff --git a/docs/api/matches.md b/docs/api/matches.md index c02a6a5..fd06329 100644 --- a/docs/api/matches.md +++ b/docs/api/matches.md @@ -24,6 +24,11 @@ their keys and value types. :undoc-members: :member-order: bysource +.. autoclass:: espncricinfo.match.OverEntry + :members: + :undoc-members: + :member-order: bysource + .. autoclass:: espncricinfo.match.BowlingEntry :members: :undoc-members: diff --git a/espncricinfo/match.py b/espncricinfo/match.py index 7ddbfc1..f9e895c 100644 --- a/espncricinfo/match.py +++ b/espncricinfo/match.py @@ -66,6 +66,24 @@ def _playwright_fetch(url: str) -> dict[str, Any]: return pool.submit(lambda: asyncio.run(_async_playwright_fetch(url))).result() +def _boundary_count(inn: dict[str, Any], key: str) -> int | None: + """Innings fours or sixes: ESPN's total, else the batters' sum, else None. + + Where ESPN gives both, the total equals the batters' sum; older matches + often carry only the batters' figures, and the oldest carry neither. + """ + if inn.get(key) is not None: + return inn[key] + counts = [ + bat.get(key) + for bat in inn.get("inningBatsmen") or [] + if bat.get("battedType") == "yes" + ] + if not counts or any(count is None for count in counts): + return None + return sum(counts) + + def _as_float(record: dict[str, Any] | None, key: str) -> float | None: """Return ``record[key]`` as a float, or None if absent or not numeric.""" if record is None: @@ -176,6 +194,9 @@ def build_team(t): "inningBatsmen": inn.get("inningBatsmen", []), "inningBowlers": inn.get("inningBowlers", []), "inningFallOfWickets": inn.get("inningFallOfWickets", []), + "inningOvers": inn.get("inningOvers") or [], + "fours": _boundary_count(inn, "fours"), + "sixes": _boundary_count(inn, "sixes"), "extras": inn.get("extras"), "byes": inn.get("byes"), "legbyes": inn.get("legbyes"), @@ -314,6 +335,16 @@ class BattingEntry(TypedDict): batted: bool +class OverEntry(TypedDict): + """One over of :attr:`Match.over_by_over`.""" + + over: int + runs: int + wickets: int + total_runs: int + total_wickets: int + + class BowlingEntry(TypedDict): """One row of :attr:`Match.bowling_scorecard`.""" @@ -373,7 +404,8 @@ class Match: scheduled_overs: int | None #: Innings numbers as strings, ``['1', '2']``. innings_list: list[str] - #: One dict per innings: team id, runs, wickets, overs and ESPN's raw lists. + #: One dict per innings: team id, runs, wickets, overs, ``fours``, ``sixes`` + #: (None where ESPN has no figures) and ESPN's raw lists. innings: list[dict[str, Any]] #: ESPN's raw innings records, including partnerships and commentary. all_innings: list[dict[str, Any]] @@ -671,6 +703,31 @@ def bowling_scorecard(self) -> list[list[BowlingEntry]]: result.append([]) return result + @property + def over_by_over(self) -> list[list[OverEntry]]: + """Runs and wickets per over: one list per innings, one row per over. + + Empty for an innings ESPN has no over data for, as with early Tests. + + Example:: + + for over in m.over_by_over[0]: + print(over["over"], over["runs"], over["total_runs"]) + """ + return [ + [ + OverEntry( + over=o["overNumber"], + runs=o["overRuns"], + wickets=o["overWickets"], + total_runs=o["totalRuns"], + total_wickets=o["totalWickets"], + ) + for o in inn.get("inningOvers") or [] + ] + for inn in self.innings + ] + # ------------------------------------------------------------------ # Static helpers # ------------------------------------------------------------------ diff --git a/tests/test_match.py b/tests/test_match.py index 7cf7a56..f7a17bd 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -468,3 +468,44 @@ def test_live_match_from_id_alone(): match = Match(729303) assert match.series_id == 695871 assert match.batting_scorecard[0] + + +def test_innings_fours_and_sixes_match_espn_totals(): + match = _make_match() + first = match.innings[0] + assert (first["fours"], first["sixes"]) == (15, 4) + for inn in match.innings: + batters = inn["inningBatsmen"] + assert inn["fours"] == sum(b["fours"] or 0 for b in batters) + assert inn["sixes"] == sum(b["sixes"] or 0 for b in batters) + + +def test_boundary_count_falls_back_to_batters_then_none(): + from espncricinfo.match import _boundary_count + + assert _boundary_count({"fours": 9, "inningBatsmen": []}, "fours") == 9 + batted = {"battedType": "yes"} + batters = [{**batted, "fours": 2}, {**batted, "fours": 5}] + did_not_bat = {"battedType": "DNB", "fours": None} + assert _boundary_count({"inningBatsmen": [*batters, did_not_bat]}, "fours") == 7 + assert ( + _boundary_count({"inningBatsmen": [{**batted, "fours": None}]}, "fours") is None + ) + assert _boundary_count({"inningBatsmen": []}, "fours") is None + + +def test_over_by_over_adds_up_to_innings_totals(): + match = _make_match() + assert [len(overs) for overs in match.over_by_over] == [20, 20] + for overs, inn in zip(match.over_by_over, match.innings, strict=True): + assert sum(o["runs"] for o in overs) == inn["runs"] + assert sum(o["wickets"] for o in overs) == inn["wickets"] + assert overs[-1]["total_runs"] == inn["runs"] + assert [o["over"] for o in overs] == list(range(1, 21)) + + +@pytest.mark.integration +def test_live_old_test_has_no_over_data_and_no_invented_boundaries(): + match = Match(63963, 61791) + assert all(overs == [] for overs in match.over_by_over) + assert all(inn["fours"] is None or inn["fours"] >= 0 for inn in match.innings) From 44a353890d158d1eadd77e3739433213089d219f Mon Sep 17 00:00:00 2001 From: ***** <721466+soodoku@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:47:31 -0700 Subject: [PATCH 2/5] Add Match.get_matches_on for one day's matches The results page lists about two weeks of matches; every item carries start and end dates, so filter to those spanning the day. Shares the page request with get_recent_matches. Live: 39 of 226 matches on 2026-10-20, spot-checked against their match pages. Refs #28 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 + README.md | 2 +- espncricinfo/match.py | 98 ++++++++++++++++++++++++++----------------- tests/test_match.py | 44 +++++++++++++++++++ 4 files changed, 107 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bac2102..2d47e4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ ### Added +- `Match.get_matches_on(date)`: the matches being played on one day, Tests + included on each of their days (#28). - `Match(match_id)` works without a series ID: the match page is found from the match ID and `series_id` is read from it (#50). - Each `Match.innings` entry has `fours` and `sixes`: ESPN's innings total, diff --git a/README.md b/README.md index 72ec0ac..df4b1b8 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ MatchRef(series_id=1523975, match_id=1523981) >>> series_id, match_id = refs[0] # MatchRefs unpack like tuples ``` -`Summary(date=...)` wraps the same call; its result is in `.matches`. +For one day only, `Match.get_matches_on("2026-10-20")` keeps the matches played that day, multi-day matches included on each of their days. `Summary(date=...)` wraps the same call; its result is in `.matches`. ## A single match diff --git a/espncricinfo/match.py b/espncricinfo/match.py index f9e895c..3a5272f 100644 --- a/espncricinfo/match.py +++ b/espncricinfo/match.py @@ -736,44 +736,66 @@ def over_by_over(self) -> list[list[OverEntry]]: def get_recent_matches(date: _date | str | None = None) -> list[MatchRef]: """Return MatchRefs for the matches on ESPNcricinfo's results page. - Fetches https://www.espncricinfo.com/live-cricket-match-results - (with ?date=DD-MM-YYYY) and extracts match data from - __NEXT_DATA__ at props.appPageProps.data.data.content.matches. - ESPN returns roughly the two weeks of matches ending on that date. - - ``date`` may be a ``datetime.date`` or a string in YYYY-MM-DD or - DD-MM-YYYY form; it defaults to today. ESPN answers the YYYY-MM-DD - form with an error page, so it is converted before the request. + ESPN returns roughly the two weeks of matches ending on ``date``. Use + :meth:`get_matches_on` for the matches played on one day. + + Args: + date: A ``datetime.date`` or a string in YYYY-MM-DD or DD-MM-YYYY + form; defaults to today. + + Returns: + The listed matches, most recent first. """ - if date is None: - day = _datetime.now().astimezone().date() - elif isinstance(date, _date): - day = date - else: - try: - day = _date.fromisoformat(date) - except ValueError: - dd, mm, yyyy = (int(part) for part in date.split("-")) - day = _date(yyyy, mm, dd) - url = ( - "https://www.espncricinfo.com/live-cricket-match-results" - f"?date={day:%d-%m-%Y}" - ) + return [ref for ref, _, _ in _results_page(_as_day(date))] + + @staticmethod + def get_matches_on(date: _date | str | None = None) -> list[MatchRef]: + """Return MatchRefs for the matches being played on one day. + + A match counts if the day falls between its start and end dates, so a + Test appears on each of its days. + + Args: + date: A ``datetime.date`` or a string in YYYY-MM-DD or DD-MM-YYYY + form; defaults to today. - next_data = _playwright_fetch(url) + Returns: + The matches on that day. + """ + day = _as_day(date) + return [ref for ref, start, end in _results_page(day) if start <= day <= end] + + +def _as_day(date: _date | str | None) -> _date: + """Parse a date argument; ESPN's results page wants DD-MM-YYYY.""" + if date is None: + return _datetime.now().astimezone().date() + if isinstance(date, _date): + return date + try: + return _date.fromisoformat(date) + except ValueError: + dd, mm, yyyy = (int(part) for part in date.split("-")) + return _date(yyyy, mm, dd) + + +def _results_page(day: _date) -> list[tuple[MatchRef, _date, _date]]: + """(MatchRef, start date, end date) for each match on the results page.""" + url = f"https://www.espncricinfo.com/live-cricket-match-results?date={day:%d-%m-%Y}" + next_data = _playwright_fetch(url) + try: + matches = next_data["props"]["appPageProps"]["data"]["data"]["content"][ + "matches" + ] + except (KeyError, TypeError): + return [] + results = [] + for m in matches: try: - matches = next_data["props"]["appPageProps"]["data"]["data"]["content"][ - "matches" - ] - except (KeyError, TypeError): - return [] - - results = [] - for m in matches: - try: - series_id = m["series"]["objectId"] - match_id = m["objectId"] - results.append(MatchRef(series_id=series_id, match_id=match_id)) - except (KeyError, TypeError): - continue - return results + ref = MatchRef(series_id=m["series"]["objectId"], match_id=m["objectId"]) + start = _date.fromisoformat(m["startDate"][:10]) + end = _date.fromisoformat((m.get("endDate") or m["startDate"])[:10]) + except (KeyError, TypeError, ValueError): + continue + results.append((ref, start, end)) + return results diff --git a/tests/test_match.py b/tests/test_match.py index f7a17bd..9cc3c16 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -509,3 +509,47 @@ def test_live_old_test_has_no_over_data_and_no_invented_boundaries(): match = Match(63963, 61791) assert all(overs == [] for overs in match.over_by_over) assert all(inn["fours"] is None or inn["fours"] >= 0 for inn in match.innings) + + +def _results_page_with(*matches): + items = [ + { + "objectId": mid, + "series": {"objectId": 1}, + "startDate": f"{start}T00:00:00.000Z", + "endDate": f"{end}T00:00:00.000Z", + } + for mid, start, end in matches + ] + return { + "props": {"appPageProps": {"data": {"data": {"content": {"matches": items}}}}} + } + + +def test_get_matches_on_keeps_matches_spanning_the_day(): + page = _results_page_with( + (10, "2026-10-20", "2026-10-20"), # one-day match on the day + (11, "2026-10-19", "2026-10-22"), # Test in progress + (12, "2026-10-19", "2026-10-19"), # finished the day before + (13, "2026-10-21", "2026-10-21"), # next day + ) + with patch("espncricinfo.match._playwright_fetch", return_value=page) as fetch: + refs = Match.get_matches_on("2026-10-20") + assert [r.match_id for r in refs] == [10, 11] + assert fetch.call_args.args[0].endswith("?date=20-10-2026") + + +def test_get_recent_matches_still_returns_every_listed_match(): + page = _results_page_with( + (10, "2026-10-20", "2026-10-20"), (12, "2026-10-19", "2026-10-19") + ) + with patch("espncricinfo.match._playwright_fetch", return_value=page): + assert [r.match_id for r in Match.get_recent_matches("2026-10-20")] == [10, 12] + + +@pytest.mark.integration +def test_live_matches_on_a_day_are_a_subset_of_recent_matches(): + on_day = Match.get_matches_on("2026-10-20") + recent = Match.get_recent_matches("2026-10-20") + assert on_day + assert set(on_day) < set(recent) From 71e22029bd3bee7547400ae7bfde9c0ac3a44356 Mon Sep 17 00:00:00 2001 From: ***** <721466+soodoku@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:49:00 -0700 Subject: [PATCH 3/5] Add Match.get_match_ids to list a year's matches by format Statsguru's per-year results table links every international match (format codes confirmed live: 1 Test, 2 ODI, 3 T20I, 8/9/10 women's). With Match(match_id) this answers bulk-scorecard requests. Live: 34 Tests in 2023; the first 1990 id loads as Australia v Pakistan, Australia won by 92 runs. Refs #34 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 +++ README.md | 10 ++++++++++ espncricinfo/match.py | 35 +++++++++++++++++++++++++++++++++++ tests/test_match.py | 24 ++++++++++++++++++++++++ 4 files changed, 72 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d47e4f..e25e473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ ### Added +- `Match.get_match_ids(year, match_format)`: every international match id of + one format in a year, from Statsguru, back to 1877. With `Match(match_id)` + this loads every scorecard of a format (#34). - `Match.get_matches_on(date)`: the matches being played on one day, Tests included on each of their days (#28). - `Match(match_id)` works without a series ID: the match page is found from diff --git a/README.md b/README.md index df4b1b8..116e72d 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,16 @@ A match needs only its match ID, the number in a match page URL. Passing the ser ('D Brown', 0) ``` +To work through every match of a format, list a year's match IDs from Statsguru (1 Test, 2 ODI, 3 T20I, 8/9/10 the women's formats) and load each one. Each load starts a browser, so a year of Tests takes a minute or two: + +```python +>>> ids = Match.get_match_ids(2023, 1) +>>> len(ids) +34 +>>> Match(ids[0]).description +'New Zealand v Pakistan' +``` + `batting_scorecard` and `bowling_scorecard` hold one list per innings and one dict per player. `over_by_over` gives runs and wickets for every over, and each `m.innings` entry has `fours` and `sixes`. ## Players diff --git a/espncricinfo/match.py b/espncricinfo/match.py index 3a5272f..b38306e 100644 --- a/espncricinfo/match.py +++ b/espncricinfo/match.py @@ -2,13 +2,16 @@ import asyncio import json +import re from concurrent.futures import ThreadPoolExecutor from datetime import date as _date from datetime import datetime as _datetime from typing import Any, TypedDict +from bs4 import BeautifulSoup from playwright.async_api import async_playwright +from espncricinfo import _core from espncricinfo.exceptions import MatchNotFoundError, NoScorecardError from espncricinfo.match_ref import MatchRef @@ -765,6 +768,38 @@ def get_matches_on(date: _date | str | None = None) -> list[MatchRef]: day = _as_day(date) return [ref for ref, start, end in _results_page(day) if start <= day <= end] + @staticmethod + def get_match_ids(year: int, match_format: int = 1) -> list[int]: + """Return the ids of every international match of one format in a year. + + Reads Statsguru's results table, so it covers matches back to 1877. + Pass each id to :class:`Match` to load it; each load starts a browser. + + Args: + year: Calendar year, e.g. ``2023``. + match_format: Statsguru class: 1 Test, 2 ODI, 3 T20I, + 8 women's Test, 9 women's ODI, 10 women's T20I. + + Returns: + Match ids in the order Statsguru lists them (by date). + """ + response = _core.http_get( + "https://stats.espncricinfo.com/ci/engine/records/team/" + f"match_results.html?class={match_format};id={year};type=year" + ) + response.raise_for_status() + soup = BeautifulSoup(response.text, "html.parser") + ids: list[int] = [] + for row in soup.select("table.engineTable tr.data1"): + for link in row.find_all("a", href=True): + found = _MATCH_LINK.search(str(link["href"])) + if found and int(found.group(1)) not in ids: + ids.append(int(found.group(1))) + return ids + + +_MATCH_LINK = re.compile(r"/ci/engine/match/(\d+)\.html") + def _as_day(date: _date | str | None) -> _date: """Parse a date argument; ESPN's results page wants DD-MM-YYYY.""" diff --git a/tests/test_match.py b/tests/test_match.py index 9cc3c16..5de9957 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -553,3 +553,27 @@ def test_live_matches_on_a_day_are_a_subset_of_recent_matches(): recent = Match.get_recent_matches("2026-10-20") assert on_day assert set(on_day) < set(recent) + + +def test_get_match_ids_reads_statsguru_rows_in_order_without_duplicates(): + from unittest.mock import MagicMock + + html = """ + + + + +
KarachiTest # 2487
Test # 2488again
""" + response = MagicMock(text=html) + with patch("espncricinfo._core.http_get", return_value=response) as get: + assert Match.get_match_ids(2023, 1) == [1322355, 1322356] + assert "class=1;id=2023;type=year" in get.call_args.args[0] + + +@pytest.mark.integration +def test_live_2023_test_ids_load_as_2023_tests(): + ids = Match.get_match_ids(2023, 1) + assert len(ids) == 34 + first = Match(ids[0]) + assert first.match_class == "Test" + assert first.date.startswith("2023") From 99583ba7fba0c732cd4d3cc7d2f11bda7d6d6671 Mon Sep 17 00:00:00 2001 From: ***** <721466+soodoku@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:50:16 -0700 Subject: [PATCH 4/5] Add a Ground object from ESPN's venue records The core API serves venues by the same id Match.ground_id holds (checked: Adelaide Oval 56293 on both), with city, country, capacity and indoor. Match.ground loads it. Live test: the venue record and the match page agree on the ground's name. Refs #8 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 ++ README.md | 2 ++ docs/api/grounds.md | 7 +++++ docs/api/index.md | 2 ++ espncricinfo/exceptions.py | 4 +++ espncricinfo/grounds.py | 53 +++++++++++++++++++++++++++++++++ espncricinfo/match.py | 7 +++++ tests/test_grounds.py | 61 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 138 insertions(+) create mode 100644 docs/api/grounds.md create mode 100644 espncricinfo/grounds.py create mode 100644 tests/test_grounds.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e25e473..0675c00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ ### Added +- `Ground` (city, country, capacity, indoor) from ESPN's venue records, and + `Match.ground` to load a match's ground (#8). - `Match.get_match_ids(year, match_format)`: every international match id of one format in a year, from Statsguru, back to 1877. With `Match(match_id)` this loads every scorecard of a format (#34). diff --git a/README.md b/README.md index 116e72d..8252ce5 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ To work through every match of a format, list a year's match IDs from Statsguru `batting_scorecard` and `bowling_scorecard` hold one list per innings and one dict per player. `over_by_over` gives runs and wickets for every over, and each `m.innings` entry has `fours` and `sixes`. +`m.ground` loads the ground record (`Ground` in `espncricinfo.grounds`): name, city, country, capacity. + ## Players The player ID is the number at the end of a player's profile URL. diff --git a/docs/api/grounds.md b/docs/api/grounds.md new file mode 100644 index 0000000..e90b4df --- /dev/null +++ b/docs/api/grounds.md @@ -0,0 +1,7 @@ +# Grounds + +```{eval-rst} +.. autoclass:: espncricinfo.grounds.Ground + :members: + :member-order: bysource +``` diff --git a/docs/api/index.md b/docs/api/index.md index 02bdc4d..6a85b23 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -18,6 +18,7 @@ types shown here. series.Series seasons.Season teams.Team + grounds.Ground ``` ```{toctree} @@ -26,5 +27,6 @@ types shown here. matches players series +grounds exceptions ``` diff --git a/espncricinfo/exceptions.py b/espncricinfo/exceptions.py index 625ecc2..89e3efd 100644 --- a/espncricinfo/exceptions.py +++ b/espncricinfo/exceptions.py @@ -23,3 +23,7 @@ class NoSeasonError(TypeError): class NoTeamError(TypeError): """Exception raised if a team does not exist in a series.""" + + +class NoGroundError(TypeError): + """Raised when a ground does not exist.""" diff --git a/espncricinfo/grounds.py b/espncricinfo/grounds.py new file mode 100644 index 0000000..15f0183 --- /dev/null +++ b/espncricinfo/grounds.py @@ -0,0 +1,53 @@ +"""Grounds (ESPN "venues") from the ESPN core API.""" + +from typing import Any + +from espncricinfo._core import get_json +from espncricinfo.exceptions import NoGroundError + +_VENUES = "http://core.espnuk.org/v2/sports/cricket/venues" + + +class Ground: + """A cricket ground. Example values are for ``Ground(56293)``.""" + + #: ESPNcricinfo ground id, as in ``Match.ground_id``. + ground_id: int + #: ``'Adelaide Oval'``. + name: str + #: Short name, often the same as ``name``. + short_name: str | None + #: ``'Adelaide'``, or None. + city: str | None + #: ``'Australia'``, or None. + country: str | None + #: Seating capacity, or None if ESPN has none. + capacity: int | None + #: True for an indoor ground, or None if unknown. + indoor: bool | None + #: The core API venue record. + json: dict[str, Any] + + def __init__(self, ground_id: int | str) -> None: + """Load the ground record. + + Args: + ground_id: ESPNcricinfo ground id, e.g. ``Match.ground_id``. + """ + self.ground_id = int(ground_id) + self.json = get_json(f"{_VENUES}/{self.ground_id}", NoGroundError) + address = self.json.get("address") or {} + self.name = self.json["fullName"] + self.short_name = self.json.get("shortName") + self.city = address.get("city") or None + self.country = address.get("country") or None + self.capacity = self.json.get("capacity") or None + self.indoor = self.json.get("indoor") + + def __str__(self) -> str: + """Return the ground's name.""" + return self.name + + def __repr__(self) -> str: + """Return ``Ground(ground_id)``.""" + return f"Ground({self.ground_id})" diff --git a/espncricinfo/match.py b/espncricinfo/match.py index b38306e..3253ef4 100644 --- a/espncricinfo/match.py +++ b/espncricinfo/match.py @@ -6,6 +6,7 @@ from concurrent.futures import ThreadPoolExecutor from datetime import date as _date from datetime import datetime as _datetime +from functools import cached_property from typing import Any, TypedDict from bs4 import BeautifulSoup @@ -13,6 +14,7 @@ from espncricinfo import _core from espncricinfo.exceptions import MatchNotFoundError, NoScorecardError +from espncricinfo.grounds import Ground from espncricinfo.match_ref import MatchRef @@ -706,6 +708,11 @@ def bowling_scorecard(self) -> list[list[BowlingEntry]]: result.append([]) return result + @cached_property + def ground(self) -> Ground: + """The match's ground, loaded on first access (one request).""" + return Ground(self.ground_id) + @property def over_by_over(self) -> list[list[OverEntry]]: """Runs and wickets per over: one list per innings, one row per over. diff --git a/tests/test_grounds.py b/tests/test_grounds.py new file mode 100644 index 0000000..6b529ab --- /dev/null +++ b/tests/test_grounds.py @@ -0,0 +1,61 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from espncricinfo.exceptions import NoGroundError +from espncricinfo.grounds import Ground + +VENUE = { + "id": "56293", + "fullName": "Adelaide Oval", + "shortName": "Adelaide Oval", + "capacity": 50000, + "indoor": False, + "address": {"city": "Adelaide", "state": "", "country": "Australia"}, +} + + +def _response(status, data=None): + response = MagicMock(status_code=status) + response.json.return_value = data + return response + + +def test_ground_reads_core_venue_record(): + with patch( + "espncricinfo._core.http_get", return_value=_response(200, VENUE) + ) as get: + ground = Ground(56293) + assert get.call_args.args[0].endswith("/venues/56293") + assert (ground.name, ground.city, ground.country) == ( + "Adelaide Oval", + "Adelaide", + "Australia", + ) + assert (ground.capacity, ground.indoor) == (50000, False) + assert str(ground) == "Adelaide Oval" + + +def test_missing_fields_become_none(): + with patch( + "espncricinfo._core.http_get", return_value=_response(200, {"fullName": "X"}) + ): + ground = Ground(1) + assert (ground.city, ground.country, ground.capacity) == (None, None, None) + + +def test_unknown_ground_raises(): + with ( + patch("espncricinfo._core.http_get", return_value=_response(404)), + pytest.raises(NoGroundError), + ): + Ground(999999999) + + +@pytest.mark.integration +def test_live_match_ground_matches_the_match_page(): + from espncricinfo.match import Match + + match = Match(1478914, 1478874) + assert match.ground.name == match.ground_name == "Adelaide Oval" + assert match.ground.country == "Australia" From debf3481833fc35be86077481f946ea98523bb1b Mon Sep 17 00:00:00 2001 From: ***** <721466+soodoku@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:58:42 -0700 Subject: [PATCH 5/5] Release 1.1.0 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 2 ++ CITATION.cff | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0675c00..fe13046 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +## [1.1.0] - 2026-10-23 + ### Fixed - Match pages and `Summary()` load inside Jupyter and other code that already diff --git a/CITATION.cff b/CITATION.cff index 8f2f8a8..efe9bf4 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -6,7 +6,7 @@ authors: - family-names: "Sood" given-names: "Gaurav" title: "python-espncricinfo: A Python client for ESPNcricinfo match, series and player data" -version: 1.0.1 +version: 1.1.0 date-released: 2026-10-23 url: "https://github.com/outside-edge/python-espncricinfo" repository-code: "https://github.com/outside-edge/python-espncricinfo" diff --git a/pyproject.toml b/pyproject.toml index 8018c3c..2471343 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "uv_build" [project] name = "python-espncricinfo" -version = "1.0.1" +version = "1.1.0" description = "Python client for ESPNcricinfo match, series, season, team and player data" readme = "README.md" requires-python = ">=3.12" diff --git a/uv.lock b/uv.lock index dc2aa6b..26a2926 100644 --- a/uv.lock +++ b/uv.lock @@ -932,7 +932,7 @@ wheels = [ [[package]] name = "python-espncricinfo" -version = "1.0.1" +version = "1.1.0" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" },