diff --git a/CHANGELOG.md b/CHANGELOG.md index 7547c5b..1b1ac18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### Added + +- `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). + ## [1.0.1] - 2026-10-23 ### Changed diff --git a/README.md b/README.md index 65b987f..ac3ca65 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,15 @@ The player ID is the number at the end of a player's profile URL. 'Chennai Super Kings' ``` +Seasons that cross New Year, like the Big Bash League's, each have their own series ID. Look one up by the label ESPNcricinfo shows: + +```python +>>> bbl = Series(8044) +>>> season = bbl.season("2024/25") # also 2024, "2024-25" or "2024/2025" +>>> season.label, season.series_id +('2024/25', 1443056) +``` + `series.season_objects` lists every season and `season.match_refs` that season's matches. `series.matches` collects MatchRefs across all seasons (1,254 for the IPL), and `series.current_matches` keeps those in progress. `team.players` loads the squad on demand. ESPN's records for some bilateral tours are incomplete, for example listing one team for a two-team series. diff --git a/espncricinfo/seasons.py b/espncricinfo/seasons.py index a1d9835..1120d3f 100644 --- a/espncricinfo/seasons.py +++ b/espncricinfo/seasons.py @@ -24,8 +24,11 @@ class Season: series_id: int #: Core API URL of the season. url: str - #: ``2025``. + #: ESPN's season year, the year the season starts: ``2024`` for 2024/25. year: int + #: ESPNcricinfo's label: ``'2024/25'`` for a season that crosses New Year, + #: otherwise the year, e.g. ``'2025'``. + label: str #: ``'Indian Premier League 2025'``. name: str | None #: Short name, or None. @@ -56,6 +59,7 @@ def __init__(self, season_id: int | str, series_id: int | str) -> None: self.short_name = self.json.get("shortName") self.start_date = _parse_date(self.json.get("startDate")) self.end_date = _parse_date(self.json.get("endDate")) + self.label = _label(self.year, self.start_date, self.end_date) @classmethod def from_reference(cls, url: str) -> Season: @@ -91,5 +95,11 @@ def __repr__(self) -> str: return f"Season(id={self.id}, series_id={self.series_id}, name={self.name!r})" +def _label(year: int, start: datetime | None, end: datetime | None) -> str: + if start and end and end.year > start.year: + return f"{start.year}/{end.year % 100:02d}" + return str(year) + + def _parse_date(value: str | None) -> datetime | None: return datetime.fromisoformat(value) if value else None diff --git a/espncricinfo/series.py b/espncricinfo/series.py index 8ca4b23..633002b 100644 --- a/espncricinfo/series.py +++ b/espncricinfo/series.py @@ -1,15 +1,17 @@ """Series (ESPN "league") records from the ESPN core API.""" +import re from functools import cached_property from typing import Any from espncricinfo import _core from espncricinfo._core import collection_refs, get_json -from espncricinfo.exceptions import NoSeriesError +from espncricinfo.exceptions import NoSeasonError, NoSeriesError from espncricinfo.match_ref import MatchRef from espncricinfo.seasons import Season _LEAGUES = "http://core.espnuk.org/v2/sports/cricket/leagues" +_SEASON_KEY = re.compile(r"(\d{4})(?:[/-](\d{2}|\d{4}))?") class Series: @@ -88,6 +90,39 @@ def season_objects(self) -> list[Season]: """Every season of the series, loaded on first access.""" return [Season.from_reference(url) for url in self.seasons] + def season(self, key: int | str) -> Season: + """Return one season, by year or by ESPNcricinfo's two-year label. + + Multi-year seasons live under their own series id, which this exposes + as ``season.series_id``: for the Big Bash League, + ``Series(8044).season("2024/25").series_id`` is ``1443056``. + + Args: + key: ``2024``, ``"2024"``, ``"2024/25"``, ``"2024-25"`` or + ``"2024/2025"``. A bare year matches the season starting then. + + Returns: + The matching season. + + Raises: + NoSeasonError: If the series has no such season. + """ + match = _SEASON_KEY.fullmatch(str(key).strip()) + if match is None: + raise NoSeasonError(f"Not a season year or label: {key!r}") + start, end = match.groups() + ref = next( + (r for r in self.seasons if r.rstrip("/").endswith(f"/{start}")), None + ) + if ref is None: + raise NoSeasonError(f"{self.name} has no season starting in {start}") + season = Season.from_reference(ref) + if end is not None and season.label != f"{start}/{end[-2:]}": + raise NoSeasonError( + f"{self.name} {start} season is labeled {season.label!r}, not {key!r}" + ) + return season + @cached_property def current_season(self) -> Season | None: """The season ESPN marks as current, or None.""" diff --git a/tests/test_navigation.py b/tests/test_navigation.py index f18a60c..0210bea 100644 --- a/tests/test_navigation.py +++ b/tests/test_navigation.py @@ -214,3 +214,79 @@ def test_live_series_navigation(): assert len(series.matches) == 8 assert MatchRef(1478874, 1478918) in series.matches assert series.current_matches == [] + + +def _bbl_routes(): + """A league whose seasons span New Year, as the Big Bash League's do.""" + league = f"{ROOT}/8044" + seasons = [ + (1490534, 2025, "2025-12-14", "2026-01-26"), + (1443056, 2024, "2024-12-15", "2025-01-27"), + ] + routes = { + f"{league}/": ( + 200, + { + "name": "Big Bash League", + "shortName": "BBL", + "abbreviation": "BBL", + "slug": "big-bash-league", + }, + ), + f"{league}/seasons": ( + 200, + { + "items": [ + {"$ref": f"{ROOT}/{sid}/seasons/{year}"} + for sid, year, _, _ in seasons + ] + }, + ), + } + for sid, year, start, end in seasons: + routes[f"{ROOT}/{sid}/seasons/{year}"] = ( + 200, + { + "year": year, + "name": f"Big Bash League {year}", + "startDate": f"{start}T00:00Z", + "endDate": f"{end}T23:59Z", + }, + ) + return routes + + +@pytest.mark.parametrize("key", ["2024/25", "2024-25", "2024/2025", 2024, "2024"]) +def test_series_season_finds_multi_year_season_by_any_label(key): + with patch("espncricinfo._core.http_get", side_effect=fake_get(_bbl_routes())): + season = Series(8044).season(key) + assert (season.series_id, season.year, season.label) == (1443056, 2024, "2024/25") + + +def test_season_label_is_single_year_when_season_does_not_cross_new_year(): + url = f"{ROOT}/1449924/seasons/2025" + routes = { + url: ( + 200, + { + "year": 2025, + "startDate": "2025-03-21T00:00Z", + "endDate": "2025-06-03T23:59Z", + }, + ) + } + with patch("espncricinfo._core.http_get", side_effect=fake_get(routes)): + assert Season(2025, 1449924).label == "2025" + + +@pytest.mark.parametrize("key", ["2019/20", "2024/26", "twenty"]) +def test_series_season_rejects_unknown_or_malformed_labels(key): + with patch("espncricinfo._core.http_get", side_effect=fake_get(_bbl_routes())): + series = Series(8044) + with pytest.raises(NoSeasonError): + series.season(key) + + +@pytest.mark.integration +def test_live_big_bash_2024_25_resolves_to_its_season_series_id(): + assert Series(8044).season("2024/25").series_id == 1443056