Skip to content

API Reference

This is the documentation for the core sport-index SDK.

Core Elements

sport-index: A unified Python SDK for sports data.

Provides the SportClient as a clean entry point to access a rich, object-oriented domain model of sports data (competitions, seasons, events, competitors, etc.). Designed for intuitive navigation of relational sports data without the hassle of manual API routing.

Note: This library accesses unofficial APIs and may rely on web scraping. Use responsibly and comply with the respective providers' terms of service.

BaseEntity

Bases: ABC, Generic[T]

Base class for all domain entities.

Source code in src/sportindex/domain/base.py
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class BaseEntity(ABC, Generic[T]):
    """Base class for all domain entities."""
    _data: T
    _REPR_FIELDS = ()

    def __init__(self, data: T, provider: SofascoreProvider, **kwargs) -> None:
        self._data = data
        self._provider = provider
        self._kwargs = kwargs

    @property
    def source(self) -> T:
        """Return the parsed data source for this entity."""
        return self._data

    def __repr__(self):
        field_str = ", ".join(f"{k}={getattr(self, k, '<missing>')!r}" for k in self._REPR_FIELDS)
        return f"<{self.__class__.__name__} {field_str}>"

    @classmethod
    def __get_pydantic_core_schema__(
        cls, source_type: Any, handler: GetCoreSchemaHandler
    ) -> core_schema.CoreSchema:
        """
        Tells Pydantic how to validate this class when used as a field type.
        We simply tell it to enforce an `isinstance` check.
        """
        return core_schema.is_instance_schema(cls)

source property

Return the parsed data source for this entity.

__get_pydantic_core_schema__(source_type, handler) classmethod

Tells Pydantic how to validate this class when used as a field type. We simply tell it to enforce an isinstance check.

Source code in src/sportindex/domain/base.py
41
42
43
44
45
46
47
48
49
@classmethod
def __get_pydantic_core_schema__(
    cls, source_type: Any, handler: GetCoreSchemaHandler
) -> core_schema.CoreSchema:
    """
    Tells Pydantic how to validate this class when used as a field type.
    We simply tell it to enforce an `isinstance` check.
    """
    return core_schema.is_instance_schema(cls)

Category

Bases: IdentifiableEntity[_CategoryData]

A category within a sport (e.g., 'France Amateur', 'Formula 1', 'International').

Provides access to its sport, country (if applicable), and competitions.

Attributes:

Name Type Description
id int

Unique category ID.

name str

Category name.

slug str

URL-friendly identifier.

sport Sport

The sport this category belongs to.

country Country | None

The country this category belongs to, or None if international.

competitions EntityCollection[Competition]

All competitions under this category.

Source code in src/sportindex/domain/core.py
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
class Category(IdentifiableEntity[_CategoryData]):
    """A category within a sport (e.g., 'France Amateur', 'Formula 1', 'International').

    Provides access to its sport, country (if applicable), and competitions.

    Attributes:
        id (int): Unique category ID.
        name (str): Category name.
        slug (str): URL-friendly identifier.
        sport (Sport): The sport this category belongs to.
        country (Country | None): The country this category belongs to, or None if international.
        competitions (EntityCollection[Competition]): All competitions under this category.
    """
    _REPR_FIELDS = ("id", "name", "slug", "sport", "country")

    def __init__(self, data: _CategoryData, provider: SofascoreProvider, **kwargs) -> None:
        super().__init__(data, provider, **kwargs)

        if not isinstance(data, _CategoryData):
            raise TypeError(f"Category data must be of type _CategoryData, got {type(data)}")

    @property
    def id(self) -> int:
        """The unique ID of the category."""
        return self._data.id

    @property
    def name(self) -> str:
        """The name of the category."""
        return self._data.name

    @property
    def slug(self) -> str:
        """The slug of the category (used in URLs)."""
        return self._data.slug or self._data.name.lower().replace(" ", "-")

    @cached_property
    def sport(self) -> Sport:
        """The sport this category belongs to."""
        return Sport(self._data.sport, self._provider)

    @cached_property
    def country(self) -> Optional[Country]:
        """The country this category belongs to, or None if it's an international category."""
        if self._data.country:
            return Country(self._data.country, self._provider)
        return None

    @cached_property
    def competitions(self) -> EntityCollection[Competition]:
        """Fetch all competitions (unique tournaments / unique stages) for this category."""
        try:
            unique_tournaments = self._provider.get_category_unique_tournaments(self.id)
        except ProviderNotFoundError:
            unique_tournaments = []
        try:
            unique_stages = self._provider.get_category_unique_stages(self.id)
        except ProviderNotFoundError:
            unique_stages = []

        from .competition import Competition
        return EntityCollection([
            Competition(c, self._provider) for c in unique_tournaments
        ] + [
            Competition(s, self._provider) for s in unique_stages
        ])

competitions cached property

Fetch all competitions (unique tournaments / unique stages) for this category.

country cached property

The country this category belongs to, or None if it's an international category.

id property

The unique ID of the category.

name property

The name of the category.

slug property

The slug of the category (used in URLs).

sport cached property

The sport this category belongs to.

Channel

Bases: IdentifiableEntity[_ChannelData]

A TV channel broadcasting sports events.

Provides access to the channel's name, ID, and scheduled events.

Attributes:

Name Type Description
id int

Unique channel ID.

name str

Channel name.

events EventCollection

Scheduled events broadcast on this channel.

Raises:

Type Description
TypeError

If initialized with invalid data type.

EntityNotFoundError

If channel does not exist in the provider.

DomainError

If a network or provider error occurs during fetch.

Source code in src/sportindex/domain/channel.py
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
class Channel(IdentifiableEntity[_ChannelData]):
    """A TV channel broadcasting sports events.

    Provides access to the channel's name, ID, and scheduled events.

    Attributes:
        id (int): Unique channel ID.
        name (str): Channel name.
        events (EventCollection): Scheduled events broadcast on this channel.

    Raises:
        TypeError: If initialized with invalid data type.
        EntityNotFoundError: If channel does not exist in the provider.
        DomainError: If a network or provider error occurs during fetch.
    """
    _REPR_FIELDS = ("id", "name")

    def __init__(self, data: _ChannelData, provider: SofascoreProvider, **kwargs) -> None:
        super().__init__(data, provider, **kwargs)

        if not isinstance(data, _ChannelData):
            raise TypeError(f"Channel data must be of type _ChannelData, got {type(data)}")

    @property
    def id(self) -> int:
        """The unique ID of the channel."""
        return self._data.id

    @property
    def name(self) -> str:
        """The name of the channel."""
        return self._data.name

    @property
    def events(self) -> EventCollection:
        """Fetch all scheduled events for this channel."""
        from .event import Event, EventCollection
        parsed_channel_events = self._provider.get_channel_schedule(self.id)
        return EventCollection([
            Event(e, self._provider) for e in parsed_channel_events.events
        ] + [
            Event(s, self._provider) for s in parsed_channel_events.stages
        ])

    @classmethod
    def from_id(cls, channel_id: int, provider: SofascoreProvider) -> Channel:
        """Fetch a channel by its ID."""
        try:
            parsed_channel_events = provider.get_channel_schedule(channel_id)
        except ProviderNotFoundError as e:
            raise EntityNotFoundError(f"Channel with id {channel_id} not found") from e
        except FetchError as e:
            raise DomainError(f"Network error while fetching channel {channel_id}") from e
        return cls(parsed_channel_events.channel, provider)

events property

Fetch all scheduled events for this channel.

id property

The unique ID of the channel.

name property

The name of the channel.

from_id(channel_id, provider) classmethod

Fetch a channel by its ID.

Source code in src/sportindex/domain/channel.py
62
63
64
65
66
67
68
69
70
71
@classmethod
def from_id(cls, channel_id: int, provider: SofascoreProvider) -> Channel:
    """Fetch a channel by its ID."""
    try:
        parsed_channel_events = provider.get_channel_schedule(channel_id)
    except ProviderNotFoundError as e:
        raise EntityNotFoundError(f"Channel with id {channel_id} not found") from e
    except FetchError as e:
        raise DomainError(f"Network error while fetching channel {channel_id}") from e
    return cls(parsed_channel_events.channel, provider)

Competition

Bases: IdentifiableEntity[_UniqueTournamentData | _UniqueStageData]

A competition, e.g., 'Ligue 1', 'Rolland Garros'.

Can represent either a unique tournament or a unique stage. Provides access to its category, sport, and associated seasons.

Attributes:

Name Type Description
id int

Unique ID, encoded from source ID and type.

name str

Competition name.

slug str

URL-friendly slug.

sport Sport

Parent sport.

category Category

Parent category (lazy-loaded).

seasons EntityCollection[Season]

Seasons of this competition (lazy-loaded).

Raises:

Type Description
TypeError

If data is not UniqueTournament or UniqueStage.

Source code in src/sportindex/domain/competition.py
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
class Competition(IdentifiableEntity[_UniqueTournamentData | _UniqueStageData]):
    """A competition, e.g., 'Ligue 1', 'Rolland Garros'.

    Can represent either a unique tournament or a unique stage.
    Provides access to its category, sport, and associated seasons.

    Attributes:
        id (int): Unique ID, encoded from source ID and type.
        name (str): Competition name.
        slug (str): URL-friendly slug.
        sport (Sport): Parent sport.
        category (Category): Parent category (lazy-loaded).
        seasons (EntityCollection[Season]): Seasons of this competition (lazy-loaded).

    Raises:
        TypeError: If data is not UniqueTournament or UniqueStage.
    """
    _REPR_FIELDS = ("id", "name", "slug", "sport", "category")
    _TYPE_MAP = {_UniqueTournamentData: 1, _UniqueStageData: 2}

    def __init__(self, data: _UniqueTournamentData | _UniqueStageData, provider: SofascoreProvider, **kwargs) -> None:
        super().__init__(data, provider)

        if not isinstance(data, (_UniqueTournamentData, _UniqueStageData)):
            raise TypeError(f"Competition data must be either _UniqueTournamentData or _UniqueStageData, got {type(data)}")

        self._full_loaded = False

    @property
    def id(self) -> int:
        """The unique ID of the competition."""
        type_idx = self._TYPE_MAP[type(self._data)]
        return self.encode_id(self._data.id, type_idx)

    @property
    def name(self) -> str:
        """The name of the competition."""
        return self._data.name

    @property
    def slug(self) -> str:
        """The slug of the competition."""
        return self._data.slug

    @property
    def sport(self) -> Sport:
        """The sport this competition belongs to."""
        return self.category.sport

    @cached_property
    def category(self) -> Category:
        """The category this competition belongs to."""
        return Category(self._data.category, self._provider)

    @cached_property
    def seasons(self) -> EntityCollection[Season]:
        """Fetch all seasons for this competition."""
        if isinstance(self._data, _UniqueTournamentData):
            return EntityCollection([
                Season(s, self._provider, competition=self)
                for s in self._provider.get_unique_tournament_seasons(self._data.id)
            ])
        elif isinstance(self._data, _UniqueStageData):
            return EntityCollection([
                Season(s, self._provider, competition=self)
                for s in self._provider.get_unique_stage_seasons(self._data.id)
            ])
        else:
            raise TypeError(f"Competition data must be either _UniqueTournamentData or _UniqueStageData, got {type(self._data)}")

    def _full_load(self) -> None:
        """
        Lazy-loads the complete competition from the provider.
        Called automatically when accessing properties that require full details
        missing from the initial lightweight API response.
        """
        if self._full_loaded:
            return
        try:
            if isinstance(self._data, _UniqueTournamentData):
                self._data = merge_pydantic_models(self._data, self._provider.get_unique_tournament(self._data.id))
            elif isinstance(self._data, _UniqueStageData):
                logger.debug(f"No endpoint available to fully load unique stage yet, skipping full load...")
            if not isinstance(self._data, (_UniqueTournamentData, _UniqueStageData)):
                raise TypeError(f"Competition data must be either _UniqueTournamentData or _UniqueStageData after full load, got {type(self._data)}")
            self._full_loaded = True
            self._clear_cache()
        except ProviderNotFoundError:
            logger.debug(f"Competition with id {self._data.id} not found during full load")
            self._full_loaded = True
            self._clear_cache()
        except FetchError as e:
            logger.debug(f"Network error while fully loading competition with id {self._data.id}: {e}")
            self._full_loaded = True
            self._clear_cache()

    def _clear_cache(self) -> None:
        """Clear cached properties."""
        self.__dict__.pop("category", None)
        self.__dict__.pop("seasons", None)

    @classmethod
    def from_id(cls, competition_id: int, provider: SofascoreProvider) -> Competition:
        """Fetch a competition by its ID."""
        raw_id, type_idx = cls.decode_id(competition_id)
        type_map_reverse = {v: k for k, v in cls._TYPE_MAP.items()}
        # exceptions imported at module level

        if type_idx not in type_map_reverse:
            raise TypeError(f"Invalid competition ID {competition_id}: unknown type index {type_idx}")

        data_cls = type_map_reverse[type_idx]
        try:
            if data_cls == _UniqueTournamentData:
                parsed_data = provider.get_unique_tournament(raw_id)
            elif data_cls == _UniqueStageData:
                us_seasons = provider.get_unique_stage_seasons(raw_id)
                if us_seasons:
                    parsed_data = us_seasons[0].unique_stage
                else:
                    raise InsufficientDataError(f"Could not find any seasons for unique stage with ID {raw_id}, cannot construct competition")
            else:
                raise TypeError(f"Unsupported data class {data_cls} for competition ID {competition_id}")
        except ProviderNotFoundError as e:
            raise EntityNotFoundError(f"Competition with id {competition_id} not found") from e
        except FetchError as e:
            raise DomainError(f"Network error while fetching competition {competition_id}") from e

        return cls(parsed_data, provider)

    @classmethod
    def search(cls, query: str, provider: SofascoreProvider) -> EntityCollection[Competition]:
        """Search for competitions matching the given query (up to the first 20 matches)."""
        entities = []
        for page in range(51): # Sofascore has a maximum of 50 pages of search results
            all_matches = provider.search_all(query=query, page=page)
            if not all_matches:
                break
            for item in all_matches:
                if isinstance(item.entity, (_UniqueTournamentData, _UniqueStageData)):
                    entities.append(Competition(item.entity, provider))
            if len(all_matches) > 20:
                break
        return EntityCollection(entities[:20])

category cached property

The category this competition belongs to.

id property

The unique ID of the competition.

name property

The name of the competition.

seasons cached property

Fetch all seasons for this competition.

slug property

The slug of the competition.

sport property

The sport this competition belongs to.

from_id(competition_id, provider) classmethod

Fetch a competition by its ID.

Source code in src/sportindex/domain/competition.py
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
@classmethod
def from_id(cls, competition_id: int, provider: SofascoreProvider) -> Competition:
    """Fetch a competition by its ID."""
    raw_id, type_idx = cls.decode_id(competition_id)
    type_map_reverse = {v: k for k, v in cls._TYPE_MAP.items()}
    # exceptions imported at module level

    if type_idx not in type_map_reverse:
        raise TypeError(f"Invalid competition ID {competition_id}: unknown type index {type_idx}")

    data_cls = type_map_reverse[type_idx]
    try:
        if data_cls == _UniqueTournamentData:
            parsed_data = provider.get_unique_tournament(raw_id)
        elif data_cls == _UniqueStageData:
            us_seasons = provider.get_unique_stage_seasons(raw_id)
            if us_seasons:
                parsed_data = us_seasons[0].unique_stage
            else:
                raise InsufficientDataError(f"Could not find any seasons for unique stage with ID {raw_id}, cannot construct competition")
        else:
            raise TypeError(f"Unsupported data class {data_cls} for competition ID {competition_id}")
    except ProviderNotFoundError as e:
        raise EntityNotFoundError(f"Competition with id {competition_id} not found") from e
    except FetchError as e:
        raise DomainError(f"Network error while fetching competition {competition_id}") from e

    return cls(parsed_data, provider)

search(query, provider) classmethod

Search for competitions matching the given query (up to the first 20 matches).

Source code in src/sportindex/domain/competition.py
154
155
156
157
158
159
160
161
162
163
164
165
166
167
@classmethod
def search(cls, query: str, provider: SofascoreProvider) -> EntityCollection[Competition]:
    """Search for competitions matching the given query (up to the first 20 matches)."""
    entities = []
    for page in range(51): # Sofascore has a maximum of 50 pages of search results
        all_matches = provider.search_all(query=query, page=page)
        if not all_matches:
            break
        for item in all_matches:
            if isinstance(item.entity, (_UniqueTournamentData, _UniqueStageData)):
                entities.append(Competition(item.entity, provider))
        if len(all_matches) > 20:
            break
    return EntityCollection(entities[:20])

Competitor

Bases: IdentifiableEntity[_TeamData | _PlayerData], EventAwareMixin

A sports competitor, either an individual or a team.

Provides access to identity, affiliations, and related entities such as players, managers, and venues. Supports fetching fixtures and results, and distinguishing between player and team competitors.

Attributes:

Name Type Description
id int

Unique competitor ID.

name str

Official competitor name.

slug str

URL-friendly slug.

short_name str

Abbreviated name.

full_name str

Full name or concatenation of first and last names for players.

kind Literal['team', 'player']

Type of competitor.

sport Sport | None

Sport this competitor belongs to.

country Country | None

Competitor's country, if applicable.

category Category | None

Competitor's category, if applicable.

manager Manager | None

Manager, if applicable.

venue Venue | None

Home venue, if applicable.

players EntityCollection[Competitor] | None

Players of this team, if applicable.

parent Competitor | None

Parent competitor for players or sub-teams.

player_info PlayerInfo | None

Detailed player information, if applicable.

Methods:

Name Description
get_fixtures

Fetch all scheduled events for the competitor.

get_results

Fetch all results for the competitor.

Raises:

Type Description
TypeError

If initialized with invalid data type.

EntityNotFoundError

If the competitor does not exist in the provider.

DomainError

If a network or provider error occurs during fetch.

Source code in src/sportindex/domain/competitor.py
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
class Competitor(IdentifiableEntity[_TeamData | _PlayerData], EventAwareMixin):
    """A sports competitor, either an individual or a team.

    Provides access to identity, affiliations, and related entities such as players, managers, and venues.
    Supports fetching fixtures and results, and distinguishing between player and team competitors.

    Attributes:
        id (int): Unique competitor ID.
        name (str): Official competitor name.
        slug (str): URL-friendly slug.
        short_name (str): Abbreviated name.
        full_name (str): Full name or concatenation of first and last names for players.
        kind (Literal['team','player']): Type of competitor.
        sport (Sport | None): Sport this competitor belongs to.
        country (Country | None): Competitor's country, if applicable.
        category (Category | None): Competitor's category, if applicable.
        manager (Manager | None): Manager, if applicable.
        venue (Venue | None): Home venue, if applicable.
        players (EntityCollection[Competitor] | None): Players of this team, if applicable.
        parent (Competitor | None): Parent competitor for players or sub-teams.
        player_info (PlayerInfo | None): Detailed player information, if applicable.

    Methods:
        get_fixtures(silent=False) -> EventCollection: Fetch all scheduled events for the competitor.
        get_results(silent=False) -> EventCollection: Fetch all results for the competitor.

    Raises:
        TypeError: If initialized with invalid data type.
        EntityNotFoundError: If the competitor does not exist in the provider.
        DomainError: If a network or provider error occurs during fetch.
    """
    _REPR_FIELDS = ("id", "name", "slug", "short_name", "full_name", "name_code", "national", "gender", "sport", "country", "category", "kind")
    _TYPE_MAP = {_TeamData: 1, _PlayerData: 2}

    def __init__(self, data: _TeamData | _PlayerData, provider: SofascoreProvider, **kwargs) -> None:
        super().__init__(data, provider, **kwargs)

        if not isinstance(data, (_TeamData, _PlayerData)):
            raise TypeError("Competitor data must be either _TeamData or _PlayerData")

        self._full_loaded = False

    @property
    def id(self) -> int:
        """The unique ID of the competitor."""
        type_idx = self._TYPE_MAP[type(self._data)]
        return self.encode_id(self._data.id, type_idx)

    @property
    def name(self) -> str:
        """The name of the competitor."""
        return self._data.name

    @property
    def slug(self) -> str:
        """The slug of the competitor."""
        return self._data.slug

    @property
    def short_name(self) -> str:
        """The short name of the competitor."""
        return self._data.shortName

    @property
    def full_name(self) -> str:
        """The full name of the competitor."""
        if isinstance(self._data, _TeamData):
            return self._data.fullName or self._data.name
        elif isinstance(self._data, _PlayerData):
            first = self._data.firstName or ""
            last = self._data.lastName or ""
            return f"{first} {last}".strip() or self._data.name

    @property
    def name_code(self) -> Optional[str]:
        """The name code of the competitor, if applicable."""
        if isinstance(self._data, _TeamData):
            return self._data.nameCode
        else:
            return None

    @property
    def national(self) -> Optional[bool]:
        """Whether this competitor is a national team."""
        if isinstance(self._data, _TeamData):
            return self._data.national
        else:
            return None

    # --- Properties to be cached, as they require additional API calls or processing (even if they are quite light) ---

    @cached_property
    def gender(self) -> Optional[Gender]:
        """The gender of the competitor, if applicable."""
        from .gender import Gender
        return Gender(self._data.gender)

    @cached_property
    def sport(self) -> Optional[Sport]:
        """The sport this competitor belongs to."""
        from .core import Sport
        if isinstance(self._data, _TeamData):
            return Sport(self._data.sport, self._provider) if self._data.sport else None
        elif isinstance(self._data, _PlayerData):
            return Sport(self._data.team.sport, self._provider) if self._data.team and self._data.team.sport else None

    @cached_property
    def country(self) -> Optional[Country]:
        """The country this competitor belongs to, if applicable."""
        from .core import Country
        return Country(self._data.country, self._provider) if self._data.country else None

    @cached_property
    def category(self) -> Optional[Category]:
        """The category this competitor belongs to, if applicable."""
        from .core import Category
        if isinstance(self._data, _TeamData):
            return Category(self._data.category, self._provider) if self._data.category else None
        elif isinstance(self._data, _PlayerData):
            return Category(self._data.team.category, self._provider) if self._data.team and self._data.team.category else None

    @cached_property
    def kind(self) -> Literal["team", "player"]:
        """Whether this competitor is a team or a player."""
        if isinstance(self._data, _PlayerData):
            return "player"
        elif isinstance(self._data, _TeamData):
            if self._data.playerTeamInfo is not None:
                logger.debug(f"Team {self._data.name} has playerTeamInfo, treating it as a player")
                return "player"
            else:
                return "team"

    @cached_property
    def parent(self) -> Optional[Competitor]:
        """The parent competitor, if this is a player belonging to a team."""
        self._full_load()
        if isinstance(self._data, _PlayerData) and self._data.team is not None:
            return Competitor(self._data.team, self._provider)
        elif isinstance(self._data, _TeamData) and self._data.parent_team is not None:
            return Competitor(self._data.parent_team, self._provider)
        else:
            return None

    @cached_property
    def players(self) -> Optional[EntityCollection[Competitor]]:
        """The players belonging to this competitor, if this is a team."""
        if not isinstance(self._data, _TeamData):
            return None
        try:
            return EntityCollection([Competitor(player, self._provider) for player in self._provider.get_team_players(self._data.id).players])
        except ProviderNotFoundError:
            logger.debug(f"No players found for team with id {self.id}, returning empty collection")
            return EntityCollection([])

    @cached_property
    def drivers(self) -> Optional[EntityCollection[Competitor]]:
        """The drivers belonging to this competitor, if this is a motorsport team."""
        if not isinstance(self._data, _TeamData):
            return None
        try:
            return EntityCollection([Competitor(driver, self._provider) for driver in self._provider.get_team(self._data.id).drivers])
        except ProviderNotFoundError:
            logger.debug(f"No drivers found for team with id {self.id}, returning empty collection")
            return EntityCollection([])

    @cached_property
    def manager(self) -> Optional[Manager]:
        """The manager of this competitor, if applicable."""
        self._full_load()
        from .manager import Manager
        return Manager(self._data.manager, self._provider) if self._data.manager else None

    @cached_property
    def venue(self) -> Optional[Venue]:
        """The venue this competitor plays at, if applicable."""
        self._full_load()
        from .venue import Venue
        return Venue(self._data.venue, self._provider) if self._data.venue else None

    @cached_property
    def player_info(self) -> Optional[PlayerInfo]:
        """Additional player info, if this is a player."""
        if self.kind != "player":
            return None
        self._full_load()
        if isinstance(self._data, _PlayerData):
            return PlayerInfo._from_parsed_player(self._data)
        elif isinstance(self._data, _TeamData) and self._data.player_team_info is not None:
            return PlayerInfo._from_parsed_player_team_info(self._data.player_team_info)
        else:
            return None

    # --- Properties to be recomputed each time, as they might change regularly ---

    def get_fixtures(self, silent: bool = False) -> EventCollection:
        """Fetch all fixtures for this competitor."""
        if isinstance(self._data, _PlayerData):
            if not silent:
                logger.warning(f"No fixtures endpoint for non individual sports players like {self.name}, returning empty list")
            from .event import EventCollection
            return EventCollection([])
        elif isinstance(self._data, _TeamData):
            return self._fetch_paginated_events(self._provider.get_team_fixtures, self._data.id)
        from .event import EventCollection
        return EventCollection([])

    def get_results(self, silent: bool = False) -> EventCollection:
        """Fetch all results for this competitor."""
        if isinstance(self._data, _PlayerData):
            return self._fetch_paginated_events(self._provider.get_player_results, self._data.id)
        elif isinstance(self._data, _TeamData):
            return self._fetch_paginated_events(self._provider.get_team_results, self._data.id)
        from .event import EventCollection
        return EventCollection([])

    def _full_load(self) -> None:
        """
        Lazy-loads the complete competitor from the provider.
        Called automatically when accessing properties that require full details
        missing from the initial lightweight API response.
        """
        if self._full_loaded:
            return
        try:
            if isinstance(self._data, _PlayerData):
                self._data = merge_pydantic_models(self._data, self._provider.get_player(self._data.id))
            elif isinstance(self._data, _TeamData):
                self._data = merge_pydantic_models(self._data, self._provider.get_team(self._data.id))
            if not isinstance(self._data, (_PlayerData, _TeamData)):
                raise TypeError("Competitor data must be either _PlayerData or _TeamData.")
            self._full_loaded = True
            self._clear_cache()
        except ProviderNotFoundError:
            logger.debug(f"Competitor with id {self._data.id} not found during full load.")
            self._full_loaded = True
            self._clear_cache()
        except FetchError as e:
            logger.debug(f"Network error while fully loading competitor with id {self._data.id}: {e}")
            self._full_loaded = True
            self._clear_cache()

    def _clear_cache(self) -> None:
        """Clear cached properties."""
        self.__dict__.pop("gender", None)
        self.__dict__.pop("sport", None)
        self.__dict__.pop("country", None)
        self.__dict__.pop("category", None)
        self.__dict__.pop("kind", None)
        self.__dict__.pop("parent", None)
        self.__dict__.pop("players", None)
        self.__dict__.pop("drivers", None)
        self.__dict__.pop("manager", None)
        self.__dict__.pop("venue", None)
        self.__dict__.pop("player_info", None)

    @classmethod
    def from_id(cls, competitor_id: int, provider: SofascoreProvider) -> Competitor:
        """Fetch a competitor by its ID."""
        raw_id, type_idx = cls.decode_id(competitor_id)
        type_map_reverse = {v: k for k, v in cls._TYPE_MAP.items()}

        if type_idx not in type_map_reverse:
            raise TypeError(f"Invalid competitor ID {competitor_id}: unknown type index {type_idx}")

        data_cls = type_map_reverse[type_idx]
        try:
            if data_cls == _TeamData:
                parsed_data = provider.get_team(raw_id).team
            elif data_cls == _PlayerData:
                parsed_data = provider.get_player(raw_id)
            else:
                raise TypeError(f"Unsupported competitor type index {type_idx} in ID {competitor_id}")
        except ProviderNotFoundError as e:
            raise EntityNotFoundError(f"Competitor with id {competitor_id} not found") from e
        except FetchError as e:
            raise DomainError(f"Network error while fetching competitor {competitor_id}") from e

        return cls(parsed_data, provider)

    @classmethod
    def search(cls, query: str, provider: SofascoreProvider) -> EntityCollection[Competitor]:
        """Search for competitors matching the given query (up to the first 20 matches)."""
        entities = []
        for page in range(51):
            try:
                all_matches = provider.search_all(query=query, page=page)
            except (ProviderNotFoundError, FetchError):
                logger.debug(f"Failed to fetch search results for query '{query}' on page {page}, stopping pagination")
                break
            if not all_matches:
                break
            for item in all_matches:
                if isinstance(item.entity, (_TeamData, _PlayerData)):
                    entities.append(Competitor(item.entity, provider))
            if len(all_matches) > 20:
                break
        return EntityCollection(entities[:20])

category cached property

The category this competitor belongs to, if applicable.

country cached property

The country this competitor belongs to, if applicable.

drivers cached property

The drivers belonging to this competitor, if this is a motorsport team.

full_name property

The full name of the competitor.

gender cached property

The gender of the competitor, if applicable.

id property

The unique ID of the competitor.

kind cached property

Whether this competitor is a team or a player.

manager cached property

The manager of this competitor, if applicable.

name property

The name of the competitor.

name_code property

The name code of the competitor, if applicable.

national property

Whether this competitor is a national team.

parent cached property

The parent competitor, if this is a player belonging to a team.

player_info cached property

Additional player info, if this is a player.

players cached property

The players belonging to this competitor, if this is a team.

short_name property

The short name of the competitor.

slug property

The slug of the competitor.

sport cached property

The sport this competitor belongs to.

venue cached property

The venue this competitor plays at, if applicable.

from_id(competitor_id, provider) classmethod

Fetch a competitor by its ID.

Source code in src/sportindex/domain/competitor.py
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
@classmethod
def from_id(cls, competitor_id: int, provider: SofascoreProvider) -> Competitor:
    """Fetch a competitor by its ID."""
    raw_id, type_idx = cls.decode_id(competitor_id)
    type_map_reverse = {v: k for k, v in cls._TYPE_MAP.items()}

    if type_idx not in type_map_reverse:
        raise TypeError(f"Invalid competitor ID {competitor_id}: unknown type index {type_idx}")

    data_cls = type_map_reverse[type_idx]
    try:
        if data_cls == _TeamData:
            parsed_data = provider.get_team(raw_id).team
        elif data_cls == _PlayerData:
            parsed_data = provider.get_player(raw_id)
        else:
            raise TypeError(f"Unsupported competitor type index {type_idx} in ID {competitor_id}")
    except ProviderNotFoundError as e:
        raise EntityNotFoundError(f"Competitor with id {competitor_id} not found") from e
    except FetchError as e:
        raise DomainError(f"Network error while fetching competitor {competitor_id}") from e

    return cls(parsed_data, provider)

get_fixtures(silent=False)

Fetch all fixtures for this competitor.

Source code in src/sportindex/domain/competitor.py
219
220
221
222
223
224
225
226
227
228
229
def get_fixtures(self, silent: bool = False) -> EventCollection:
    """Fetch all fixtures for this competitor."""
    if isinstance(self._data, _PlayerData):
        if not silent:
            logger.warning(f"No fixtures endpoint for non individual sports players like {self.name}, returning empty list")
        from .event import EventCollection
        return EventCollection([])
    elif isinstance(self._data, _TeamData):
        return self._fetch_paginated_events(self._provider.get_team_fixtures, self._data.id)
    from .event import EventCollection
    return EventCollection([])

get_results(silent=False)

Fetch all results for this competitor.

Source code in src/sportindex/domain/competitor.py
231
232
233
234
235
236
237
238
def get_results(self, silent: bool = False) -> EventCollection:
    """Fetch all results for this competitor."""
    if isinstance(self._data, _PlayerData):
        return self._fetch_paginated_events(self._provider.get_player_results, self._data.id)
    elif isinstance(self._data, _TeamData):
        return self._fetch_paginated_events(self._provider.get_team_results, self._data.id)
    from .event import EventCollection
    return EventCollection([])

search(query, provider) classmethod

Search for competitors matching the given query (up to the first 20 matches).

Source code in src/sportindex/domain/competitor.py
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
@classmethod
def search(cls, query: str, provider: SofascoreProvider) -> EntityCollection[Competitor]:
    """Search for competitors matching the given query (up to the first 20 matches)."""
    entities = []
    for page in range(51):
        try:
            all_matches = provider.search_all(query=query, page=page)
        except (ProviderNotFoundError, FetchError):
            logger.debug(f"Failed to fetch search results for query '{query}' on page {page}, stopping pagination")
            break
        if not all_matches:
            break
        for item in all_matches:
            if isinstance(item.entity, (_TeamData, _PlayerData)):
                entities.append(Competitor(item.entity, provider))
        if len(all_matches) > 20:
            break
    return EntityCollection(entities[:20])

Country

Bases: BaseEntity[_CountryData]

A country (e.g., France, England, Spain).

Provides standard identifiers (name, slug, alpha-2, alpha-3) and can be instantiated from a name or alpha code.

Attributes:

Name Type Description
name str

Official country name.

slug str

URL-friendly identifier.

alpha2 str | None

ISO alpha-2 code.

alpha3 str | None

ISO alpha-3 code.

Methods:

Name Description
from_alpha

str, provider) -> Optional[Country]: Create from alpha code.

from_name

str, provider) -> Optional[Country]: Create from country name.

Source code in src/sportindex/domain/core.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
class Country(BaseEntity[_CountryData]):
    """A country (e.g., France, England, Spain).

    Provides standard identifiers (name, slug, alpha-2, alpha-3) and can be instantiated from a name or alpha code.

    Attributes:
        name (str): Official country name.
        slug (str): URL-friendly identifier.
        alpha2 (str | None): ISO alpha-2 code.
        alpha3 (str | None): ISO alpha-3 code.

    Methods:
        from_alpha(alpha: str, provider) -> Optional[Country]: Create from alpha code.
        from_name(name: str, provider) -> Optional[Country]: Create from country name.
    """
    _REPR_FIELDS = ("name", "slug", "alpha2", "alpha3")

    def __init__(self, data: _CountryData, provider: SofascoreProvider, **kwargs) -> None:
        super().__init__(data, provider, **kwargs)

        if not isinstance(data, _CountryData):
            raise TypeError(f"Country data must be of type _CountryData, got {type(data)}")

        self._country = next(
            (c for c in pycountry.countries if c.name.lower() == self.name.lower()), None
        )

    @property
    def name(self) -> str:
        """The name of the country."""
        return self._data.name.title() or self._data.slug.replace("-", " ").title()

    @property
    def slug(self) -> str:
        """The slug of the country (used in URLs)."""
        return self._data.slug

    @property
    def alpha2(self) -> Optional[str]:
        """The alpha-2 code of the country (e.g. 'FR' for France)."""
        return self._data.alpha2 or (self._country.alpha_2 if self._country else None)

    @property
    def alpha3(self) -> Optional[str]:
        """The alpha-3 code of the country (e.g. 'FRA' for France)."""
        return self._data.alpha3 or (self._country.alpha_3 if self._country else None)

    @classmethod
    def from_alpha(cls, alpha: str, provider: SofascoreProvider) -> Optional[Country]:
        """Create a Country instance from an alpha-2 or alpha-3 code."""
        country = next(
            (c for c in pycountry.countries if c.alpha_2 == alpha.upper() or c.alpha_3 == alpha.upper()), None
        )
        if country:
            return cls(
                data=_CountryData(
                    name=country.name,
                    slug=country.name.lower().replace(" ", "-"),
                    alpha2=country.alpha_2,
                    alpha3=country.alpha_3
                ),
                provider=provider
            )
        return None

    @classmethod
    def from_name(cls, name: str, provider: SofascoreProvider) -> Optional[Country]:
        """Create a Country instance from a country name."""
        country = next(
            (c for c in pycountry.countries if c.name.lower() == name.lower()), None
        )
        if country:
            return cls(
                data=_CountryData(
                    name=country.name,
                    slug=country.name.lower().replace(" ", "-"),
                    alpha2=country.alpha_2,
                    alpha3=country.alpha_3
                ),
                provider=provider
            )
        return None

alpha2 property

The alpha-2 code of the country (e.g. 'FR' for France).

alpha3 property

The alpha-3 code of the country (e.g. 'FRA' for France).

name property

The name of the country.

slug property

The slug of the country (used in URLs).

from_alpha(alpha, provider) classmethod

Create a Country instance from an alpha-2 or alpha-3 code.

Source code in src/sportindex/domain/core.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
@classmethod
def from_alpha(cls, alpha: str, provider: SofascoreProvider) -> Optional[Country]:
    """Create a Country instance from an alpha-2 or alpha-3 code."""
    country = next(
        (c for c in pycountry.countries if c.alpha_2 == alpha.upper() or c.alpha_3 == alpha.upper()), None
    )
    if country:
        return cls(
            data=_CountryData(
                name=country.name,
                slug=country.name.lower().replace(" ", "-"),
                alpha2=country.alpha_2,
                alpha3=country.alpha_3
            ),
            provider=provider
        )
    return None

from_name(name, provider) classmethod

Create a Country instance from a country name.

Source code in src/sportindex/domain/core.py
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
@classmethod
def from_name(cls, name: str, provider: SofascoreProvider) -> Optional[Country]:
    """Create a Country instance from a country name."""
    country = next(
        (c for c in pycountry.countries if c.name.lower() == name.lower()), None
    )
    if country:
        return cls(
            data=_CountryData(
                name=country.name,
                slug=country.name.lower().replace(" ", "-"),
                alpha2=country.alpha_2,
                alpha3=country.alpha_3
            ),
            provider=provider
        )
    return None

EntityCollection

Bases: Generic[E]

A generic collection of entities for any BaseEntity subclass.

Source code in src/sportindex/domain/base.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
class EntityCollection(Generic[E]):
    """A generic collection of entities for any BaseEntity subclass."""

    def __init__(self, entities: Iterable[E] | None = None) -> None:
        self._entities = list(entities) if entities is not None else []

    def __iter__(self) -> Iterator[E]:
        return iter(self._entities)

    def __len__(self) -> int:
        return len(self._entities)

    def __add__(self, other: EntityCollection[E] | list[E]) -> EntityCollection[E]:
        if not isinstance(other, (EntityCollection, list)):
            return NotImplemented
        other_items = other._entities if isinstance(other, EntityCollection) else other
        return self.__class__(self._entities + other_items)

    def __iadd__(self, other: EntityCollection[E] | list[E]) -> EntityCollection[E]:
        if not isinstance(other, (EntityCollection, list)):
            return NotImplemented
        other_items = other._entities if isinstance(other, EntityCollection) else other
        self.extend(other_items)
        return self

    def __contains__(self, item: E) -> bool:
        return item in self._entities

    def append(self, entity: E) -> None:
        """Add a single entity to the collection."""
        self._entities.append(entity)

    def extend(self, collection: EntityCollection[E] | list[E]) -> None:
        """Add all entities from another collection."""
        items = collection._entities if isinstance(collection, EntityCollection) else collection
        self._entities.extend(items)

    @overload
    def __getitem__(self, key: int) -> E: ...

    @overload
    def __getitem__(self, key: slice) -> EntityCollection[E]: ...

    def __getitem__(self, key: int | slice) -> E | EntityCollection[E]:
        if isinstance(key, slice):
            return self.__class__(self._entities[key])
        return self._entities[key]

    def get(self, **kwargs) -> Optional[E]:
        """Get an entity by arbitrary attributes (e.g. id=1, name="Football")."""
        for e in self._entities:
            if all(getattr(e, k, None) == v for k, v in kwargs.items()):
                return e
        return None

    def search(self, query: str, by: str = "name") -> EntityCollection[E]:
        """
        Smart search that handles case-insensitivity, ignores extra spaces, 
        and allows for partial matches on a specified string attribute.
        Returns a new collection.
        """
        clean_query = query.strip().lower()
        results = [
            e for e in self._entities 
            if clean_query in str(getattr(e, by, "")).lower()
        ]
        return self.__class__(results)

    def to_list(self) -> list[E]:
        """Return the entities as a list."""
        return list(self._entities)

    def copy(self) -> EntityCollection[E]:
        """Return a shallow copy of the collection."""
        return self.__class__(self._entities.copy())

    def __or__(self, other: EntityCollection[E]) -> EntityCollection[E]:
        """Union (|): Returns a new collection with unique entities from both collections."""
        if not isinstance(other, EntityCollection):
            return NotImplemented

        if not self._entities and not other._entities:
            return self.__class__([])

        merged = list(dict.fromkeys(self._entities + other._entities))
        return self.__class__(merged)

    def __and__(self, other: EntityCollection[E]) -> EntityCollection[E]:
        """Intersection (&): Returns a new collection with entities common to both collections."""
        if not isinstance(other, EntityCollection):
            return NotImplemented

        if not self._entities or not other._entities:
            return self.__class__([])

        other_set = set(other._entities)
        common = list(dict.fromkeys(e for e in self._entities if e in other_set))
        return self.__class__(common)

    def __sub__(self, other: EntityCollection[E]) -> EntityCollection[E]:
        """Difference (-): Returns a new collection with entities in self but not in other."""
        if not isinstance(other, EntityCollection):
            return NotImplemented

        if not self._entities:
            return self.__class__([])
        if not other._entities:
            return self.copy()

        other_set = set(other._entities)
        diff = list(dict.fromkeys(e for e in self._entities if e not in other_set))
        return self.__class__(diff)

    def __repr__(self) -> str:
        return f"<{self.__class__.__name__} count={len(self._entities)}>"

    def __str__(self) -> str:
        if not self._entities:
            return f"<{self.__class__.__name__} (empty)>"
        lines = [f"<{self.__class__.__name__} ({len(self._entities)} entities)>:"]
        for e in self._entities[:10]:  # Show up to 10 entities
            lines.append(f"  - {e!r}")
        if len(self._entities) > 10:
            lines.append(f"  ... and {len(self._entities) - 10} more")
        return "\n".join(lines)

__and__(other)

Intersection (&): Returns a new collection with entities common to both collections.

Source code in src/sportindex/domain/base.py
209
210
211
212
213
214
215
216
217
218
219
def __and__(self, other: EntityCollection[E]) -> EntityCollection[E]:
    """Intersection (&): Returns a new collection with entities common to both collections."""
    if not isinstance(other, EntityCollection):
        return NotImplemented

    if not self._entities or not other._entities:
        return self.__class__([])

    other_set = set(other._entities)
    common = list(dict.fromkeys(e for e in self._entities if e in other_set))
    return self.__class__(common)

__or__(other)

Union (|): Returns a new collection with unique entities from both collections.

Source code in src/sportindex/domain/base.py
198
199
200
201
202
203
204
205
206
207
def __or__(self, other: EntityCollection[E]) -> EntityCollection[E]:
    """Union (|): Returns a new collection with unique entities from both collections."""
    if not isinstance(other, EntityCollection):
        return NotImplemented

    if not self._entities and not other._entities:
        return self.__class__([])

    merged = list(dict.fromkeys(self._entities + other._entities))
    return self.__class__(merged)

__sub__(other)

Difference (-): Returns a new collection with entities in self but not in other.

Source code in src/sportindex/domain/base.py
221
222
223
224
225
226
227
228
229
230
231
232
233
def __sub__(self, other: EntityCollection[E]) -> EntityCollection[E]:
    """Difference (-): Returns a new collection with entities in self but not in other."""
    if not isinstance(other, EntityCollection):
        return NotImplemented

    if not self._entities:
        return self.__class__([])
    if not other._entities:
        return self.copy()

    other_set = set(other._entities)
    diff = list(dict.fromkeys(e for e in self._entities if e not in other_set))
    return self.__class__(diff)

append(entity)

Add a single entity to the collection.

Source code in src/sportindex/domain/base.py
150
151
152
def append(self, entity: E) -> None:
    """Add a single entity to the collection."""
    self._entities.append(entity)

copy()

Return a shallow copy of the collection.

Source code in src/sportindex/domain/base.py
194
195
196
def copy(self) -> EntityCollection[E]:
    """Return a shallow copy of the collection."""
    return self.__class__(self._entities.copy())

extend(collection)

Add all entities from another collection.

Source code in src/sportindex/domain/base.py
154
155
156
157
def extend(self, collection: EntityCollection[E] | list[E]) -> None:
    """Add all entities from another collection."""
    items = collection._entities if isinstance(collection, EntityCollection) else collection
    self._entities.extend(items)

get(**kwargs)

Get an entity by arbitrary attributes (e.g. id=1, name="Football").

Source code in src/sportindex/domain/base.py
170
171
172
173
174
175
def get(self, **kwargs) -> Optional[E]:
    """Get an entity by arbitrary attributes (e.g. id=1, name="Football")."""
    for e in self._entities:
        if all(getattr(e, k, None) == v for k, v in kwargs.items()):
            return e
    return None

search(query, by='name')

Smart search that handles case-insensitivity, ignores extra spaces, and allows for partial matches on a specified string attribute. Returns a new collection.

Source code in src/sportindex/domain/base.py
177
178
179
180
181
182
183
184
185
186
187
188
def search(self, query: str, by: str = "name") -> EntityCollection[E]:
    """
    Smart search that handles case-insensitivity, ignores extra spaces, 
    and allows for partial matches on a specified string attribute.
    Returns a new collection.
    """
    clean_query = query.strip().lower()
    results = [
        e for e in self._entities 
        if clean_query in str(getattr(e, by, "")).lower()
    ]
    return self.__class__(results)

to_list()

Return the entities as a list.

Source code in src/sportindex/domain/base.py
190
191
192
def to_list(self) -> list[E]:
    """Return the entities as a list."""
    return list(self._entities)

Event

Bases: IdentifiableEntity[_EventData | _StageData]

An event in a sport, such as a football match, tennis match, or motorsport race.

Provides access to event metadata, competitors, scores, lineups, incidents, statistics, and associated entities like venue, referee, season, and competition. Supports both match- and race-specific properties.

Attributes:

Name Type Description
id int

Unique event ID.

name str

Event name.

slug str

URL-friendly identifier.

start datetime

Start time of the event.

end datetime | None

End time, if available.

kind Literal['match', 'race']

Type of event.

round Round | None

Event round, if applicable.

sport Sport

Sport associated with this event.

season Season

Season this event belongs to.

competition Competition

Competition this event belongs to.

referee Referee | None

Referee for the event, if available.

venue Venue | None

Venue where the event takes place.

channels EventChannels

TV or streaming channels broadcasting the event.

Match-specific attributes

competitors (MatchCompetitors | None): Competitors in the event. score (Score | None): Score of the match. periods (list[MatchPeriod] | None): Periods of the match. lineups (MatchLineups | None): Player lineups. incidents (list[Incident] | None): Notable incidents. statistics (list[PeriodStats] | None): Event statistics. momentum_graph (list[MomentumPoint] | None): Momentum graph. h2h (EventCollection | None): Head-to-head history.

Race-specific attributes

substages (EventCollection | None): Substages of the race. standings (EntityCollection[Standings] | None): Standings of competitors and teams.

Post-event attributes

winner (Competitor | None): Winner of the event.

Methods:

Name Description
from_id

int, provider) -> Event: Fetch an event by its unique ID.

_full_load

Lazy-load full event details from the provider.

Source code in src/sportindex/domain/event.py
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
class Event(IdentifiableEntity[_EventData | _StageData]):
    """An event in a sport, such as a football match, tennis match, or motorsport race.

    Provides access to event metadata, competitors, scores, lineups, incidents, statistics, and associated entities
    like venue, referee, season, and competition. Supports both match- and race-specific properties.

    Attributes:
        id (int): Unique event ID.
        name (str): Event name.
        slug (str): URL-friendly identifier.
        start (datetime): Start time of the event.
        end (datetime | None): End time, if available.
        kind (Literal["match", "race"]): Type of event.
        round (Round | None): Event round, if applicable.
        sport (Sport): Sport associated with this event.
        season (Season): Season this event belongs to.
        competition (Competition): Competition this event belongs to.
        referee (Referee | None): Referee for the event, if available.
        venue (Venue | None): Venue where the event takes place.
        channels (EventChannels): TV or streaming channels broadcasting the event.

    Match-specific attributes:
        competitors (MatchCompetitors | None): Competitors in the event.
        score (Score | None): Score of the match.
        periods (list[MatchPeriod] | None): Periods of the match.
        lineups (MatchLineups | None): Player lineups.
        incidents (list[Incident] | None): Notable incidents.
        statistics (list[PeriodStats] | None): Event statistics.
        momentum_graph (list[MomentumPoint] | None): Momentum graph.
        h2h (EventCollection | None): Head-to-head history.

    Race-specific attributes:
        substages (EventCollection | None): Substages of the race.
        standings (EntityCollection[Standings] | None): Standings of competitors and teams.

    Post-event attributes:
        winner (Competitor | None): Winner of the event.

    Methods:
        from_id(event_id: int, provider) -> Event: Fetch an event by its unique ID.
        _full_load() -> None: Lazy-load full event details from the provider.
    """
    _REPR_FIELDS = ("id", "name", "slug", "start", "kind", "end", "round", "competitors")
    _TYPE_MAP = {_EventData: 1, _StageData: 2}

    def __init__(self, data: _EventData | _StageData, provider: SofascoreProvider, **kwargs) -> None:
        super().__init__(data, provider, **kwargs)

        if not isinstance(data, (_EventData, _StageData)):
            raise TypeError(f"Event data must be either _EventData or _StageData, got {type(data)}")

        self._full_loaded = False

    @property
    def id(self) -> int:
        """The unique ID of the event."""
        type_idx = self._TYPE_MAP[type(self._data)]
        return self.encode_id(self._data.id, type_idx)

    @property
    def name(self) -> str:
        """The name of the event."""
        return self._data.name if hasattr(self._data, "name") and self._data.name else self._data.slug.replace("-", " ").title()

    @property
    def slug(self) -> str:
        """The slug of the event (used in URLs)."""
        return self._data.slug

    @property
    def start(self) -> datetime:
        """The start time of the event."""
        return self._data.start

    @property
    def kind(self) -> Literal["match", "race"]:
        """The kind of the event (match or race)."""
        return "match" if isinstance(self._data, _EventData) else "race"

    @property
    def end(self) -> Optional[datetime]:
        """The end time of the event, if available."""
        return self._data.end if hasattr(self._data, "end") else None

    @property
    def round(self) -> Optional[Round]:
        """The round of the event, if available."""
        if isinstance(self._data, _EventData):
            return self._data.round
        elif isinstance(self._data, _StageData):
            from sportindex.provider.models import Round
            return Round(
                name=self._data.description,
                value=self._data.info.stage_round if self._data.info else None
            ) # Not the most semantically correct way to represent stage rounds, but the best we can do with the available data...
        else:
            raise TypeError(f"Event data must be either _EventData or _StageData to determine round, got {type(self._data)}")

    @property
    def sport(self) -> Sport:
        """The sport this event belongs to."""
        return self.season.sport

    @cached_property
    def season(self) -> Season:
        """The season this event belongs to."""
        from sportindex.provider.models import _EventData, _StageData
        if isinstance(self._data, _EventData):
            return Season(self._data.season, self._provider, uniqueTournament=self._data.tournament.unique_tournament)
        elif isinstance(self._data, _StageData):
            parent_stage_id = self._data.parent.id if self._data.parent else None
            if not parent_stage_id:
                raise EntityNotFoundError(f"Parent stage not found for stage {self.id}, cannot determine season")
            return Season(self._provider.get_stage(parent_stage_id), self._provider)
        else:
            raise TypeError(f"Event data must be either _EventData or _StageData to determine season, got {type(self._data)}")

    @property
    def competition(self) -> Competition:
        """The competition this event belongs to."""
        return self.season.competition

    @cached_property
    def referee(self) -> Optional[Referee]:
        """The referee for this event, if available."""
        from .referee import Referee
        if isinstance(self._data, _EventData) and self._data.referee:
            return Referee(self._data.referee, self._provider)

    @cached_property
    def venue(self) -> Optional[Venue]:
        """The venue where this event takes place, if available."""
        from .venue import Venue
        if isinstance(self._data, _EventData) and self._data.venue:
            return Venue(self._data.venue, self._provider)
        elif isinstance(self._data, _StageData):
            return Venue(self._data, self._provider)

    @property
    def channels(self) -> EventChannels:
        """Get the channels broadcasting this event, if match and available."""
        from .channel import EventChannels
        if isinstance(self._data, _EventData):
            return EventChannels(self._provider.get_event_channels(self._data.id), self._provider)
        elif isinstance(self._data, _StageData):
            return EventChannels(self._provider.get_stage_channels(self._data.id), self._provider)


    # Match specific properties

    @cached_property
    def competitors(self) -> Optional[MatchCompetitors]:
        """The competitors in this event, if match and available."""
        if isinstance(self._data, _EventData):
            from .competitor import Competitor
            return MatchCompetitors(
                home=Competitor(self._data.home.team, self._provider),
                away=Competitor(self._data.away.team, self._provider)
            )
        return None

    @property
    def score(self) -> Optional[Score]:
        """The score for this event, if match and available."""
        if isinstance(self._data, _EventData):
            from sportindex.provider.models import Score
            return Score(
                home=self._data.home.score,
                away=self._data.away.score
            )
        return None

    @property
    def periods(self) -> Optional[list[MatchPeriod]]:
        """The periods for this event, if match and available."""
        if isinstance(self._data, _EventData):
            return self._data.periods.periods if self._data.periods else None
        return None

    @cached_property
    def lineups(self) -> Optional[MatchLineups]:
        """The lineups for this event, if match and available."""
        if isinstance(self._data, _EventData):
            try:
                parsed_lineups = self._provider.get_event_lineups(self._data.id)
                return MatchLineups._from_base_schema(parsed_lineups, self._provider)
            except ProviderNotFoundError:
                logger.debug(f"Lineups not found for event {self.id}.")
                return None
        return None

    @property
    def incidents(self) -> list[Incident]:
        """The incidents for this event, if match and available."""
        if isinstance(self._data, _EventData):
            try:
                from .incident import to_domain_incident
                parsed_incidents = self._provider.get_event_incidents(self._data.id)
                return [to_domain_incident(inc, provider=self._provider) for inc in parsed_incidents]
            except ProviderNotFoundError:
                logger.debug(f"Incidents not found for event {self.id}.")
                return []
        return []

    @cached_property
    def statistics(self) -> list[PeriodStats]:
        """The statistics for this event, if match and available."""
        if isinstance(self._data, _EventData):
            try:
                parsed_stats_response = self._provider.get_event_statistics(self._data.id)
                if parsed_stats_response:
                    return parsed_stats_response.statistics
            except ProviderNotFoundError:
                logger.debug(f"Statistics not found for event {self.id}.")
                return []
        return []

    @property
    def momentum_graph(self) -> Optional[list[MomentumPoint]]:
        """The momentum graph for this event, if match and available."""
        if isinstance(self._data, _EventData):
            try:
                graph = self._provider.get_event_graph(self._data.id)
                if graph:
                    return graph.points
            except ProviderNotFoundError:
                logger.debug(f"Momentum graph not found for event {self.id}.")
                return []
        return []

    @cached_property
    def h2h(self) -> EventCollection:
        """Head-to-head history for the competitors in this event, if match and available."""
        if isinstance(self._data, _EventData):
            try:
                return EventCollection([Event(e, self._provider) for e in self._provider.get_h2h_history(self._data.custom_id).events])
            except ProviderNotFoundError:
                logger.debug(f"H2H history not found for event {self.id}.")
                return EventCollection([])
        return EventCollection([])


    # Race specific properties

    @cached_property
    def substages(self) -> EventCollection:
        """The substages for this event, if race and available."""
        if isinstance(self._data, _StageData):
            try:
                return EventCollection([Event(s, self._provider) for s in self._provider.get_stage_substages(self._data.id)])
            except ProviderNotFoundError:
                logger.debug(f"Substages not found for event {self.id}.")
                return EventCollection([])
        return EventCollection([])

    @property
    def standings(self) -> Optional[EntityCollection[Standings]]:
        """The standings for this event, if race and available."""
        if isinstance(self._data, _StageData):
            try:
                competitors_standings = self._provider.get_stage_standings_competitors(self._data.id)
            except ProviderNotFoundError as e:
                logger.debug(f"Failed to fetch competitors standings for event {self.id}: {e}")
                competitors_standings = []
            try:
                teams_standings = self._provider.get_stage_standings_teams(self._data.id)
            except ProviderNotFoundError as e:
                logger.debug(f"Failed to fetch teams standings for event {self.id}: {e}")
                teams_standings = []
            return EntityCollection([
                Standings(competitors_standings, self._provider, name=f"Competitors {self.name}", kind="competitors"),
                Standings(teams_standings, self._provider, name=f"Teams {self.name}", kind="teams")
            ])
        return None


    # Post event properties (available for both matches and races)

    @cached_property
    def winner(self) -> Optional[Competitor]:
        """The winner of this event, if available."""
        if isinstance(self._data, _EventData):
            winner_code = self._data.winner_code
            if winner_code == 1:
                return Competitor(self._data.home.team, self._provider)
            elif winner_code == 2:
                return Competitor(self._data.away.team, self._provider)
        elif isinstance(self._data, _EventData):
            if self._data.winner:
                return Competitor(self._data.winner, self._provider)
        return None

    def _full_load(self) -> None:
        """
        Lazy-loads the complete event from the provider.
        Called automatically when accessing properties that require full details
        missing from the initial lightweight API response.
        """
        if self._full_loaded:
            return
        try:
            if isinstance(self._data, _EventData):
                self._data = merge_pydantic_models(self._data, self._provider.get_event(self._data.id))
            elif isinstance(self._data, _StageData):
                self._data = merge_pydantic_models(self._data, self._provider.get_stage_details(self._data.id))
            if not isinstance(self._data, (_EventData, _StageData)):
                raise TypeError(f"Event data must be either _EventData or _StageData after full load, got {type(self._data)}")
            self._full_loaded = True
            self._clear_cache()
        except ProviderNotFoundError:
            logger.debug(f"Event with id {self._data.id} not found during full load.")
            self._full_loaded = True
            self._clear_cache()
        except FetchError as e:
            logger.debug(f"Network error while fully loading event with id {self._data.id}: {e}")
            self._full_loaded = True
            self._clear_cache()

    def _clear_cache(self) -> None:
        """Clear cached properties."""
        self.__dict__.pop("season", None)
        self.__dict__.pop("venue", None)
        self.__dict__.pop("competitors", None)
        self.__dict__.pop("score", None)
        self.__dict__.pop("lineups", None)
        self.__dict__.pop("h2h", None)
        self.__dict__.pop("substages", None)

    @classmethod
    def from_id(cls, event_id: int, provider: SofascoreProvider) -> Event:
        """Fetch an event by its ID."""
        raw_id, type_idx = cls.decode_id(event_id)
        type_map_reverse = {v: k for k, v in cls._TYPE_MAP.items()}

        if type_idx not in type_map_reverse:
            raise ValueError(f"Invalid event ID {event_id}: unknown type index {type_idx}")

        data_cls = type_map_reverse[type_idx]
        try:
            if data_cls == _EventData:
                parsed_data = provider.get_event(raw_id)
            elif data_cls == _StageData:
                parsed_data = provider.get_stage(raw_id)
            else:
                raise TypeError(f"Unsupported event type index {type_idx} in ID {event_id}")
        except ProviderNotFoundError as e:
            raise EntityNotFoundError(f"Event with id {event_id} not found") from e
        except FetchError as e:
            raise DomainError(f"Network error while fetching event {event_id}") from e

        return cls(parsed_data, provider)

channels property

Get the channels broadcasting this event, if match and available.

competition property

The competition this event belongs to.

competitors cached property

The competitors in this event, if match and available.

end property

The end time of the event, if available.

h2h cached property

Head-to-head history for the competitors in this event, if match and available.

id property

The unique ID of the event.

incidents property

The incidents for this event, if match and available.

kind property

The kind of the event (match or race).

lineups cached property

The lineups for this event, if match and available.

momentum_graph property

The momentum graph for this event, if match and available.

name property

The name of the event.

periods property

The periods for this event, if match and available.

referee cached property

The referee for this event, if available.

round property

The round of the event, if available.

score property

The score for this event, if match and available.

season cached property

The season this event belongs to.

slug property

The slug of the event (used in URLs).

sport property

The sport this event belongs to.

standings property

The standings for this event, if race and available.

start property

The start time of the event.

statistics cached property

The statistics for this event, if match and available.

substages cached property

The substages for this event, if race and available.

venue cached property

The venue where this event takes place, if available.

winner cached property

The winner of this event, if available.

from_id(event_id, provider) classmethod

Fetch an event by its ID.

Source code in src/sportindex/domain/event.py
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
@classmethod
def from_id(cls, event_id: int, provider: SofascoreProvider) -> Event:
    """Fetch an event by its ID."""
    raw_id, type_idx = cls.decode_id(event_id)
    type_map_reverse = {v: k for k, v in cls._TYPE_MAP.items()}

    if type_idx not in type_map_reverse:
        raise ValueError(f"Invalid event ID {event_id}: unknown type index {type_idx}")

    data_cls = type_map_reverse[type_idx]
    try:
        if data_cls == _EventData:
            parsed_data = provider.get_event(raw_id)
        elif data_cls == _StageData:
            parsed_data = provider.get_stage(raw_id)
        else:
            raise TypeError(f"Unsupported event type index {type_idx} in ID {event_id}")
    except ProviderNotFoundError as e:
        raise EntityNotFoundError(f"Event with id {event_id} not found") from e
    except FetchError as e:
        raise DomainError(f"Network error while fetching event {event_id}") from e

    return cls(parsed_data, provider)

EventAwareMixin

Toolkit for entities that fetch fixtures and results. Provides shared pagination and unified date filtering.

Source code in src/sportindex/domain/base.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
class EventAwareMixin:
    """
    Toolkit for entities that fetch fixtures and results.
    Provides shared pagination and unified date filtering.
    """

    def get_fixtures(self, silent: bool = False) -> EventCollection:
        """Override in subclass if fixtures are supported."""
        raise NotImplementedError(f"Method get_fixtures must be implemented in the subclass {self.__class__.__name__}")

    def get_results(self, silent: bool = False) -> EventCollection:
        """Override in subclass if results are supported."""
        raise NotImplementedError(f"Method get_results must be implemented in the subclass {self.__class__.__name__}")

    def get_events(self) -> EventCollection:
        """Fetch all events and apply filters."""
        events: EventCollection = self.get_results(silent=True) + self.get_fixtures(silent=True)
        return events.sort_by_date()

    def _fetch_paginated_events(self, provider_callable: Callable, *args, max_pages: int = 10) -> EventCollection:
        """Internal helper to exhaust a paginated provider endpoint."""
        from .event import Event, EventCollection
        parsed_events = []
        for page in range(max_pages):
            events_response: _EventsResponse = provider_callable(*args, page=page)
            parsed_events.extend(events_response.events)

            # Use getattr safely in case the response lacks hasNextPage
            if not getattr(events_response, "hasNextPage", False):
                break

        # self._provider exists because this mixin will be attached to BaseEntity subclasses
        return EventCollection([Event(e, getattr(self, "_provider")) for e in parsed_events])

get_events()

Fetch all events and apply filters.

Source code in src/sportindex/domain/base.py
 99
100
101
102
def get_events(self) -> EventCollection:
    """Fetch all events and apply filters."""
    events: EventCollection = self.get_results(silent=True) + self.get_fixtures(silent=True)
    return events.sort_by_date()

get_fixtures(silent=False)

Override in subclass if fixtures are supported.

Source code in src/sportindex/domain/base.py
91
92
93
def get_fixtures(self, silent: bool = False) -> EventCollection:
    """Override in subclass if fixtures are supported."""
    raise NotImplementedError(f"Method get_fixtures must be implemented in the subclass {self.__class__.__name__}")

get_results(silent=False)

Override in subclass if results are supported.

Source code in src/sportindex/domain/base.py
95
96
97
def get_results(self, silent: bool = False) -> EventCollection:
    """Override in subclass if results are supported."""
    raise NotImplementedError(f"Method get_results must be implemented in the subclass {self.__class__.__name__}")

EventChannels

Bases: BaseEntity[_CountryChannelsResponse]

Channels broadcasting a specific event, organized by country.

Allows querying which channels broadcast the event in a given country.

Attributes:

Name Type Description
channels dict[str, list[int]]

Mapping from country alpha-2 codes to lists of channel IDs.

Methods:

Name Description
get_channels

Return Channel entities broadcasting the event in a specific country.

Raises:

Type Description
TypeError

If initialized with invalid data type.

EntityNotFoundError

If a specified country cannot be found.

Source code in src/sportindex/domain/channel.py
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
class EventChannels(BaseEntity[_CountryChannelsResponse]):
    """Channels broadcasting a specific event, organized by country.

    Allows querying which channels broadcast the event in a given country.

    Attributes:
        channels (dict[str, list[int]]): Mapping from country alpha-2 codes to lists of channel IDs.

    Methods:
        get_channels(country, country_name, country_alpha): Return Channel entities broadcasting the event in a specific country.

    Raises:
        TypeError: If initialized with invalid data type.
        EntityNotFoundError: If a specified country cannot be found.
    """
    _REPR_FIELDS = ("channels")

    def __init__(self, data: _CountryChannelsResponse, provider: SofascoreProvider, **kwargs) -> None:
        super().__init__(data, provider, **kwargs)

        if not isinstance(data, _CountryChannelsResponse):
            raise TypeError(f"EventChannels data must be of type _CountryChannelsResponse, got {type(data)}")

    @property
    def channels(self) -> dict[str, list[int]]:
        """A dictionary mapping country alpha-2 codes to lists of channel IDs broadcasting this event in that country."""
        return self._data.channels

    def get_channels(self, *, country: Country | None = None, country_name: str | None = None, country_alpha: str | None = None) -> EntityCollection[Channel]:
        """Get the channels broadcasting this event in a specific country (by object, name or alpha code)."""
        if country is not None:
            country_alpha2 = country.alpha2
        elif country_name is not None:
            country_obj = next(
                (c for c in pycountry.countries if c.name.lower() == country_name.lower()), None
            )
            if country_obj is None:
                raise EntityNotFoundError(f"Country with name '{country_name}' not found")
            country_alpha2 = country_obj.alpha_2
        elif country_alpha is not None:
            country_obj = next(
                (c for c in pycountry.countries if c.alpha_2 == country_alpha.upper() or c.alpha_3 == country_alpha.upper()),
                None
            )
            if country_obj is None:
                raise EntityNotFoundError(f"Country with alpha code '{country_alpha}' not found")
            country_alpha2 = country_obj.alpha_2
        else:
            raise TypeError("Must provide either country object, name or alpha code")
        return EntityCollection([Channel.from_id(cid, self._provider) for cid in self.channels.get(country_alpha2, [])])

channels property

A dictionary mapping country alpha-2 codes to lists of channel IDs broadcasting this event in that country.

get_channels(*, country=None, country_name=None, country_alpha=None)

Get the channels broadcasting this event in a specific country (by object, name or alpha code).

Source code in src/sportindex/domain/channel.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
def get_channels(self, *, country: Country | None = None, country_name: str | None = None, country_alpha: str | None = None) -> EntityCollection[Channel]:
    """Get the channels broadcasting this event in a specific country (by object, name or alpha code)."""
    if country is not None:
        country_alpha2 = country.alpha2
    elif country_name is not None:
        country_obj = next(
            (c for c in pycountry.countries if c.name.lower() == country_name.lower()), None
        )
        if country_obj is None:
            raise EntityNotFoundError(f"Country with name '{country_name}' not found")
        country_alpha2 = country_obj.alpha_2
    elif country_alpha is not None:
        country_obj = next(
            (c for c in pycountry.countries if c.alpha_2 == country_alpha.upper() or c.alpha_3 == country_alpha.upper()),
            None
        )
        if country_obj is None:
            raise EntityNotFoundError(f"Country with alpha code '{country_alpha}' not found")
        country_alpha2 = country_obj.alpha_2
    else:
        raise TypeError("Must provide either country object, name or alpha code")
    return EntityCollection([Channel.from_id(cid, self._provider) for cid in self.channels.get(country_alpha2, [])])

EventCollection

Bases: EntityCollection[Event]

A specialized collection for handling lists of events with common filtering and sorting needs.

Source code in src/sportindex/domain/event.py
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
class EventCollection(EntityCollection[Event]):
    """A specialized collection for handling lists of events with common filtering and sorting needs."""

    def filter_by_date(self, *, before: Optional[date | datetime] = None, after: Optional[date | datetime] = None) -> EventCollection:
        """Return a new EventCollection filtered by date."""
        results = self._entities

        def to_dt(val: date | datetime) -> datetime:
            if isinstance(val, datetime): return val
            return datetime.combine(val, datetime.min.time())

        if before is not None:
            before_dt = to_dt(before)
            results = [e for e in results if e.start < before_dt]
        if after is not None:
            after_dt = to_dt(after)
            results = [e for e in results if e.start > after_dt]

        return self.__class__(results)

    def filter_by_competitors(self, competitor_ids: list[int]) -> EventCollection:
        """Return a new EventCollection filtered by competitor IDs."""
        results = []
        for event in self._entities:
            if event.competitors:
                if (event.competitors.home.id in competitor_ids) or (event.competitors.away.id in competitor_ids):
                    results.append(event)
        return self.__class__(results)

    def sort_by_date(self, ascending: bool = True) -> EventCollection:
        """Return a new EventCollection sorted by date."""
        return self.__class__(sorted(self._entities, key=lambda e: e.start, reverse=not ascending))

filter_by_competitors(competitor_ids)

Return a new EventCollection filtered by competitor IDs.

Source code in src/sportindex/domain/event.py
434
435
436
437
438
439
440
441
def filter_by_competitors(self, competitor_ids: list[int]) -> EventCollection:
    """Return a new EventCollection filtered by competitor IDs."""
    results = []
    for event in self._entities:
        if event.competitors:
            if (event.competitors.home.id in competitor_ids) or (event.competitors.away.id in competitor_ids):
                results.append(event)
    return self.__class__(results)

filter_by_date(*, before=None, after=None)

Return a new EventCollection filtered by date.

Source code in src/sportindex/domain/event.py
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
def filter_by_date(self, *, before: Optional[date | datetime] = None, after: Optional[date | datetime] = None) -> EventCollection:
    """Return a new EventCollection filtered by date."""
    results = self._entities

    def to_dt(val: date | datetime) -> datetime:
        if isinstance(val, datetime): return val
        return datetime.combine(val, datetime.min.time())

    if before is not None:
        before_dt = to_dt(before)
        results = [e for e in results if e.start < before_dt]
    if after is not None:
        after_dt = to_dt(after)
        results = [e for e in results if e.start > after_dt]

    return self.__class__(results)

sort_by_date(ascending=True)

Return a new EventCollection sorted by date.

Source code in src/sportindex/domain/event.py
443
444
445
def sort_by_date(self, ascending: bool = True) -> EventCollection:
    """Return a new EventCollection sorted by date."""
    return self.__class__(sorted(self._entities, key=lambda e: e.start, reverse=not ascending))

Gender

Bases: str, Enum

Standardized representation of gender for competitors.

Values

UNSPECIFIED ("X"): Unknown or not specified. MALE ("M"): Male. FEMALE ("F"): Female.

Source code in src/sportindex/domain/gender.py
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Gender(str, Enum):
    """Standardized representation of gender for competitors.

    Values:
        UNSPECIFIED ("X"): Unknown or not specified.
        MALE ("M"): Male.
        FEMALE ("F"): Female.
    """
    UNSPECIFIED = "X"
    MALE = "M"
    FEMALE = "F"

    @classmethod
    def _missing_(cls, value):
        # This triggers if 'value' is not "X", "M", or "F".
        logger.debug(f"Received unknown gender value '{value}', defaulting to UNSPECIFIED")
        return cls.UNSPECIFIED

IdentifiableEntity

Bases: BaseEntity[SingleT]

Base class for entities that have a unique identifier.

Source code in src/sportindex/domain/base.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
class IdentifiableEntity(BaseEntity[SingleT]):
    """Base class for entities that have a unique identifier."""
    _ID_OFFSET_STEP = 10_000_000_000 # to avoid ID collisions across entity types when using several sofascore types for the same entity (e.g. competitions, seasons, events, competitors, etc.)

    @property
    @abstractmethod
    def id(self) -> int:
        """The unique ID of the entity, encoded as a globally unique SDK ID."""
        raise NotImplementedError("Subclasses of IdentifiableEntity must implement the id property")

    @classmethod
    def encode_id(cls, raw_id: int, type_idx: int) -> int:
        """Creates a globally unique SDK ID by combining the raw ID with a type index."""
        return (type_idx * cls._ID_OFFSET_STEP) + raw_id

    @classmethod
    def decode_id(cls, sdk_id: int) -> tuple[int, int]:
        """Splits an SDK ID back into its raw ID and type index components."""
        raw_id = sdk_id % cls._ID_OFFSET_STEP
        type_idx = sdk_id // cls._ID_OFFSET_STEP
        return raw_id, type_idx

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, type(self)):
            return NotImplemented
        return self.id == other.id

    def __hash__(self) -> int:
        return hash(self.id)

id abstractmethod property

The unique ID of the entity, encoded as a globally unique SDK ID.

decode_id(sdk_id) classmethod

Splits an SDK ID back into its raw ID and type index components.

Source code in src/sportindex/domain/base.py
69
70
71
72
73
74
@classmethod
def decode_id(cls, sdk_id: int) -> tuple[int, int]:
    """Splits an SDK ID back into its raw ID and type index components."""
    raw_id = sdk_id % cls._ID_OFFSET_STEP
    type_idx = sdk_id // cls._ID_OFFSET_STEP
    return raw_id, type_idx

encode_id(raw_id, type_idx) classmethod

Creates a globally unique SDK ID by combining the raw ID with a type index.

Source code in src/sportindex/domain/base.py
64
65
66
67
@classmethod
def encode_id(cls, raw_id: int, type_idx: int) -> int:
    """Creates a globally unique SDK ID by combining the raw ID with a type index."""
    return (type_idx * cls._ID_OFFSET_STEP) + raw_id

Manager

Bases: IdentifiableEntity[_ManagerData], EventAwareMixin

Represents a sports manager/coach (e.g., football manager, Formula 1 team principal).

This entity handles basic information, associated sport and country, team affiliations, career history, and provides access to fixtures and results. Supports lazy full-loading for properties that require more detailed API responses.

Attributes:

Name Type Description
id int

Unique identifier of the manager.

name str

Full name of the manager.

slug str

URL-friendly slug of the manager.

short_name str

Shortened name or abbreviation (e.g., "Z. Zidane").

sport Sport

Sport associated with the manager.

country Country | None

Country associated with the manager, if available.

team Competitor | None

Current primary team, if assigned.

teams EntityCollection[Competitor]

All teams associated with the manager.

performances list[ManagerTenure]

Career history and performance records of the manager.

Methods:

Name Description
get_fixtures

bool = False) -> EventCollection: Returns fixtures for this manager. Currently returns empty, logs a warning.

get_results

bool = False) -> EventCollection: Returns results for this manager.

from_id

int, provider: SofascoreProvider) -> Manager: Fetch a manager by its unique ID.

search

str, provider: SofascoreProvider) -> EntityCollection[Manager]: Search for managers matching a query string (up to 20 results).

Source code in src/sportindex/domain/manager.py
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
class Manager(IdentifiableEntity[_ManagerData], EventAwareMixin):
    """Represents a sports manager/coach (e.g., football manager, Formula 1 team principal).

    This entity handles basic information, associated sport and country, team affiliations,
    career history, and provides access to fixtures and results. Supports lazy full-loading
    for properties that require more detailed API responses.

    Attributes:
        id (int): Unique identifier of the manager.
        name (str): Full name of the manager.
        slug (str): URL-friendly slug of the manager.
        short_name (str): Shortened name or abbreviation (e.g., "Z. Zidane").
        sport (Sport): Sport associated with the manager.
        country (Country | None): Country associated with the manager, if available.
        team (Competitor | None): Current primary team, if assigned.
        teams (EntityCollection[Competitor]): All teams associated with the manager.
        performances (list[ManagerTenure]): Career history and performance records of the manager.

    Methods:
        get_fixtures(silent: bool = False) -> EventCollection:
            Returns fixtures for this manager. Currently returns empty, logs a warning.
        get_results(silent: bool = False) -> EventCollection:
            Returns results for this manager.
        from_id(manager_id: int, provider: SofascoreProvider) -> Manager:
            Fetch a manager by its unique ID.
        search(query: str, provider: SofascoreProvider) -> EntityCollection[Manager]:
            Search for managers matching a query string (up to 20 results).
    """
    _REPR_FIELDS = ("id", "name", "slug", "short_name", "sport", "country")

    def __init__(self, data: _ManagerData, provider: SofascoreProvider, **kwargs) -> None:
        super().__init__(data, provider, **kwargs)

        if not isinstance(data, _ManagerData):
            raise TypeError(f"Manager data must be of type _ManagerData, got {type(data)}")

        self._full_loaded = False

    @property
    def id(self) -> int:
        """The unique ID of the manager."""
        return self._data.id

    @property
    def name(self) -> str:
        """The full name of the manager."""
        return self._data.name

    @property
    def slug(self) -> str:
        """The slug of the manager (used in URLs)."""
        return self._data.slug or self._data.name.lower().replace(" ", "-")

    @property
    def short_name(self) -> str:
        """The short name of the manager, e.g. "Z. Zidane"."""
        return self._data.short_name

    @cached_property
    def sport(self) -> Sport:
        """The sport this manager is associated with."""
        return Sport(self._data.sport, self._provider)

    @cached_property
    def country(self) -> Optional[Country]:
        """The country this manager is associated with, if any."""
        return Country(self._data.country, self._provider) if self._data.country else None

    @cached_property
    def team(self) -> Optional[Competitor]:
        self._full_load()
        from .competitor import Competitor
        return Competitor(self._data.team, self._provider) if self._data.team else None

    @cached_property
    def teams(self) -> EntityCollection[Competitor]:
        self._full_load()
        from .competitor import Competitor
        return EntityCollection([Competitor(t, self._provider) for t in self._data.teams]) if self._data.teams else EntityCollection([])

    @cached_property
    def performances(self) -> list[ManagerTenure]:
        parsed_career_history = self._provider.get_manager_career_history(self._data.id)
        return [ManagerTenure._from_base_schema(parsed, provider=self._provider) for parsed in parsed_career_history]

    def get_fixtures(self, silent: bool = False) -> EventCollection:
        """Fetch all fixtures for this manager."""
        from .event import EventCollection
        if not silent:
            logger.warning("No fixtures endpoint available for managers, returning empty list")
        return EventCollection([])

    def get_results(self, silent: bool = False) -> EventCollection:
        """Fetch all results for this manager."""
        return self._fetch_paginated_events(self._provider.get_manager_results, self._data.id)

    def _full_load(self) -> None:
        """
        Lazy-loads the complete manager from the provider.
        Called automatically when accessing properties that require full details 
        missing from the initial lightweight API response.
        """
        if self._full_loaded:
            return
        try:
            self._data = merge_pydantic_models(self._data, self._provider.get_manager(self._data.id))
            if not isinstance(self._data, _ManagerData):
                raise TypeError(f"Manager data must be of type _ManagerData after full load, got {type(self._data)}")
            self._full_loaded = True
            self._clear_cache()
        except ProviderNotFoundError:
            logger.debug(f"Manager with id {self._data.id} not found during full load")
            self._full_loaded = True
            self._clear_cache()
        except FetchError as e:
            logger.debug(f"Network error while fully loading manager with id {self._data.id}: {e}")
            self._full_loaded = True
            self._clear_cache()

    def _clear_cache(self) -> None:
        """Clear cached properties."""
        self.__dict__.pop("sport", None)
        self.__dict__.pop("country", None)
        self.__dict__.pop("team", None)
        self.__dict__.pop("teams", None)
        self.__dict__.pop("performances", None)

    @classmethod
    def from_id(cls, manager_id: int, provider: SofascoreProvider) -> Manager:
        """Fetch a manager by its ID."""
        try:
            parsed_data = provider.get_manager(manager_id)
        except ProviderNotFoundError as e:
            raise EntityNotFoundError(f"Manager with id {manager_id} not found") from e
        except FetchError as e:
            raise DomainError(f"Network error while fetching manager {manager_id}") from e
        return cls(parsed_data, provider)

    @classmethod
    def search(cls, query: str, provider: SofascoreProvider) -> EntityCollection[Manager]:
        """Search for managers matching the given query (up to the first 20 matches)."""
        entities = []
        for page in range(51): # Sofascore has a maximum of 50 pages of search results
            matches = provider.search_managers(query=query, page=page)
            if not matches:
                break
            for item in matches:
                entities.append(Manager(item.entity, provider))
            if len(matches) > 20:
                break
        return EntityCollection(entities[:20])

country cached property

The country this manager is associated with, if any.

id property

The unique ID of the manager.

name property

The full name of the manager.

short_name property

The short name of the manager, e.g. "Z. Zidane".

slug property

The slug of the manager (used in URLs).

sport cached property

The sport this manager is associated with.

from_id(manager_id, provider) classmethod

Fetch a manager by its ID.

Source code in src/sportindex/domain/manager.py
158
159
160
161
162
163
164
165
166
167
@classmethod
def from_id(cls, manager_id: int, provider: SofascoreProvider) -> Manager:
    """Fetch a manager by its ID."""
    try:
        parsed_data = provider.get_manager(manager_id)
    except ProviderNotFoundError as e:
        raise EntityNotFoundError(f"Manager with id {manager_id} not found") from e
    except FetchError as e:
        raise DomainError(f"Network error while fetching manager {manager_id}") from e
    return cls(parsed_data, provider)

get_fixtures(silent=False)

Fetch all fixtures for this manager.

Source code in src/sportindex/domain/manager.py
116
117
118
119
120
121
def get_fixtures(self, silent: bool = False) -> EventCollection:
    """Fetch all fixtures for this manager."""
    from .event import EventCollection
    if not silent:
        logger.warning("No fixtures endpoint available for managers, returning empty list")
    return EventCollection([])

get_results(silent=False)

Fetch all results for this manager.

Source code in src/sportindex/domain/manager.py
123
124
125
def get_results(self, silent: bool = False) -> EventCollection:
    """Fetch all results for this manager."""
    return self._fetch_paginated_events(self._provider.get_manager_results, self._data.id)

search(query, provider) classmethod

Search for managers matching the given query (up to the first 20 matches).

Source code in src/sportindex/domain/manager.py
169
170
171
172
173
174
175
176
177
178
179
180
181
@classmethod
def search(cls, query: str, provider: SofascoreProvider) -> EntityCollection[Manager]:
    """Search for managers matching the given query (up to the first 20 matches)."""
    entities = []
    for page in range(51): # Sofascore has a maximum of 50 pages of search results
        matches = provider.search_managers(query=query, page=page)
        if not matches:
            break
        for item in matches:
            entities.append(Manager(item.entity, provider))
        if len(matches) > 20:
            break
    return EntityCollection(entities[:20])

PlayerInfo

Bases: BaseModel

Comprehensive details about an individual athlete.

Covers identity, physical attributes, career status, technical profile, and financial/contractual data.

Attributes:

Name Type Description
first_name str | None

Player's first name.

last_name str | None

Player's last name.

weight float | None

Player weight in kilograms.

height int | None

Player height in centimeters.

date_of_birth date | None

Birth date.

place_of_birth str | None

Birthplace.

retired bool | None

Whether the player is retired.

deceased bool | None

Whether the player is deceased.

number int | None

Shirt or squad number.

preferred_foot str | None

Dominant foot (if applicable).

preferred_hand str | None

Dominant hand (if applicable).

positions list[str] | None

Positions played.

total_prizes Amount | None

Career prize earnings.

salary Amount | None

Current salary.

market_value Amount | None

Market valuation.

contract_expiry date | None

Contract end date.

Methods:

Name Description
_from_parsed_player

_PlayerData) -> PlayerInfo: Create instance from _PlayerData data.

_from_parsed_player_team_info

_PlayerTeamInfoData) -> PlayerInfo: Create instance from _PlayerTeamInfoData data.

Source code in src/sportindex/domain/competitor.py
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
class PlayerInfo(BaseModel):
    """Comprehensive details about an individual athlete.

    Covers identity, physical attributes, career status, technical profile, and financial/contractual data.

    Attributes:
        first_name (str | None): Player's first name.
        last_name (str | None): Player's last name.
        weight (float | None): Player weight in kilograms.
        height (int | None): Player height in centimeters.
        date_of_birth (date | None): Birth date.
        place_of_birth (str | None): Birthplace.
        retired (bool | None): Whether the player is retired.
        deceased (bool | None): Whether the player is deceased.
        number (int | None): Shirt or squad number.
        preferred_foot (str | None): Dominant foot (if applicable).
        preferred_hand (str | None): Dominant hand (if applicable).
        positions (list[str] | None): Positions played.
        total_prizes (Amount | None): Career prize earnings.
        salary (Amount | None): Current salary.
        market_value (Amount | None): Market valuation.
        contract_expiry (date | None): Contract end date.

    Methods:
        _from_parsed_player(data: _PlayerData) -> PlayerInfo: Create instance from _PlayerData data.
        _from_parsed_player_team_info(data: _PlayerTeamInfoData) -> PlayerInfo: Create instance from _PlayerTeamInfoData data.
    """

    # --- Identity & Physical Attributes ---
    first_name: Optional[str] = None
    last_name: Optional[str] = None
    weight: Optional[float] = None
    height: Optional[int] = None
    date_of_birth: Optional[date] = None
    place_of_birth: Optional[str] = None
    retired: Optional[bool] = None
    deceased: Optional[bool] = None

    # --- Technical Profile & Gameplay ---
    number: Optional[int] = None
    preferred_foot: Optional[str] = None  # e.g. "left", "right", "both"
    preferred_hand: Optional[str] = None  # e.g. "left", "right", "both"
    positions: Optional[list[str]] = None

    # --- Valuation & Contractual Data ---
    total_prizes: Optional[Amount] = None
    salary: Optional[Amount] = None
    market_value: Optional[Amount] = None
    contract_expiry: Optional[date] = None

    @classmethod
    def _from_parsed_player_team_info(cls, data: _PlayerTeamInfoData) -> PlayerInfo:
        """Create a PlayerInfo instance from _PlayerTeamInfoData data."""
        return cls(
            weight=float(data.weight),
            height=int(data.height * 100),
            date_of_birth=data.birth_date.date() if data.birth_date else None,
            place_of_birth=data.birthplace,
            number=int(data.number),
            preferred_foot=data.plays, # Note: probably never populated, as there are not single sport using the foot preference field...
            preferred_hand=data.plays,
            total_prizes=data.prize_total,
        )

    @classmethod
    def _from_parsed_player(cls, data: _PlayerData) -> PlayerInfo:
        """Create a PlayerInfo instance from _PlayerData data."""
        return cls(
            first_name=data.first_name,
            last_name=data.last_name,
            weight=float(data.weight),
            height=int(data.height),
            date_of_birth=data.date_of_birth.date() if data.date_of_birth else None,
            retired=data.retired,
            deceased=data.deceased,
            number=int(data.shirt_number),
            preferred_foot=data.preferred_foot,
            preferred_hand=data.preferred_hand,
            positions=data.positions_detailed or [data.position] or [data.primary_position] or None,
            salary=data.salary,
            market_value=data.proposed_market_value,
            contract_expiry=data.contract_until.date() if data.contract_until else None,
        )

Rankings

Bases: BaseEntity[_RankingsResponse]

Represents the rankings of a sport, e.g., FIFA, ATP, or Olympic rankings.

Attributes:

Name Type Description
id int | None

Unique ID of the ranking type.

name str | None

Name of the rankings.

slug str | None

URL-friendly slug of the ranking.

updated_at datetime | None

Last updated timestamp.

gender Gender | None

Gender category of the ranking (M/F/X).

sport Sport | None

Sport associated with the ranking.

category Category | None

Category, if applicable.

competition Competition | None

Competition associated, if applicable.

entries list[RankingsEntry]

Ordered list of ranking entries.

Source code in src/sportindex/domain/leaderboard.py
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
class Rankings(BaseEntity[_RankingsResponse]):
    """Represents the rankings of a sport, e.g., FIFA, ATP, or Olympic rankings.

    Attributes:
        id (int | None): Unique ID of the ranking type.
        name (str | None): Name of the rankings.
        slug (str | None): URL-friendly slug of the ranking.
        updated_at (datetime | None): Last updated timestamp.
        gender (Gender | None): Gender category of the ranking (M/F/X).
        sport (Sport | None): Sport associated with the ranking.
        category (Category | None): Category, if applicable.
        competition (Competition | None): Competition associated, if applicable.
        entries (list[RankingsEntry]): Ordered list of ranking entries.
    """
    _REPR_FIELDS = ("id", "name", "slug", "sport", "category", "gender", "updated_at")

    def __init__(self, data: _RankingsResponse, provider: SofascoreProvider, **kwargs) -> None:
        super().__init__(data, provider, **kwargs)

        if not isinstance(data, _RankingsResponse):
            raise TypeError(f"Rankings data must be _RankingsResponse, got {type(data)}")

    @property
    def id(self) -> Optional[int]:
        """The unique ID of these rankings."""
        return self._data.ranking_type.id if self._data.ranking_type else None

    @property
    def name(self) -> Optional[str]:
        """The name of the rankings, e.g. "FIFA Rankings", "ATP Rankings", etc."""
        return self._data.ranking_type.name if self._data.ranking_type else None

    @property
    def slug(self) -> Optional[str]:
        """The slug of the rankings, e.g. "fifa", "atp", etc."""
        return self._data.ranking_type.slug if self._data.ranking_type else None

    @property
    def updated_at(self) -> Optional[datetime]:
        """The date and time when the rankings were last updated."""
        return self._data.ranking_type.last_updated if self._data.ranking_type else None

    @cached_property
    def gender(self) -> Optional[Gender]:
        """The gender category of these rankings, e.g. "M", "F" or "X" (mixed/other)."""
        from .core import Gender
        return Gender(self._data.ranking_type.gender) if self._data.ranking_type and self._data.ranking_type.gender else None

    @cached_property
    def entries(self) -> list[RankingsEntry]:
        """The entries in the rankings."""
        return [RankingsEntry._from_base_schema(e, self._provider) for e in self._data.ranking_rows]

    @cached_property
    def sport(self) -> Optional[Sport]:
        """The sport these rankings belong to."""
        from .core import Sport
        return Sport(self._data.ranking_type.sport, self._provider) if self._data.ranking_type and self._data.ranking_type.sport else None

    @cached_property
    def category(self) -> Optional[Category]:
        """The category these rankings belong to, if any."""
        from .core import Category
        try:
            return Category(self._data.ranking_type.category, self._provider) if self._data.ranking_type and self._data.ranking_type.category else None
        except TypeError:
            return None

    @cached_property
    def competition(self) -> Optional[Competition]:
        """The competition these rankings belong to, if any."""
        from .competition import Competition
        try:
            return Competition(self._data.ranking_type.unique_tournament, self._provider) if self._data.ranking_type and self._data.ranking_type.unique_tournament else None
        except TypeError:
            return None

category cached property

The category these rankings belong to, if any.

competition cached property

The competition these rankings belong to, if any.

entries cached property

The entries in the rankings.

gender cached property

The gender category of these rankings, e.g. "M", "F" or "X" (mixed/other).

id property

The unique ID of these rankings.

name property

The name of the rankings, e.g. "FIFA Rankings", "ATP Rankings", etc.

slug property

The slug of the rankings, e.g. "fifa", "atp", etc.

sport cached property

The sport these rankings belong to.

updated_at property

The date and time when the rankings were last updated.

RankingsEntry

Bases: BaseModel

A single entry in a Rankings table.

Attributes:

Name Type Description
position int

Current position.

entity Competitor | Competition

Entity being ranked.

points float

Points in the ranking.

previous_position int | None

Previous ranking position.

previous_points float | None

Previous points.

best_position int | None

Best historical position.

Source code in src/sportindex/domain/leaderboard.py
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
class RankingsEntry(BaseModel):
    """A single entry in a Rankings table.

    Attributes:
        position (int): Current position.
        entity (Competitor | Competition): Entity being ranked.
        points (float): Points in the ranking.
        previous_position (int | None): Previous ranking position.
        previous_points (float | None): Previous points.
        best_position (int | None): Best historical position.
    """
    position: int
    entity: Competitor | Competition
    points: Optional[float] = None

    previous_position: Optional[int] = None
    previous_points: Optional[float] = None
    best_position: Optional[int] = None

    @classmethod
    def _from_base_schema(cls, raw: _RankingEntryData, provider: Any) -> RankingsEntry:
        """Alternative constructor to build a domain RankingsEntry from raw provider data."""

        if raw.unique_tournament:
            entity = Competition(raw.unique_tournament, provider)
        elif raw.team:
            entity = Competitor(raw.team, provider)
        else:
            raise ValueError("Ranking entry must have either a team or a unique tournament associated")

        return cls(
            position=raw.position,
            entity=entity,
            points=raw.points,
            previous_position=raw.previous_position,
            previous_points=raw.previous_points,
            best_position=raw.best_position
        )

Referee

Bases: IdentifiableEntity[_RefereeData], EventAwareMixin

Represents a sports referee/officiator (e.g., football referee, Formula 1 race director).

Handles basic information, associated sport and country, games officiated, and cards issued. Supports lazy full-loading for properties requiring detailed API responses.

Attributes:

Name Type Description
id int

Unique identifier of the referee.

name str

Full name of the referee.

slug str

URL-friendly slug of the referee.

sport Sport

Sport associated with the referee.

country Country | None

Country associated with the referee, if available.

games int | None

Number of games officiated by the referee.

cards Cards | None

Counts of yellow, red, and yellow-red cards issued.

Methods:

Name Description
get_fixtures

bool = False) -> EventCollection: Returns fixtures for this referee. Currently returns empty, logs a warning.

get_results

bool = False) -> EventCollection: Returns results for this referee.

from_id

int, provider: SofascoreProvider) -> Referee: Fetch a referee by its unique ID.

search

str, provider: SofascoreProvider) -> EntityCollection[Referee]: Search for referees matching a query string (up to 20 results).

Source code in src/sportindex/domain/referee.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
class Referee(IdentifiableEntity[_RefereeData], EventAwareMixin):
    """Represents a sports referee/officiator (e.g., football referee, Formula 1 race director).

    Handles basic information, associated sport and country, games officiated,
    and cards issued. Supports lazy full-loading for properties requiring
    detailed API responses.

    Attributes:
        id (int): Unique identifier of the referee.
        name (str): Full name of the referee.
        slug (str): URL-friendly slug of the referee.
        sport (Sport): Sport associated with the referee.
        country (Country | None): Country associated with the referee, if available.
        games (int | None): Number of games officiated by the referee.
        cards (Cards | None): Counts of yellow, red, and yellow-red cards issued.

    Methods:
        get_fixtures(silent: bool = False) -> EventCollection:
            Returns fixtures for this referee. Currently returns empty, logs a warning.
        get_results(silent: bool = False) -> EventCollection:
            Returns results for this referee.
        from_id(referee_id: int, provider: SofascoreProvider) -> Referee:
            Fetch a referee by its unique ID.
        search(query: str, provider: SofascoreProvider) -> EntityCollection[Referee]:
            Search for referees matching a query string (up to 20 results).
    """
    _REPR_FIELDS = ("id", "name", "slug", "sport", "country")

    def __init__(self, data: _RefereeData, provider: SofascoreProvider, **kwargs) -> None:
        super().__init__(data, provider, **kwargs)

        if not isinstance(data, _RefereeData):
            raise TypeError(f"Referee data must be of type _RefereeData, got {type(data)}")

        self._full_loaded = False

    @property
    def id(self) -> int:
        """The unique ID of the referee."""
        return self._data.id

    @property
    def name(self) -> str:
        """The full name of the referee."""
        return self._data.name

    @property
    def slug(self) -> str:
        """The slug of the referee (used in URLs)."""
        return self._data.slug or self._data.name.lower().replace(" ", "-")

    @cached_property
    def sport(self) -> Sport:
        """The sport this referee is associated with."""
        from .core import Sport
        return Sport(self._data.sport, self._provider)

    @cached_property
    def country(self) -> Optional[Country]:
        """The country this referee is associated with, if any."""
        from .core import Country
        return Country(self._data.country, self._provider) if self._data.country else None

    @cached_property
    def games(self) -> Optional[int]:
        """Get the number of games this referee has officiated."""
        self._full_load()
        return int(self._data.games)

    @cached_property
    def cards(self) -> Optional[Cards]:
        """Get the number of cards this referee has given."""
        self._full_load()
        return Cards(
            yellow=int(self._data.yellow_cards),
            red=int(self._data.red_cards),
            yellow_red=int(self._data.yellow_red_cards)
        )

    def get_fixtures(self, silent: bool = False) -> EventCollection:
        """Fetch all fixtures for this referee."""
        from .event import EventCollection
        if not silent:
            logger.warning("No fixtures endpoint available for referees, returning empty list")
        return EventCollection([])

    def get_results(self, silent: bool = False) -> EventCollection:
        """Fetch all results for this referee."""
        return self._fetch_paginated_events(self._provider.get_referee_results, self._data.id)

    def _full_load(self) -> None:
        """
        Lazy-loads the complete referee from the provider.
        Called automatically when accessing properties that require full details 
        missing from the initial lightweight API response.
        """
        if self._full_loaded:
            return
        try:
            self._data = merge_pydantic_models(self._data, self._provider.get_referee(self._data.id))
            if not isinstance(self._data, _RefereeData):
                raise TypeError(f"Referee data must be of type _RefereeData after full load, got {type(self._data)}")
            self._full_loaded = True
            self._clear_cache()
        except ProviderNotFoundError:
            logger.debug(f"Referee with id {self._data.id} not found during full load")
            self._full_loaded = True
            self._clear_cache()
        except FetchError as e:
            logger.debug(f"Network error while fully loading referee with id {self._data.id}: {e}")
            self._full_loaded = True
            self._clear_cache()

    def _clear_cache(self) -> None:
        """Clear cached properties."""
        self.__dict__.pop("sport", None)
        self.__dict__.pop("country", None)
        self.__dict__.pop("games", None)
        self.__dict__.pop("cards", None)

    @classmethod
    def from_id(cls, referee_id: int, provider: SofascoreProvider) -> Referee:
        """Fetch a referee by its ID."""
        try:
            parsed_data = provider.get_referee(referee_id)
        except ProviderNotFoundError as e:
            raise EntityNotFoundError(f"Referee with id {referee_id} not found") from e
        except FetchError as e:
            raise DomainError(f"Network error while fetching referee {referee_id}") from e
        return cls(parsed_data, provider)

    @classmethod
    def search(cls, query: str, provider: SofascoreProvider) -> EntityCollection[Referee]:
        """Search for referees matching the given query (up to the first 20 matches)."""
        entities = []
        for page in range(51): # Sofascore has a maximum of 50 pages of search results
            matches = provider.search_referees(query=query, page=page)
            if not matches:
                break
            for item in matches:
                entities.append(Referee(item.entity, provider))
            if len(matches) > 20:
                break
        return EntityCollection(entities[:20])

cards cached property

Get the number of cards this referee has given.

country cached property

The country this referee is associated with, if any.

games cached property

Get the number of games this referee has officiated.

id property

The unique ID of the referee.

name property

The full name of the referee.

slug property

The slug of the referee (used in URLs).

sport cached property

The sport this referee is associated with.

from_id(referee_id, provider) classmethod

Fetch a referee by its ID.

Source code in src/sportindex/domain/referee.py
146
147
148
149
150
151
152
153
154
155
@classmethod
def from_id(cls, referee_id: int, provider: SofascoreProvider) -> Referee:
    """Fetch a referee by its ID."""
    try:
        parsed_data = provider.get_referee(referee_id)
    except ProviderNotFoundError as e:
        raise EntityNotFoundError(f"Referee with id {referee_id} not found") from e
    except FetchError as e:
        raise DomainError(f"Network error while fetching referee {referee_id}") from e
    return cls(parsed_data, provider)

get_fixtures(silent=False)

Fetch all fixtures for this referee.

Source code in src/sportindex/domain/referee.py
105
106
107
108
109
110
def get_fixtures(self, silent: bool = False) -> EventCollection:
    """Fetch all fixtures for this referee."""
    from .event import EventCollection
    if not silent:
        logger.warning("No fixtures endpoint available for referees, returning empty list")
    return EventCollection([])

get_results(silent=False)

Fetch all results for this referee.

Source code in src/sportindex/domain/referee.py
112
113
114
def get_results(self, silent: bool = False) -> EventCollection:
    """Fetch all results for this referee."""
    return self._fetch_paginated_events(self._provider.get_referee_results, self._data.id)

search(query, provider) classmethod

Search for referees matching the given query (up to the first 20 matches).

Source code in src/sportindex/domain/referee.py
157
158
159
160
161
162
163
164
165
166
167
168
169
@classmethod
def search(cls, query: str, provider: SofascoreProvider) -> EntityCollection[Referee]:
    """Search for referees matching the given query (up to the first 20 matches)."""
    entities = []
    for page in range(51): # Sofascore has a maximum of 50 pages of search results
        matches = provider.search_referees(query=query, page=page)
        if not matches:
            break
        for item in matches:
            entities.append(Referee(item.entity, provider))
        if len(matches) > 20:
            break
    return EntityCollection(entities[:20])

Season

Bases: IdentifiableEntity[_SeasonData | _StageData], EventAwareMixin

A season of a competition, e.g., '2023/24', '2024'.

Provides access to parent competition, sport, standings, fixtures, and results.

Attributes:

Name Type Description
id int

Unique ID, encoded from source ID and type.

name str

Season name.

year str

Season year.

start Optional[datetime]

Start date of the season.

sport Sport

Parent sport.

competition Competition

Parent competition (lazy-loaded).

standings EntityCollection[Standings]

Standings for this season.

Raises:

Type Description
InsufficientDataError

If season data lacks required competition info.

Source code in src/sportindex/domain/competition.py
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
class Season(IdentifiableEntity[_SeasonData | _StageData], EventAwareMixin):
    """A season of a competition, e.g., '2023/24', '2024'.

    Provides access to parent competition, sport, standings, fixtures, and results.

    Attributes:
        id (int): Unique ID, encoded from source ID and type.
        name (str): Season name.
        year (str): Season year.
        start (Optional[datetime]): Start date of the season.
        sport (Sport): Parent sport.
        competition (Competition): Parent competition (lazy-loaded).
        standings (EntityCollection[Standings]): Standings for this season.

    Raises:
        InsufficientDataError: If season data lacks required competition info.
    """
    _REPR_FIELDS = ("id", "name", "year", "start", "sport")
    _TYPE_MAP = {_SeasonData: 1, _StageData: 2}

    def __init__(self, data: _SeasonData | _StageData, provider: SofascoreProvider, **kwargs) -> None:
        super().__init__(data, provider, **kwargs)

        if not isinstance(data, (_SeasonData, _StageData)):
            raise TypeError(f"Season data must be either _SeasonData or _StageData, got {type(data)}")

        self._full_loaded = False

    @property
    def id(self) -> int:
        """The unique ID of the season."""
        type_idx = self._TYPE_MAP[type(self._data)]
        return self.encode_id(self._data.id, type_idx)

    @property
    def name(self) -> str:
        """The name of the season."""
        return self._data.name

    @property
    def year(self) -> str:
        """The year of the season."""
        return self._data.year

    @property
    def start(self) -> Optional[datetime]:
        """The start date of the season, if available."""
        return self._data.start

    @property
    def sport(self) -> Sport:
        """The sport this season belongs to."""
        return self.competition.sport

    @cached_property
    def competition(self) -> Competition:
        """The competition this season belongs to."""
        if "competition" in self._kwargs and isinstance(self._kwargs["competition"], Competition):
            return self._kwargs["competition"]
        else:
            if isinstance(self._data, _SeasonData):
                # _SeasonData doesn't contain any competition info, so it must be passed in via kwargs (either with competition key or uniqueTournament key)
                if "uniqueTournament" not in self._kwargs:
                    raise InsufficientDataError("Season data requires 'competition' or 'uniqueTournament' to be passed in via kwargs")
                return Competition(self._kwargs["uniqueTournament"], self._provider)
            elif isinstance(self._data, _StageData):
                return Competition(self._data.unique_stage, self._provider)
            else:
                raise TypeError(f"Season data must be either _SeasonData or _StageData, got {type(self._data)}")

    @property
    def current_round(self) -> Optional[Round]:
        """The current round of the season, if available."""
        return self._season_rounds.current_round if self._season_rounds else None

    @property
    def rounds(self) -> Optional[list[Round]]:
        """The list of rounds in the season, if available."""
        return self._season_rounds.rounds if self._season_rounds else None

    @cached_property
    def _season_rounds(self) -> Optional[_SeasonRoundsResponse]:
        if isinstance(self._data, _SeasonData):
            return self._provider.get_unique_tournament_rounds(self.competition._data.id, self._data.id)
        elif isinstance(self._data, _StageData):
            logger.debug(f"No rounds for stages, skipping fetch...")
            return None

    @property
    def standings(self) -> EntityCollection[Standings]:
        """Fetch all standings for this season (only available for current seasons)."""
        from .leaderboard import Standings
        if isinstance(self._data, _SeasonData):
            standings = self._provider.get_unique_tournament_standings(self.competition._data.id, self._data.id, view="total")
            try:
                standings.extend(self._provider.get_unique_tournament_standings(self.competition._data.id, self._data.id, view="home"))
            except ProviderNotFoundError as e:
                logger.debug(f"Failed to fetch home standings for season {self.id}: {e}")
            try:
                standings.extend(self._provider.get_unique_tournament_standings(self.competition._data.id, self._data.id, view="away"))
            except ProviderNotFoundError as e:
                logger.debug(f"Failed to fetch away standings for season {self.id}: {e}")
            return EntityCollection([Standings(s, self._provider) for s in standings])
        elif isinstance(self._data, _StageData):
            try:
                competitors_standings = self._provider.get_stage_standings_competitors(self._data.id)
            except ProviderNotFoundError as e:
                logger.debug(f"Failed to fetch competitors standings for stage {self.id}: {e}")
                competitors_standings = []
            try:
                teams_standings = self._provider.get_stage_standings_teams(self._data.id)
            except ProviderNotFoundError as e:
                logger.debug(f"Failed to fetch teams standings for stage {self.id}: {e}")
                teams_standings = []
            return EntityCollection([
                Standings(competitors_standings, self._provider, name=f"Individuals {self.name}", kind="individuals"),
                Standings(teams_standings, self._provider, name=f"Teams {self.name}", kind="teams")
            ])

    def get_fixtures(self, silent: bool = False) -> EventCollection:
        """Fetch all fixtures for this season."""
        if isinstance(self._data, _SeasonData):
            return self._fetch_paginated_events(
                self._provider.get_unique_tournament_fixtures, 
                self.competition._data.id, 
                self._data.id
            )
        elif isinstance(self._data, _StageData):
            from .event import Event, EventCollection
            substages = self._provider.get_stage_substages(self._data.id)
            future_substages = [s for s in substages if s.start >= datetime.now(tz=timezone.utc)]
            return EventCollection([Event(s, self._provider) for s in future_substages])

    def get_results(self, silent: bool = False) -> EventCollection:
        """Fetch all results for this season."""
        if isinstance(self._data, _SeasonData):
            return self._fetch_paginated_events(
                self._provider.get_unique_tournament_results, 
                self.competition._data.id, 
                self._data.id
            )
        elif isinstance(self._data, _StageData):
            from .event import Event, EventCollection
            substages = self._provider.get_stage_substages(self._data.id)
            past_substages = [s for s in substages if s.end < datetime.now(tz=timezone.utc)]
            return EventCollection([Event(s, self._provider) for s in past_substages])

    def get_round_events(self, round: Round) -> EventCollection:
        """Fetch events for a specific round."""
        if isinstance(self._data, _SeasonData):
            from .event import Event, EventCollection
            events_response = self._provider.get_unique_tournament_events_round(
                self.competition._data.id, 
                self._data.id, 
                round.value,
                round_slug=round.slug,
                round_prefix=round.prefix
            )
            return EventCollection([Event(e, self._provider) for e in events_response.events])
        elif isinstance(self._data, _StageData):
            logger.debug(f"No rounds for stages, skipping fetch...")
            return EventCollection([])

    def _full_load(self) -> None:
        """
        Lazy-loads the complete season from the provider.
        Called automatically when accessing properties that require full details
        missing from the initial lightweight API response.
        """
        if self._full_loaded:
            return
        try:
            if isinstance(self._data, _SeasonData):
                logger.info("No endpoint available to fully load unique tournament season yet, skipping full load...")
            elif isinstance(self._data, _StageData):
                self._data = merge_pydantic_models(self._data, self._provider.get_stage(self._data.id))
                if self._data.type_ is not None and self._data.type_.get("name") != "Season":
                    logger.warning(
                        f"_StageData with id {self._data.id} has type '{self._data.type_.get('name')}' "
                        "instead of 'Season', but is being used to create a Season entity. "
                        "This could lead to incorrect data being assigned to the Season entity. "
                        "Please check the data and consider using a different entity type if appropriate."
                    )
            if not isinstance(self._data, (_SeasonData, _StageData)):
                raise TypeError(f"Season data must be either _SeasonData or _StageData after full load, got {type(self._data)}")
            self._full_loaded = True
            self._clear_cache()
        except ProviderNotFoundError:
            logger.debug(f"Season with id {self._data.id} not found during full load")
            self._full_loaded = True
            self._clear_cache()
        except FetchError as e:
            logger.debug(f"Network error while fully loading season with id {self._data.id}: {e}")
            self._full_loaded = True
            self._clear_cache()

    def _clear_cache(self) -> None:
        """Clear cached properties."""
        self.__dict__.pop("competition", None)
        self.__dict__.pop("_season_rounds", None)

competition cached property

The competition this season belongs to.

current_round property

The current round of the season, if available.

id property

The unique ID of the season.

name property

The name of the season.

rounds property

The list of rounds in the season, if available.

sport property

The sport this season belongs to.

standings property

Fetch all standings for this season (only available for current seasons).

start property

The start date of the season, if available.

year property

The year of the season.

get_fixtures(silent=False)

Fetch all fixtures for this season.

Source code in src/sportindex/domain/competition.py
289
290
291
292
293
294
295
296
297
298
299
300
301
def get_fixtures(self, silent: bool = False) -> EventCollection:
    """Fetch all fixtures for this season."""
    if isinstance(self._data, _SeasonData):
        return self._fetch_paginated_events(
            self._provider.get_unique_tournament_fixtures, 
            self.competition._data.id, 
            self._data.id
        )
    elif isinstance(self._data, _StageData):
        from .event import Event, EventCollection
        substages = self._provider.get_stage_substages(self._data.id)
        future_substages = [s for s in substages if s.start >= datetime.now(tz=timezone.utc)]
        return EventCollection([Event(s, self._provider) for s in future_substages])

get_results(silent=False)

Fetch all results for this season.

Source code in src/sportindex/domain/competition.py
303
304
305
306
307
308
309
310
311
312
313
314
315
def get_results(self, silent: bool = False) -> EventCollection:
    """Fetch all results for this season."""
    if isinstance(self._data, _SeasonData):
        return self._fetch_paginated_events(
            self._provider.get_unique_tournament_results, 
            self.competition._data.id, 
            self._data.id
        )
    elif isinstance(self._data, _StageData):
        from .event import Event, EventCollection
        substages = self._provider.get_stage_substages(self._data.id)
        past_substages = [s for s in substages if s.end < datetime.now(tz=timezone.utc)]
        return EventCollection([Event(s, self._provider) for s in past_substages])

get_round_events(round)

Fetch events for a specific round.

Source code in src/sportindex/domain/competition.py
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
def get_round_events(self, round: Round) -> EventCollection:
    """Fetch events for a specific round."""
    if isinstance(self._data, _SeasonData):
        from .event import Event, EventCollection
        events_response = self._provider.get_unique_tournament_events_round(
            self.competition._data.id, 
            self._data.id, 
            round.value,
            round_slug=round.slug,
            round_prefix=round.prefix
        )
        return EventCollection([Event(e, self._provider) for e in events_response.events])
    elif isinstance(self._data, _StageData):
        logger.debug(f"No rounds for stages, skipping fetch...")
        return EventCollection([])

Sport

Bases: IdentifiableEntity[_SportData]

A sport (e.g., football, tennis, motorsport).

Provides access to its categories and official rankings, and can be instantiated from minimal raw data without fetching full details.

Attributes:

Name Type Description
id int

Unique sport ID.

name str

Official sport name.

slug str

URL-friendly identifier.

categories EntityCollection[Category]

All categories associated with this sport.

Methods:

Name Description
get_rankings

Optional[str] = None) -> list[Rankings]: Fetch official rankings for the sport.

Source code in src/sportindex/domain/core.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
class Sport(IdentifiableEntity[_SportData]):
    """A sport (e.g., football, tennis, motorsport).

    Provides access to its categories and official rankings, and can be instantiated from minimal raw data without fetching full details.

    Attributes:
        id (int): Unique sport ID.
        name (str): Official sport name.
        slug (str): URL-friendly identifier.
        categories (EntityCollection[Category]): All categories associated with this sport.

    Methods:
        get_rankings(gender: Optional[str] = None) -> list[Rankings]: Fetch official rankings for the sport.
    """
    _REPR_FIELDS = ("id", "name", "slug")

    def __init__(self, data: _SportData, provider: SofascoreProvider, **kwargs) -> None:
        super().__init__(data, provider, **kwargs)

        if not isinstance(data, _SportData):
            raise TypeError(f"Sport data must be of type _SportData, got {type(data)}")

    @property
    def id(self) -> int:
        """The unique ID of the sport."""
        return self._data.id

    @property
    def name(self) -> str:
        """The name of the sport."""
        return self._data.name

    @property
    def slug(self) -> str:
        """The slug of the sport (used in URLs)."""
        return self._data.slug or self._data.name.lower().replace(" ", "-")

    @cached_property
    def categories(self) -> EntityCollection[Category]:
        """Fetch all categories for this sport."""
        return EntityCollection([
            Category(c, self._provider)
            for c in self._provider.get_categories(self.slug)
        ])

    def get_rankings(self, gender: Optional[str] = None) -> list[Rankings]:
        """Fetch all rankings for this sport."""
        from .leaderboard import Rankings
        rankings = []
        for ranking_id, ranking_gender in SPORT_RANKINGS.get(self.slug, []):
            if gender is None or ranking_gender == gender:
                rankings.append(Rankings(
                    self._provider.get_ranking(ranking_id),
                    provider=self._provider,
                ))
        return rankings

    @classmethod
    def _from_tuple(cls, data: tuple[int, str, str], provider: SofascoreProvider) -> Sport:
        """Create a Sport instance from a raw tuple (id, slug, name). This is used to build the initial list of sports without needing to fetch categories or rankings."""
        sid, slug, name = data
        return cls(_SportData(id=sid, slug=slug, name=name), provider)

categories cached property

Fetch all categories for this sport.

id property

The unique ID of the sport.

name property

The name of the sport.

slug property

The slug of the sport (used in URLs).

get_rankings(gender=None)

Fetch all rankings for this sport.

Source code in src/sportindex/domain/core.py
68
69
70
71
72
73
74
75
76
77
78
def get_rankings(self, gender: Optional[str] = None) -> list[Rankings]:
    """Fetch all rankings for this sport."""
    from .leaderboard import Rankings
    rankings = []
    for ranking_id, ranking_gender in SPORT_RANKINGS.get(self.slug, []):
        if gender is None or ranking_gender == gender:
            rankings.append(Rankings(
                self._provider.get_ranking(ranking_id),
                provider=self._provider,
            ))
    return rankings

SportClient

Main client for accessing sports data.

Provides methods to fetch sports, competitions, events, competitors, managers, referees, venues, etc. in a unified, object-oriented way. Caches entities in memory to minimize redundant API calls and improve performance. Cache can be cleared manually if needed.

Usage

client = SportClient() events = client.list_events(competition_id=123) sport = client.get_sport(id=1)

Raises:

Type Description
EntityNotFoundError

If a requested entity does not exist.

ProviderNotFoundError

If the data provider is unavailable.

Source code in src/sportindex/client.py
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
class SportClient:
    """Main client for accessing sports data.

    Provides methods to fetch sports, competitions, events, competitors, managers, referees, venues, etc. in a unified, object-oriented way.
    Caches entities in memory to minimize redundant API calls and improve performance. Cache can be cleared manually if needed.

    Usage:
        >>> client = SportClient()
        >>> events = client.list_events(competition_id=123)
        >>> sport = client.get_sport(id=1)

    Raises:
        EntityNotFoundError: If a requested entity does not exist.
        ProviderNotFoundError: If the data provider is unavailable.
    """

    def __init__(self):
        """Initialize the SportClient.

        Initializes in-memory caches for all entity types.
        """
        self._provider = _get_default_provider()

        self._cache: dict[str, dict[int, Any]] = {
            "sports": {}, 
            "competitions": {},
            "seasons": {},
            "events": {},
            "competitors": {},
            "managers": {},
            "referees": {},
            "venues": {},
        }

    # --- Cache Helpers ---

    def _get_cached(self, namespace: str, entity_id: int, expected_type: type[T]) -> Optional[T]:
        """Return an entity from the cache if it exists.

        Args:
            namespace (str): The cache namespace (e.g., 'events', 'competitions').
            entity_id (int): The unique identifier of the entity.
            expected_type (type[T]): Expected type of the entity for IDE type hinting.

        Returns:
            Optional[T]: The cached entity, or None if not found.
        """
        return self._cache[namespace].get(entity_id)

    def _set_cached(self, namespace: str, entity: T) -> T:
        """Add an entity to the cache and return it.

        Args:
            namespace (str): The cache namespace.
            entity (T): The entity to store.

        Returns:
            T: The same entity for chaining.
        """
        self._cache[namespace][entity.id] = entity
        return entity

    def _hydrate_cache(self, namespace: str, collection: C) -> C:
        """Cache all entities in an iterable collection.

        Args:
            namespace (str): The cache namespace.
            collection (C): Iterable of entities.

        Returns:
            C: The same collection for chaining.
        """
        for entity in collection:
            self._cache[namespace][entity.id] = entity
        return collection

    def clear_cache(self, namespace: Optional[str] = None) -> None:
        """Clear cached entities to free memory.

        Args:
            namespace (Optional[str]): Specific namespace to clear. If None, clears all caches.

        Raises:
            KeyError: If the provided namespace does not exist.
        """
        if namespace:
            if namespace in self._cache:
                self._cache[namespace].clear()
            else:
                raise KeyError(f"Unknown cache namespace: {namespace}")
        else:
            for ns in self._cache:
                self._cache[ns].clear()

    # --- Sports ---

    def get_sport(self, id: int) -> Sport | None:
        """Fetch a sport by its ID.

        Args:
            id (int): The sport ID.

        Returns:
            Optional[Sport]: The sport object, or None if not found.
        """
        sports = self.list_sports()
        return sports.get(id=id)

    def list_sports(self) -> EntityCollection[Sport]:
        """Return all known sports.

        Returns:
            EntityCollection[Sport]: All sports available.
        """
        sports = get_sports()
        return self._hydrate_cache("sports", sports)

    def search_sports(self, query: str) -> EntityCollection[Sport]:
        """Search for sports matching a query.

        Args:
            query (str): Partial or full sport name.

        Returns:
            EntityCollection[Sport]: Sports that match the query.
        """
        sports = self.list_sports()
        return sports.search(query)

    # --- Categories ---

    def list_categories(self, sport_id: int) -> EntityCollection[Category]:
        """Fetch categories for a given sport.

        Args:
            sport_id (int): The ID of the sport.

        Returns:
            EntityCollection[Category]: Categories under the sport.

        Raises:
            EntityNotFoundError: If the sport does not exist.
        """
        sport = self.get_sport(sport_id)
        if sport is None:
            raise EntityNotFoundError(f"Sport with ID {sport_id} not found")
        return sport.categories

    # --- Competitions ---

    def get_competition(self, id: int) -> Optional[Competition]:
        """Fetch a competition by ID.

        Args:
            id (int): Competition ID.

        Returns:
            Optional[Competition]: The competition if found, else None.

        Raises:
            ProviderNotFoundError: If the data provider is unavailable.
        """
        if cached := self._get_cached("competitions", id, Competition):
            return cached
        try:
            entity = Competition.from_id(id, self._provider)
            return self._set_cached("competitions", entity)
        except ProviderNotFoundError:
            return None

    def list_competitions(self, sport_id: int, category_id: int) -> EntityCollection[Competition]:
        """Fetch competitions for a sport and category.

        Args:
            sport_id (int): Sport ID.
            category_id (int): Category ID.

        Returns:
            EntityCollection[Competition]: Competitions under the category.

        Raises:
            EntityNotFoundError: If sport or category does not exist.
        """
        sport = self.get_sport(sport_id)
        if sport is None:
            raise EntityNotFoundError(f"Sport with ID {sport_id} not found")
        category = sport.categories.get(id=category_id)
        if category is None:
            raise EntityNotFoundError(f"Category with ID {category_id} not found")
        return self._hydrate_cache("competitions", category.competitions)

    # --- Seasons ---

    def list_seasons(self, competition_id: int) -> EntityCollection[Season]:
        """Fetch seasons for a competition.

        Args:
            competition_id (int): Competition ID.

        Returns:
            EntityCollection[Season]: Seasons under the competition.

        Raises:
            EntityNotFoundError: If the competition does not exist.
        """
        competition = self.get_competition(competition_id)
        if competition is None:
            raise EntityNotFoundError(f"Competition with ID {competition_id} not found")
        return self._hydrate_cache("seasons", competition.seasons)

    # --- Events ---

    def get_event(self, id: int) -> Optional[Event]:
        """Fetch an event by ID.

        Args:
            id (int): Event ID.

        Returns:
            Optional[Event]: Event object if found, else None.

        Raises:
            ProviderNotFoundError: If the provider is unavailable.
        """
        if cached := self._get_cached("events", id, Event):
            return cached
        try:
            entity = Event.from_id(id, self._provider)
            return self._set_cached("events", entity)
        except ProviderNotFoundError:
            return None

    # --- Competitors ---

    def get_competitor(self, id: int) -> Optional[Competitor]:
        """Fetch a competitor by ID.

        Args:
            id (int): Competitor ID.

        Returns:
            Optional[Competitor]: Competitor if found, else None.

        Raises:
            ProviderNotFoundError: If the provider is unavailable.
        """
        if cached := self._get_cached("competitors", id, Competitor):
            return cached
        try:
            entity = Competitor.from_id(id, self._provider)
            return self._set_cached("competitors", entity)
        except ProviderNotFoundError:
            return None

    def search_competitors(self, query: str) -> EntityCollection[Competitor]:
        """Search for competitors by name.

        Args:
            query (str): Partial or full competitor name.

        Returns:
            EntityCollection[Competitor]: Matching competitors.
        """
        results = Competitor.search(query, self._provider)
        return self._hydrate_cache("competitors", results)

    # --- Managers ---

    def get_manager(self, id: int) -> Optional[Manager]:
        """Fetch a manager by ID.

        Args:
            id (int): Manager ID.

        Returns:
            Optional[Manager]: Manager if found, else None.

        Raises:
            ProviderNotFoundError: If the provider is unavailable.
        """
        if cached := self._get_cached("managers", id, Manager):
            return cached
        try:
            entity = Manager.from_id(id, self._provider)
            return self._set_cached("managers", entity)
        except ProviderNotFoundError:
            return None

    def search_managers(self, query: str) -> EntityCollection[Manager]:
        """Search for managers by name.

        Args:
            query (str): Partial or full manager name.

        Returns:
            EntityCollection[Manager]: Matching managers.
        """
        results = Manager.search(query, self._provider)
        return self._hydrate_cache("managers", results)

    # --- Referees ---

    def get_referee(self, id: int) -> Optional[Referee]:
        """Fetch a referee by ID.

        Args:
            id (int): Referee ID.

        Returns:
            Optional[Referee]: Referee if found, else None.

        Raises:
            ProviderNotFoundError: If the provider is unavailable.
        """
        if cached := self._get_cached("referees", id, Referee):
            return cached
        try:
            entity = Referee.from_id(id, self._provider)
            return self._set_cached("referees", entity)
        except ProviderNotFoundError:
            return None

    def search_referees(self, query: str) -> EntityCollection[Referee]:
        """Search referees by name.

        Args:
            query (str): Partial or full referee name.

        Returns:
            EntityCollection[Referee]: Matching referees.
        """
        results = Referee.search(query, self._provider)
        return self._hydrate_cache("referees", results)

    # --- Venues ---

    def get_venue(self, id: int) -> Optional[Venue]:
        """Fetch a venue by ID.

        Args:
            id (int): Venue ID.

        Returns:
            Optional[Venue]: Venue if found, else None.

        Raises:
            ProviderNotFoundError: If the provider is unavailable.
        """
        if cached := self._get_cached("venues", id, Venue):
            return cached
        try:
            entity = Venue.from_id(id, self._provider)
            return self._set_cached("venues", entity)
        except ProviderNotFoundError:
            return None

    def search_venues(self, query: str) -> EntityCollection[Venue]:
        """Search venues by name.

        Args:
            query (str): Partial or full venue name.

        Returns:
            EntityCollection[Venue]: Matching venues.
        """
        results = Venue.search(query, self._provider)
        return self._hydrate_cache("venues", results)

__init__()

Initialize the SportClient.

Initializes in-memory caches for all entity types.

Source code in src/sportindex/client.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def __init__(self):
    """Initialize the SportClient.

    Initializes in-memory caches for all entity types.
    """
    self._provider = _get_default_provider()

    self._cache: dict[str, dict[int, Any]] = {
        "sports": {}, 
        "competitions": {},
        "seasons": {},
        "events": {},
        "competitors": {},
        "managers": {},
        "referees": {},
        "venues": {},
    }

clear_cache(namespace=None)

Clear cached entities to free memory.

Parameters:

Name Type Description Default
namespace Optional[str]

Specific namespace to clear. If None, clears all caches.

None

Raises:

Type Description
KeyError

If the provided namespace does not exist.

Source code in src/sportindex/client.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
def clear_cache(self, namespace: Optional[str] = None) -> None:
    """Clear cached entities to free memory.

    Args:
        namespace (Optional[str]): Specific namespace to clear. If None, clears all caches.

    Raises:
        KeyError: If the provided namespace does not exist.
    """
    if namespace:
        if namespace in self._cache:
            self._cache[namespace].clear()
        else:
            raise KeyError(f"Unknown cache namespace: {namespace}")
    else:
        for ns in self._cache:
            self._cache[ns].clear()

get_competition(id)

Fetch a competition by ID.

Parameters:

Name Type Description Default
id int

Competition ID.

required

Returns:

Type Description
Optional[Competition]

Optional[Competition]: The competition if found, else None.

Raises:

Type Description
ProviderNotFoundError

If the data provider is unavailable.

Source code in src/sportindex/client.py
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
def get_competition(self, id: int) -> Optional[Competition]:
    """Fetch a competition by ID.

    Args:
        id (int): Competition ID.

    Returns:
        Optional[Competition]: The competition if found, else None.

    Raises:
        ProviderNotFoundError: If the data provider is unavailable.
    """
    if cached := self._get_cached("competitions", id, Competition):
        return cached
    try:
        entity = Competition.from_id(id, self._provider)
        return self._set_cached("competitions", entity)
    except ProviderNotFoundError:
        return None

get_competitor(id)

Fetch a competitor by ID.

Parameters:

Name Type Description Default
id int

Competitor ID.

required

Returns:

Type Description
Optional[Competitor]

Optional[Competitor]: Competitor if found, else None.

Raises:

Type Description
ProviderNotFoundError

If the provider is unavailable.

Source code in src/sportindex/client.py
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
def get_competitor(self, id: int) -> Optional[Competitor]:
    """Fetch a competitor by ID.

    Args:
        id (int): Competitor ID.

    Returns:
        Optional[Competitor]: Competitor if found, else None.

    Raises:
        ProviderNotFoundError: If the provider is unavailable.
    """
    if cached := self._get_cached("competitors", id, Competitor):
        return cached
    try:
        entity = Competitor.from_id(id, self._provider)
        return self._set_cached("competitors", entity)
    except ProviderNotFoundError:
        return None

get_event(id)

Fetch an event by ID.

Parameters:

Name Type Description Default
id int

Event ID.

required

Returns:

Type Description
Optional[Event]

Optional[Event]: Event object if found, else None.

Raises:

Type Description
ProviderNotFoundError

If the provider is unavailable.

Source code in src/sportindex/client.py
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
def get_event(self, id: int) -> Optional[Event]:
    """Fetch an event by ID.

    Args:
        id (int): Event ID.

    Returns:
        Optional[Event]: Event object if found, else None.

    Raises:
        ProviderNotFoundError: If the provider is unavailable.
    """
    if cached := self._get_cached("events", id, Event):
        return cached
    try:
        entity = Event.from_id(id, self._provider)
        return self._set_cached("events", entity)
    except ProviderNotFoundError:
        return None

get_manager(id)

Fetch a manager by ID.

Parameters:

Name Type Description Default
id int

Manager ID.

required

Returns:

Type Description
Optional[Manager]

Optional[Manager]: Manager if found, else None.

Raises:

Type Description
ProviderNotFoundError

If the provider is unavailable.

Source code in src/sportindex/client.py
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
def get_manager(self, id: int) -> Optional[Manager]:
    """Fetch a manager by ID.

    Args:
        id (int): Manager ID.

    Returns:
        Optional[Manager]: Manager if found, else None.

    Raises:
        ProviderNotFoundError: If the provider is unavailable.
    """
    if cached := self._get_cached("managers", id, Manager):
        return cached
    try:
        entity = Manager.from_id(id, self._provider)
        return self._set_cached("managers", entity)
    except ProviderNotFoundError:
        return None

get_referee(id)

Fetch a referee by ID.

Parameters:

Name Type Description Default
id int

Referee ID.

required

Returns:

Type Description
Optional[Referee]

Optional[Referee]: Referee if found, else None.

Raises:

Type Description
ProviderNotFoundError

If the provider is unavailable.

Source code in src/sportindex/client.py
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
def get_referee(self, id: int) -> Optional[Referee]:
    """Fetch a referee by ID.

    Args:
        id (int): Referee ID.

    Returns:
        Optional[Referee]: Referee if found, else None.

    Raises:
        ProviderNotFoundError: If the provider is unavailable.
    """
    if cached := self._get_cached("referees", id, Referee):
        return cached
    try:
        entity = Referee.from_id(id, self._provider)
        return self._set_cached("referees", entity)
    except ProviderNotFoundError:
        return None

get_sport(id)

Fetch a sport by its ID.

Parameters:

Name Type Description Default
id int

The sport ID.

required

Returns:

Type Description
Sport | None

Optional[Sport]: The sport object, or None if not found.

Source code in src/sportindex/client.py
144
145
146
147
148
149
150
151
152
153
154
def get_sport(self, id: int) -> Sport | None:
    """Fetch a sport by its ID.

    Args:
        id (int): The sport ID.

    Returns:
        Optional[Sport]: The sport object, or None if not found.
    """
    sports = self.list_sports()
    return sports.get(id=id)

get_venue(id)

Fetch a venue by ID.

Parameters:

Name Type Description Default
id int

Venue ID.

required

Returns:

Type Description
Optional[Venue]

Optional[Venue]: Venue if found, else None.

Raises:

Type Description
ProviderNotFoundError

If the provider is unavailable.

Source code in src/sportindex/client.py
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
def get_venue(self, id: int) -> Optional[Venue]:
    """Fetch a venue by ID.

    Args:
        id (int): Venue ID.

    Returns:
        Optional[Venue]: Venue if found, else None.

    Raises:
        ProviderNotFoundError: If the provider is unavailable.
    """
    if cached := self._get_cached("venues", id, Venue):
        return cached
    try:
        entity = Venue.from_id(id, self._provider)
        return self._set_cached("venues", entity)
    except ProviderNotFoundError:
        return None

list_categories(sport_id)

Fetch categories for a given sport.

Parameters:

Name Type Description Default
sport_id int

The ID of the sport.

required

Returns:

Type Description
EntityCollection[Category]

EntityCollection[Category]: Categories under the sport.

Raises:

Type Description
EntityNotFoundError

If the sport does not exist.

Source code in src/sportindex/client.py
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
def list_categories(self, sport_id: int) -> EntityCollection[Category]:
    """Fetch categories for a given sport.

    Args:
        sport_id (int): The ID of the sport.

    Returns:
        EntityCollection[Category]: Categories under the sport.

    Raises:
        EntityNotFoundError: If the sport does not exist.
    """
    sport = self.get_sport(sport_id)
    if sport is None:
        raise EntityNotFoundError(f"Sport with ID {sport_id} not found")
    return sport.categories

list_competitions(sport_id, category_id)

Fetch competitions for a sport and category.

Parameters:

Name Type Description Default
sport_id int

Sport ID.

required
category_id int

Category ID.

required

Returns:

Type Description
EntityCollection[Competition]

EntityCollection[Competition]: Competitions under the category.

Raises:

Type Description
EntityNotFoundError

If sport or category does not exist.

Source code in src/sportindex/client.py
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
def list_competitions(self, sport_id: int, category_id: int) -> EntityCollection[Competition]:
    """Fetch competitions for a sport and category.

    Args:
        sport_id (int): Sport ID.
        category_id (int): Category ID.

    Returns:
        EntityCollection[Competition]: Competitions under the category.

    Raises:
        EntityNotFoundError: If sport or category does not exist.
    """
    sport = self.get_sport(sport_id)
    if sport is None:
        raise EntityNotFoundError(f"Sport with ID {sport_id} not found")
    category = sport.categories.get(id=category_id)
    if category is None:
        raise EntityNotFoundError(f"Category with ID {category_id} not found")
    return self._hydrate_cache("competitions", category.competitions)

list_seasons(competition_id)

Fetch seasons for a competition.

Parameters:

Name Type Description Default
competition_id int

Competition ID.

required

Returns:

Type Description
EntityCollection[Season]

EntityCollection[Season]: Seasons under the competition.

Raises:

Type Description
EntityNotFoundError

If the competition does not exist.

Source code in src/sportindex/client.py
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
def list_seasons(self, competition_id: int) -> EntityCollection[Season]:
    """Fetch seasons for a competition.

    Args:
        competition_id (int): Competition ID.

    Returns:
        EntityCollection[Season]: Seasons under the competition.

    Raises:
        EntityNotFoundError: If the competition does not exist.
    """
    competition = self.get_competition(competition_id)
    if competition is None:
        raise EntityNotFoundError(f"Competition with ID {competition_id} not found")
    return self._hydrate_cache("seasons", competition.seasons)

list_sports()

Return all known sports.

Returns:

Type Description
EntityCollection[Sport]

EntityCollection[Sport]: All sports available.

Source code in src/sportindex/client.py
156
157
158
159
160
161
162
163
def list_sports(self) -> EntityCollection[Sport]:
    """Return all known sports.

    Returns:
        EntityCollection[Sport]: All sports available.
    """
    sports = get_sports()
    return self._hydrate_cache("sports", sports)

search_competitors(query)

Search for competitors by name.

Parameters:

Name Type Description Default
query str

Partial or full competitor name.

required

Returns:

Type Description
EntityCollection[Competitor]

EntityCollection[Competitor]: Matching competitors.

Source code in src/sportindex/client.py
302
303
304
305
306
307
308
309
310
311
312
def search_competitors(self, query: str) -> EntityCollection[Competitor]:
    """Search for competitors by name.

    Args:
        query (str): Partial or full competitor name.

    Returns:
        EntityCollection[Competitor]: Matching competitors.
    """
    results = Competitor.search(query, self._provider)
    return self._hydrate_cache("competitors", results)

search_managers(query)

Search for managers by name.

Parameters:

Name Type Description Default
query str

Partial or full manager name.

required

Returns:

Type Description
EntityCollection[Manager]

EntityCollection[Manager]: Matching managers.

Source code in src/sportindex/client.py
336
337
338
339
340
341
342
343
344
345
346
def search_managers(self, query: str) -> EntityCollection[Manager]:
    """Search for managers by name.

    Args:
        query (str): Partial or full manager name.

    Returns:
        EntityCollection[Manager]: Matching managers.
    """
    results = Manager.search(query, self._provider)
    return self._hydrate_cache("managers", results)

search_referees(query)

Search referees by name.

Parameters:

Name Type Description Default
query str

Partial or full referee name.

required

Returns:

Type Description
EntityCollection[Referee]

EntityCollection[Referee]: Matching referees.

Source code in src/sportindex/client.py
370
371
372
373
374
375
376
377
378
379
380
def search_referees(self, query: str) -> EntityCollection[Referee]:
    """Search referees by name.

    Args:
        query (str): Partial or full referee name.

    Returns:
        EntityCollection[Referee]: Matching referees.
    """
    results = Referee.search(query, self._provider)
    return self._hydrate_cache("referees", results)

search_sports(query)

Search for sports matching a query.

Parameters:

Name Type Description Default
query str

Partial or full sport name.

required

Returns:

Type Description
EntityCollection[Sport]

EntityCollection[Sport]: Sports that match the query.

Source code in src/sportindex/client.py
165
166
167
168
169
170
171
172
173
174
175
def search_sports(self, query: str) -> EntityCollection[Sport]:
    """Search for sports matching a query.

    Args:
        query (str): Partial or full sport name.

    Returns:
        EntityCollection[Sport]: Sports that match the query.
    """
    sports = self.list_sports()
    return sports.search(query)

search_venues(query)

Search venues by name.

Parameters:

Name Type Description Default
query str

Partial or full venue name.

required

Returns:

Type Description
EntityCollection[Venue]

EntityCollection[Venue]: Matching venues.

Source code in src/sportindex/client.py
404
405
406
407
408
409
410
411
412
413
414
def search_venues(self, query: str) -> EntityCollection[Venue]:
    """Search venues by name.

    Args:
        query (str): Partial or full venue name.

    Returns:
        EntityCollection[Venue]: Matching venues.
    """
    results = Venue.search(query, self._provider)
    return self._hydrate_cache("venues", results)

Standings

Bases: BaseEntity[_TeamStandingsData | list[_RacingStandingsEntryData]]

Represents the standings (ranked table) of a competition or sport.

Can handle both team/match standings (e.g., football league tables) and racing/cycling standings (e.g., Formula 1 driver standings).

Attributes:

Name Type Description
name str | None

Name of the standings, e.g., "Ligue 1 table".

kind str | None

Type of standings, e.g., "home", "away", "competitors", "teams".

updated_at datetime | None

Last update timestamp.

sport Sport | None

Sport associated with these standings.

entries list[StandingsEntry]

Ordered list of entries in the standings.

Source code in src/sportindex/domain/leaderboard.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
class Standings(BaseEntity[_TeamStandingsData | list[_RacingStandingsEntryData]]):
    """Represents the standings (ranked table) of a competition or sport.

    Can handle both team/match standings (e.g., football league tables) and racing/cycling standings 
    (e.g., Formula 1 driver standings).

    Attributes:
        name (str | None): Name of the standings, e.g., "Ligue 1 table".
        kind (str | None): Type of standings, e.g., "home", "away", "competitors", "teams".
        updated_at (datetime | None): Last update timestamp.
        sport (Sport | None): Sport associated with these standings.
        entries (list[StandingsEntry]): Ordered list of entries in the standings.
    """
    _REPR_FIELDS = ("name", "kind", "updated_at")

    def __init__(self, data: _TeamStandingsData | list[_RacingStandingsEntryData], provider: SofascoreProvider, **kwargs) -> None:
        super().__init__(data, provider, **kwargs)

        if not (isinstance(data, _TeamStandingsData) or (isinstance(data, list) and all(isinstance(e, _RacingStandingsEntryData) for e in data))):
            raise TypeError(f"Standings data must be either _TeamStandingsData or list[_RacingStandingsEntryData], got {type(data)}")

    @property
    def name(self) -> Optional[str]:
        """The name of the standings, e.g. "Ligue 1 table", "Formula 1 driver standings", etc."""
        if isinstance(self._data, _TeamStandingsData):
            return self._data.name
        else:
            return self._kwargs.get("name", None)

    @property
    def kind(self) -> Optional[str]:
        """The kind of standings, e.g. "home", "away", "total" (match standings) or "competitors", "teams" (racing standings)."""
        if isinstance(self._data, _TeamStandingsData):
            return self._data.type_
        else:
            return self._kwargs.get("kind", None)

    @property
    def updated_at(self) -> Optional[datetime]:
        """The date and time when the standings were last updated."""
        if isinstance(self._data, _TeamStandingsData):
            return self._data.updated_at
        else:
            return self._data[0].updated_at if self._data else None

    @cached_property
    def sport(self) -> Optional[Sport]:
        """The sport these standings belong to."""
        if self.entries:
            return self.entries[0].competitor.sport
        else:
            logger.warning(f"Standings {self.name} has no entries, cannot determine sport")
            return None

    @cached_property
    def entries(self) -> list[StandingsEntry]:
        """The entries in the standings."""
        entries = self._data.rows if isinstance(self._data, _TeamStandingsData) else self._data
        return [StandingsEntry._from_base_schema(e, self._provider) for e in entries]

entries cached property

The entries in the standings.

kind property

The kind of standings, e.g. "home", "away", "total" (match standings) or "competitors", "teams" (racing standings).

name property

The name of the standings, e.g. "Ligue 1 table", "Formula 1 driver standings", etc.

sport cached property

The sport these standings belong to.

updated_at property

The date and time when the standings were last updated.

StandingsEntry

Bases: BaseModel

A single entry in a Standings table.

Attributes:

Name Type Description
competitor Competitor

Competitor (team, driver, or rider).

position int

Rank/position in the standings.

points float

Points accumulated.

matches, (wins, draws, losses, scores_for, scores_against, score_formatted, games_behind, promotion)

Match-specific fields.

victories, (podiums, races_with_points, races_started)

Racing-specific fields.

time, gap_to_leader

Cycling-specific fields.

Source code in src/sportindex/domain/leaderboard.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
class StandingsEntry(BaseModel):
    """A single entry in a Standings table.

    Attributes:
        competitor (Competitor): Competitor (team, driver, or rider).
        position (int): Rank/position in the standings.
        points (float): Points accumulated.
        matches, wins, draws, losses, scores_for, scores_against, score_formatted, games_behind, promotion:
            Match-specific fields.
        victories, podiums, races_with_points, races_started:
            Racing-specific fields.
        time, gap_to_leader:
            Cycling-specific fields.
    """
    competitor: Competitor
    position: int
    points: Optional[float] = None

    # Match standings
    matches: Optional[int] = None
    wins: Optional[int] = None
    draws: Optional[int] = None
    losses: Optional[int] = None
    scores_for: Optional[int] = None
    scores_against: Optional[int] = None
    score_formatted: Optional[str] = None
    games_behind: Optional[float] = None  # Kept as float to preserve half-games (e.g. 1.5)
    promotion: Optional[Promotion] = None

    # Racing standings (motorsport)
    victories: Optional[int] = None
    podiums: Optional[int] = None
    races_with_points: Optional[int] = None
    races_started: Optional[int] = None

    # Racing standings (cycling)
    time: Optional[str] = None
    gap_to_leader: Optional[str] = None

    @classmethod
    def _from_base_schema(
        cls, 
        raw: _TeamStandingsEntryData | _RacingStandingsEntryData, 
        provider: Any
    ) -> StandingsEntry:

        if not raw.team:
            raise ValueError("Standings entry must have an associated team to determine competitor")
        if not raw.position:
            raise ValueError("Standings entry must have a position")

        if isinstance(raw, _TeamStandingsEntryData):
            return cls(
                competitor=Competitor(raw.team, provider),
                position=raw.position,
                points=raw.points,
                matches=raw.matches,
                wins=raw.wins,
                draws=raw.draws,
                losses=raw.losses,
                scores_for=raw.scores_for,
                scores_against=raw.scores_against,
                score_formatted=raw.score_diff_formatted,
                games_behind=raw.games_behind,
                promotion=raw.promotion
            )

        elif isinstance(raw, _RacingStandingsEntryData):
            return cls(
                competitor=Competitor(raw.team, provider),
                position=raw.position,
                points=raw.points,
                victories=raw.victories,
                podiums=raw.podiums,
                races_with_points=raw.races_with_points,
                races_started=raw.races_started,
                time=raw.time,
                gap_to_leader=raw.gap
            )

Venue

Bases: IdentifiableEntity[_VenueData | _StageData], EventAwareMixin

Represents a sports venue or race stage, e.g., a stadium, tennis court, or race track.

Handles basic information, location, capacity, associated teams, and provides lazy full-loading for detailed API properties.

Attributes:

Name Type Description
id int

Unique identifier of the venue.

name str

Name of the venue.

city str | None

City where the venue is located.

capacity int | None

Seating or attendance capacity of the venue.

country Country | None

Country where the venue is located.

teams EntityCollection[Competitor]

Main teams associated with the venue.

Methods:

Name Description
get_fixtures

Fetch all fixtures scheduled at this venue.

get_results

Fetch all results played at this venue.

from_id

int, provider: SofascoreProvider) -> Venue: Fetch a venue by its unique ID.

search

str, provider: SofascoreProvider) -> EntityCollection[Venue]: Search for venues by query string (up to 20 results).

Source code in src/sportindex/domain/venue.py
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
class Venue(IdentifiableEntity[_VenueData | _StageData], EventAwareMixin):
    """Represents a sports venue or race stage, e.g., a stadium, tennis court, or race track.

    Handles basic information, location, capacity, associated teams, and provides
    lazy full-loading for detailed API properties.

    Attributes:
        id (int): Unique identifier of the venue.
        name (str): Name of the venue.
        city (str | None): City where the venue is located.
        capacity (int | None): Seating or attendance capacity of the venue.
        country (Country | None): Country where the venue is located.
        teams (EntityCollection[Competitor]): Main teams associated with the venue.

    Methods:
        get_fixtures() -> EventCollection:
            Fetch all fixtures scheduled at this venue.
        get_results() -> EventCollection:
            Fetch all results played at this venue.
        from_id(venue_id: int, provider: SofascoreProvider) -> Venue:
            Fetch a venue by its unique ID.
        search(query: str, provider: SofascoreProvider) -> EntityCollection[Venue]:
            Search for venues by query string (up to 20 results).
    """
    _REPR_FIELDS = ("id", "name")

    def __init__(self, data: _VenueData | _StageData, provider: SofascoreProvider, **kwargs) -> None:
        super().__init__(data, provider, **kwargs)

        if not isinstance(data, (_VenueData, _StageData)):
            raise TypeError(f"Venue data must be either _VenueData or _StageData, got {type(data)}")

        self._full_loaded = False

    @property
    def id(self) -> int:
        """The unique ID of the venue."""
        return self._data.id

    @property
    def name(self) -> str:
        """The name of the venue."""
        if isinstance(self._data, _VenueData):
            return self._data.name or (self._data.stadium.name if self._data.stadium else "")
        elif isinstance(self._data, _StageData):
            return self._data.info.circuit if self._data.info else ""

    @property
    def city(self) -> Optional[str]:
        """The city where the venue is located."""
        self._full_load()
        if isinstance(self._data, _VenueData):
            return self._data.city
        elif isinstance(self._data, _StageData):
            return self._data.info.circuit_city if self._data.info else None

    @property
    def capacity(self) -> Optional[int]:
        """The capacity of the venue, if available."""
        if isinstance(self._data, _VenueData):
            self._full_load()
            return self._data.capacity or (self._data.stadium.capacity if self._data.stadium else None)
        elif isinstance(self._data, _StageData):
            return None

    @cached_property
    def country(self) -> Optional[Country]:
        """The country where the venue is located, if available."""
        self._full_load()
        from .core import Country
        if isinstance(self._data, _VenueData):
            return Country(self._data.country, self._provider)
        elif isinstance(self._data, _StageData):
            return Country.from_name(self._data.info.circuit_country, self._provider) if self._data.info and self._data.info.circuit_country else None

    @cached_property
    def teams(self) -> EntityCollection[Competitor]:
        self._full_load()
        from .competitor import Competitor
        if isinstance(self._data, _VenueData):
            return EntityCollection([
                Competitor(t, self._provider)
                for t in self._data.main_teams
            ])
        elif isinstance(self._data, _StageData):
            logger.warning("Teams for stages are not available in the current provider implementation, returning empty list")
            return EntityCollection([])

    def get_fixtures(self) -> EventCollection:
        """Fetch all fixtures for this venue."""
        return self._fetch_paginated_events(self._provider.get_venue_fixtures, self._data.id)

    def get_results(self) -> EventCollection:
        """Fetch all results for this venue."""
        return self._fetch_paginated_events(self._provider.get_venue_results, self._data.id)

    def _full_load(self) -> None:
        """
        Lazy-loads the complete venue from the provider.
        Called automatically when accessing properties that require full details 
        missing from the initial lightweight API response.
        """
        if self._full_loaded:
            return
        try:
            self._data = merge_pydantic_models(self._data, self._provider.get_venue(self._data.id))
            if not isinstance(self._data, _VenueData):
                raise TypeError(f"Venue data must be of type _VenueData after full load, got {type(self._data)}")
            self._full_loaded = True
            self._clear_cache()
        except ProviderNotFoundError:
            logger.debug(f"Venue with id {self._data.id} not found during full load")
            self._full_loaded = True
            self._clear_cache()
        except FetchError as e:
            logger.debug(f"Network error while fully loading venue with id {self._data.id}: {e}")
            self._full_loaded = True
            self._clear_cache()

    def _clear_cache(self) -> None:
        """Clear cached properties."""
        self.__dict__.pop("country", None)
        self.__dict__.pop("teams", None)
    @classmethod
    def from_id(cls, venue_id: int, provider: SofascoreProvider) -> Venue:
        """Fetch a venue by its ID."""
        try:
            parsed_data = provider.get_venue(venue_id)
        except ProviderNotFoundError as e:
            raise EntityNotFoundError(f"Venue with id {venue_id} not found") from e
        except FetchError as e:
            raise DomainError(f"Network error while fetching venue {venue_id}") from e
        return cls(parsed_data, provider)

    @classmethod
    def search(cls, query: str, provider: SofascoreProvider) -> EntityCollection[Venue]:
        """Search for venues matching the given query (up to the first 20 matches)."""
        entities = []
        for page in range(51): # Sofascore has a maximum of 50 pages of search results
            matches = provider.search_venues(query=query, page=page)
            if not matches:
                break
            for item in matches:
                entities.append(Venue(item.entity, provider))
            if len(matches) > 20:
                break
        return EntityCollection(entities[:20])

capacity property

The capacity of the venue, if available.

city property

The city where the venue is located.

country cached property

The country where the venue is located, if available.

id property

The unique ID of the venue.

name property

The name of the venue.

from_id(venue_id, provider) classmethod

Fetch a venue by its ID.

Source code in src/sportindex/domain/venue.py
142
143
144
145
146
147
148
149
150
151
@classmethod
def from_id(cls, venue_id: int, provider: SofascoreProvider) -> Venue:
    """Fetch a venue by its ID."""
    try:
        parsed_data = provider.get_venue(venue_id)
    except ProviderNotFoundError as e:
        raise EntityNotFoundError(f"Venue with id {venue_id} not found") from e
    except FetchError as e:
        raise DomainError(f"Network error while fetching venue {venue_id}") from e
    return cls(parsed_data, provider)

get_fixtures()

Fetch all fixtures for this venue.

Source code in src/sportindex/domain/venue.py
107
108
109
def get_fixtures(self) -> EventCollection:
    """Fetch all fixtures for this venue."""
    return self._fetch_paginated_events(self._provider.get_venue_fixtures, self._data.id)

get_results()

Fetch all results for this venue.

Source code in src/sportindex/domain/venue.py
111
112
113
def get_results(self) -> EventCollection:
    """Fetch all results for this venue."""
    return self._fetch_paginated_events(self._provider.get_venue_results, self._data.id)

search(query, provider) classmethod

Search for venues matching the given query (up to the first 20 matches).

Source code in src/sportindex/domain/venue.py
153
154
155
156
157
158
159
160
161
162
163
164
165
@classmethod
def search(cls, query: str, provider: SofascoreProvider) -> EntityCollection[Venue]:
    """Search for venues matching the given query (up to the first 20 matches)."""
    entities = []
    for page in range(51): # Sofascore has a maximum of 50 pages of search results
        matches = provider.search_venues(query=query, page=page)
        if not matches:
            break
        for item in matches:
            entities.append(Venue(item.entity, provider))
        if len(matches) > 20:
            break
    return EntityCollection(entities[:20])

Exceptions

Package-level exceptions for sportindex.

Provide a small, stable hierarchy users can import from sportindex.exceptions.

BusinessRuleViolation

Bases: DomainError

Raised when a domain/business rule is violated.

Source code in src/sportindex/exceptions.py
50
51
class BusinessRuleViolation(DomainError):
    """Raised when a domain/business rule is violated."""

ConflictError

Bases: DomainError

Raised when a business-logic conflict occurs (e.g. duplicate/unique constraint).

Source code in src/sportindex/exceptions.py
42
43
class ConflictError(DomainError):
    """Raised when a business-logic conflict occurs (e.g. duplicate/unique constraint)."""

DomainError

Bases: SportIndexError

Base class for domain/business-logic errors.

Source code in src/sportindex/exceptions.py
34
35
class DomainError(SportIndexError):
    """Base class for domain/business-logic errors."""

EntityNotFoundError

Bases: DomainError

Raised when a requested domain entity cannot be found or constructed.

Source code in src/sportindex/exceptions.py
38
39
class EntityNotFoundError(DomainError):
    """Raised when a requested domain entity cannot be found or constructed."""

FetchError

Bases: ProviderError

Raised for general fetch failures (non-404/429 HTTP responses or exhausted retries).

Source code in src/sportindex/exceptions.py
30
31
class FetchError(ProviderError):
    """Raised for general fetch failures (non-404/429 HTTP responses or exhausted retries)."""

InsufficientDataError

Bases: DomainError

Raised when required data is missing or incomplete for domain operations.

Source code in src/sportindex/exceptions.py
46
47
class InsufficientDataError(DomainError):
    """Raised when required data is missing or incomplete for domain operations."""

NetworkError

Bases: ProviderError

Network-level errors (connection, DNS, timeouts).

Source code in src/sportindex/exceptions.py
18
19
class NetworkError(ProviderError):
    """Network-level errors (connection, DNS, timeouts)."""

ParseError

Bases: SportIndexError

Raised when provider data cannot be parsed or mapped.

Source code in src/sportindex/exceptions.py
10
11
class ParseError(SportIndexError):
    """Raised when provider data cannot be parsed or mapped."""

ProviderError

Bases: SportIndexError

Base for errors coming from provider integration and network fetches.

Source code in src/sportindex/exceptions.py
14
15
class ProviderError(SportIndexError):
    """Base for errors coming from provider integration and network fetches."""

ProviderNotFoundError

Bases: ProviderError

Raised when a requested remote resource is not found (HTTP 404).

Source code in src/sportindex/exceptions.py
26
27
class ProviderNotFoundError(ProviderError):
    """Raised when a requested remote resource is not found (HTTP 404)."""

RateLimitError

Bases: ProviderError

Raised when an upstream service rate-limits requests (e.g. HTTP 429).

Source code in src/sportindex/exceptions.py
22
23
class RateLimitError(ProviderError):
    """Raised when an upstream service rate-limits requests (e.g. HTTP 429)."""

SportIndexError

Bases: Exception

Base class for all errors raised by sportindex.

Source code in src/sportindex/exceptions.py
6
7
class SportIndexError(Exception):
    """Base class for all errors raised by sportindex."""