diff --git a/README.md b/README.md index 806a666..6b14de4 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,21 @@ For series (or league) details, pass in the series ID (found in a match URL, for 'India tour of Ireland and England 2018' ``` +Navigate from a series to its seasons, teams, and matches: + +```python +from espncricinfo.series import Series + +series = Series(1478874) +season = series.current_season +for team in season.teams: + print(team.name, team.match) +for ref in series.matches: + print(ref.series_id, ref.match_id) +``` + +`series.season_objects` lists all seasons. `season.match_refs` lists that season's matches, while `series.current_matches` contains only matches whose ESPN status is in progress. These match lists contain lightweight `MatchRef` objects; loading a full `Match` launches Playwright. `team.players` loads player records on demand. + ### Tests **Unit tests** (no network required — uses recorded fixtures): diff --git a/espncricinfo/_core.py b/espncricinfo/_core.py new file mode 100644 index 0000000..051ebde --- /dev/null +++ b/espncricinfo/_core.py @@ -0,0 +1,32 @@ +import re + +import requests + +HEADERS = {"user-agent": "Mozilla/5.0"} + + +def reference_ids(url, resource): + match = re.search(rf"/leagues/(\d+)/{resource}/(\d+)(?:/|$)", url) + if match is None: + raise ValueError(f"Invalid {resource} reference: {url}") + return int(match.group(1)), int(match.group(2)) + + +def get_json(url, not_found_error): + response = requests.get(url, headers=HEADERS, timeout=30) + if response.status_code == 404: + raise not_found_error(f"Not found: {url}") + response.raise_for_status() + return response.json() + + +def collection_refs(url, not_found_error): + references = [] + page = 1 + while True: + page_url = url if page == 1 else f"{url}?page={page}" + data = get_json(page_url, not_found_error) + references.extend(item["$ref"] for item in data.get("items", [])) + if page >= data.get("pageCount", 1): + return references + page += 1 diff --git a/espncricinfo/exceptions.py b/espncricinfo/exceptions.py index ac62f11..41f17f5 100644 --- a/espncricinfo/exceptions.py +++ b/espncricinfo/exceptions.py @@ -31,3 +31,11 @@ class NoSeriesError(TypeError): """ pass + + +class NoSeasonError(TypeError): + """Exception raised if a season does not exist.""" + + +class NoTeamError(TypeError): + """Exception raised if a team does not exist in a series.""" diff --git a/espncricinfo/match_ref.py b/espncricinfo/match_ref.py index ab16ea0..73c6380 100644 --- a/espncricinfo/match_ref.py +++ b/espncricinfo/match_ref.py @@ -3,6 +3,8 @@ from dataclasses import dataclass from typing import TYPE_CHECKING +from espncricinfo._core import reference_ids + if TYPE_CHECKING: from espncricinfo.match import Match @@ -48,6 +50,12 @@ def from_dict(cls, d: dict) -> "MatchRef": """Construct from a mapping with ``series_id`` and ``match_id`` keys.""" return cls(series_id=d["series_id"], match_id=d["match_id"]) + @classmethod + def from_core_event_url(cls, url: str) -> "MatchRef": + """Construct from a core ESPN event reference.""" + series_id, match_id = reference_ids(url, "events") + return cls(series_id=series_id, match_id=match_id) + def to_csv_row(self) -> list: """Return ``[str(series_id), str(match_id)]`` for use with ``csv.writer``.""" return [str(self.series_id), str(self.match_id)] diff --git a/espncricinfo/seasons.py b/espncricinfo/seasons.py new file mode 100644 index 0000000..b22f7e1 --- /dev/null +++ b/espncricinfo/seasons.py @@ -0,0 +1,62 @@ +from datetime import datetime + +from espncricinfo._core import collection_refs, get_json, reference_ids +from espncricinfo.exceptions import NoSeasonError +from espncricinfo.match_ref import MatchRef +from espncricinfo.teams import Team + + +class Season: + def __init__(self, season_id, series_id): + self.id = int(season_id) + self.series_id = int(series_id) + self.url = ( + "http://core.espnuk.org/v2/sports/cricket/leagues/" + f"{self.series_id}/seasons/{self.id}" + ) + self.json = get_json(self.url, NoSeasonError) + self.year = int(self.json.get("year", self.id)) + self.name = self.json.get("name") + self.short_name = self.json.get("shortName") + self.start_date = self._parse_date(self.json.get("startDate")) + self.end_date = self._parse_date(self.json.get("endDate")) + self._teams = None + self._match_refs = None + self._series = None + + @staticmethod + def _parse_date(value): + return datetime.fromisoformat(value.replace("Z", "+00:00")) if value else None + + @classmethod + def from_reference(cls, url): + series_id, season_id = reference_ids(url, "seasons") + return cls(season_id, series_id) + + @property + def teams(self): + if self._teams is None: + urls = collection_refs(f"{self.url}/teams", NoSeasonError) + self._teams = [Team.from_reference(url) for url in urls] + return self._teams + + @property + def match_refs(self): + if self._match_refs is None: + urls = collection_refs(f"{self.url}/events", NoSeasonError) + self._match_refs = [MatchRef.from_core_event_url(url) for url in urls] + return self._match_refs + + @property + def series(self): + if self._series is None: + from espncricinfo.series import Series + + self._series = Series(self.series_id) + return self._series + + def __str__(self): + return self.name or str(self.year) + + def __repr__(self): + return f"Season(id={self.id}, series_id={self.series_id}, name={self.name!r})" diff --git a/espncricinfo/series.py b/espncricinfo/series.py index fd2f54b..4a22564 100644 --- a/espncricinfo/series.py +++ b/espncricinfo/series.py @@ -1,6 +1,9 @@ import requests +from espncricinfo._core import collection_refs, get_json from espncricinfo.exceptions import NoSeriesError +from espncricinfo.match_ref import MatchRef +from espncricinfo.seasons import Season class Series(object): @@ -23,6 +26,10 @@ def __init__(self, series_id): self.json = self.get_json(self.json_url) self.seasons = self._get_seasons() self.years = self._get_years_from_seasons() + self._season_objects = None + self._current_season = None + self._match_refs = None + self._current_matches = None if self.json: self.name = self.json["name"] self.short_name = self.json["shortName"] @@ -70,3 +77,56 @@ def _build_events(self): for event in self.events_json: events.append(self.get_json(event["$ref"])) return events + + @property + def season_objects(self): + if self._season_objects is None: + self._season_objects = [Season.from_reference(url) for url in self.seasons] + return self._season_objects + + @property + def current_season(self): + if self._current_season is None: + reference = (self.json.get("season") or {}).get("$ref") + self._current_season = ( + Season.from_reference(reference) if reference else None + ) + return self._current_season + + @property + def matches(self): + if self._match_refs is None: + if self.seasons: + self._match_refs = list( + dict.fromkeys( + ref + for season in self.season_objects + for ref in season.match_refs + ) + ) + else: + reference = (self.json.get("events") or {}).get("$ref") + urls = collection_refs(reference or self.events_url, NoSeriesError) + self._match_refs = [MatchRef.from_core_event_url(url) for url in urls] + return self._match_refs + + @property + def current_matches(self): + if self._current_matches is None: + active = [] + for ref in self.matches: + url = ( + "http://core.espnuk.org/v2/sports/cricket/leagues/" + f"{ref.series_id}/events/{ref.match_id}" + ) + event = get_json(url, NoSeriesError) + for competition in event.get("competitions", []): + status_url = (competition.get("status") or {}).get("$ref") + if status_url and ( + get_json(status_url, NoSeriesError).get("type", {}).get("state") + == "in" + ): + active.append(ref) + break + self._current_matches = active + return self._current_matches diff --git a/espncricinfo/teams.py b/espncricinfo/teams.py new file mode 100644 index 0000000..3f16f28 --- /dev/null +++ b/espncricinfo/teams.py @@ -0,0 +1,62 @@ +from datetime import datetime + +from espncricinfo._core import collection_refs, get_json, reference_ids +from espncricinfo.exceptions import NoTeamError +from espncricinfo.match_ref import MatchRef + + +class Team: + def __init__(self, team_id, series_id): + self.id = int(team_id) + self.series_id = int(series_id) + self.url = ( + "http://core.espnuk.org/v2/sports/cricket/leagues/" + f"{self.series_id}/teams/{self.id}" + ) + self.json = get_json(self.url, NoTeamError) + self.name = self.json.get("name") + self.short_name = self.json.get("abbreviation") + self.location = self.json.get("location") + self.nickname = self.json.get("nickname") + self.slug = self.json.get("slug") + self.color = self.json.get("color") + self.logo = next( + (logo["href"] for logo in self.json.get("logos", []) if "href" in logo), + None, + ) + self.is_national = self.json.get("isNational") + self.is_active = self.json.get("isActive") + event = self.json.get("event") or {} + self.match_date = ( + datetime.fromisoformat(event["date"].replace("Z", "+00:00")) + if event.get("date") + else None + ) + self.match = ( + MatchRef.from_core_event_url(event["$ref"]) if event.get("$ref") else None + ) + self._players = None + + @classmethod + def from_reference(cls, url): + series_id, team_id = reference_ids(url, "teams") + return cls(team_id, series_id) + + @property + def players(self): + if self._players is None: + from espncricinfo.player import Player + + athletes = self.json.get("athletes") or {} + self._players = ( + [ + Player(int(url.rstrip("/").rsplit("/", 1)[-1])) + for url in collection_refs(athletes["$ref"], NoTeamError) + ] + if athletes.get("$ref") + else [] + ) + return self._players + + def __repr__(self): + return f"Team(id={self.id}, series_id={self.series_id}, name={self.name!r})" diff --git a/tests/test_navigation.py b/tests/test_navigation.py new file mode 100644 index 0000000..f8f26a9 --- /dev/null +++ b/tests/test_navigation.py @@ -0,0 +1,216 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from espncricinfo._core import reference_ids +from espncricinfo.exceptions import NoSeasonError, NoTeamError +from espncricinfo.match_ref import MatchRef +from espncricinfo.seasons import Season +from espncricinfo.series import Series +from espncricinfo.teams import Team + +ROOT = "http://core.espnuk.org/v2/sports/cricket/leagues" + + +def fake_get(routes): + def get(url, headers=None, timeout=None): + response = MagicMock() + if url not in routes: + raise AssertionError(f"Unexpected request: {url}") + status, data = routes[url] + response.status_code = status + response.json.return_value = data + return response + + return get + + +def test_team_uses_reference_league_and_resolves_match_without_browser(): + team_url = f"{ROOT}/23262/teams/1812" + athlete_url = f"{team_url}/athletes" + event_url = f"{ROOT}/24371/events/1535654" + routes = { + team_url: ( + 200, + { + "name": "India Women", + "abbreviation": "IND-W", + "isNational": False, + "isActive": True, + "logos": [{"href": "https://example.org/logo.png"}], + "event": {"$ref": event_url, "date": "2026-10-20T13:30:15Z"}, + "athletes": {"$ref": athlete_url}, + }, + ), + athlete_url: ( + 200, + { + "items": [ + {"$ref": "http://core.espnuk.org/v2/sports/cricket/athletes/253802"} + ] + }, + ), + } + with patch("espncricinfo._core.requests.get", side_effect=fake_get(routes)) as get: + team = Team.from_reference(team_url) + assert (team.id, team.series_id, team.name) == (1812, 23262, "India Women") + assert team.short_name == "IND-W" + assert team.logo == "https://example.org/logo.png" + assert team.match == MatchRef(24371, 1535654) + assert team.match_date.year == 2026 + assert team.match_date.second == 15 + assert get.call_count == 1 + with patch("espncricinfo.player.Player") as player: + assert team.players == [player.return_value] + assert team.players == [player.return_value] + player.assert_called_once_with(253802) + assert get.call_count == 2 + + +def test_season_loads_teams_and_all_event_pages_lazily(): + season_url = f"{ROOT}/23262/seasons/2026" + team_url = f"{ROOT}/23262/teams/1812" + routes = { + season_url: ( + 200, + { + "year": 2026, + "name": "India Women tour of Australia", + "startDate": "2026-02-13T00:00Z", + "endDate": "2026-03-09T23:59Z", + }, + ), + f"{season_url}/teams": (200, {"items": [{"$ref": team_url}]}), + team_url: (200, {"name": "India Women"}), + f"{season_url}/events": ( + 200, + {"pageCount": 2, "items": [{"$ref": f"{ROOT}/23262/events/1478912"}]}, + ), + f"{season_url}/events?page=2": ( + 200, + {"pageCount": 2, "items": [{"$ref": f"{ROOT}/23262/events/1478918"}]}, + ), + } + with patch("espncricinfo._core.requests.get", side_effect=fake_get(routes)) as get: + season = Season.from_reference(season_url) + assert season.year == 2026 + assert season.start_date.month == 2 + assert get.call_count == 1 + assert [team.name for team in season.teams] == ["India Women"] + assert season.match_refs == [ + MatchRef(23262, 1478912), + MatchRef(23262, 1478918), + ] + assert season.match_refs == season.match_refs + assert get.call_count == 5 + + +def test_series_navigation_uses_canonical_references_and_live_status(): + alias = f"{ROOT}/1478874" + canonical = f"{ROOT}/23262" + season_url = f"{canonical}/seasons/2026" + past_event = f"{canonical}/events/1478918" + live_event = f"{canonical}/events/1535654" + routes = { + f"{alias}/": ( + 200, + { + "name": "India Women tour of Australia", + "shortName": "India Women in Australia", + "abbreviation": "IND-W in AUS-W", + "slug": "india-women-in-australia", + "isTournament": False, + "links": [{"href": "https://www.espncricinfo.com/series/1478874"}], + "season": {"$ref": season_url}, + "events": {"$ref": f"{canonical}/events"}, + }, + ), + f"{alias}/seasons": (200, {"items": [{"$ref": season_url}]}), + f"{alias}/events": (200, {"items": [{"$ref": past_event}]}), + season_url: (200, {"year": 2026, "name": "India Women tour of Australia"}), + f"{season_url}/events": ( + 200, + {"items": [{"$ref": past_event}, {"$ref": live_event}]}, + ), + f"{canonical}/events": ( + 200, + {"items": [{"$ref": past_event}, {"$ref": live_event}]}, + ), + past_event: ( + 200, + {"competitions": [{"status": {"$ref": f"{past_event}/status"}}]}, + ), + live_event: ( + 200, + {"competitions": [{"status": {"$ref": f"{live_event}/status"}}]}, + ), + f"{past_event}/status": (200, {"type": {"state": "post"}}), + f"{live_event}/status": (200, {"type": {"state": "in"}}), + } + with patch("espncricinfo._core.requests.get", side_effect=fake_get(routes)): + series = Series(1478874) + assert series.years == ["2026"] + assert series.current_season.year == 2026 + assert [season.year for season in series.season_objects] == [2026] + assert series.matches == [MatchRef(23262, 1478918), MatchRef(23262, 1535654)] + assert series.current_matches == [MatchRef(23262, 1535654)] + + +def test_series_without_seasons_uses_direct_event_references(): + url = f"{ROOT}/999" + event_url = f"{ROOT}/999/events/123" + routes = { + f"{url}/": ( + 200, + { + "name": "One-off match", + "shortName": "One-off", + "abbreviation": "ONE", + "slug": "one-off", + "isTournament": False, + "links": [{"href": "https://www.espncricinfo.com/series/999"}], + "events": {"$ref": f"{url}/events"}, + }, + ), + f"{url}/seasons": (200, {"items": []}), + f"{url}/events": (200, {"items": [{"$ref": event_url}]}), + event_url: (200, {"competitions": []}), + } + with patch("espncricinfo._core.requests.get", side_effect=fake_get(routes)): + series = Series(999) + assert series.current_season is None + assert series.matches == [MatchRef(999, 123)] + + +@pytest.mark.parametrize( + ("reference", "resource"), + [("https://example.org/no-team/1", "teams"), ("/leagues/a/events/1", "events")], +) +def test_invalid_references_are_rejected(reference, resource): + with pytest.raises(ValueError): + reference_ids(reference, resource) + + +@pytest.mark.parametrize( + ("cls", "url", "error"), + [ + (Team, f"{ROOT}/23262/teams/999", NoTeamError), + (Season, f"{ROOT}/23262/seasons/1999", NoSeasonError), + ], +) +def test_missing_resource_raises_specific_error(cls, url, error): + with patch( + "espncricinfo._core.requests.get", side_effect=fake_get({url: (404, {})}) + ): + with pytest.raises(error): + cls.from_reference(url) + + +@pytest.mark.integration +def test_live_series_navigation(): + series = Series(1478874) + assert series.current_season.year == 2026 + assert any(team.name == "India Women" for team in series.current_season.teams) + assert len(series.matches) == 8 + assert MatchRef(1478874, 1478918) in series.matches + assert series.current_matches == []