Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<!-- USAGE-END -->
Expand Down
12 changes: 11 additions & 1 deletion espncricinfo/seasons.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
37 changes: 36 additions & 1 deletion espncricinfo/series.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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."""
Expand Down
76 changes: 76 additions & 0 deletions tests/test_navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading