ps3838api 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ps3838api might be problematic. Click here for more details.

ps3838api/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from pathlib import Path
2
+
3
+ MODULE_DIR = Path(__file__).resolve().parent
4
+ ROOT_DIR = MODULE_DIR.parent.parent
5
+
ps3838api/api.py ADDED
@@ -0,0 +1,528 @@
1
+ """
2
+ bets_ps3838.py
3
+
4
+ A simplified module providing functions to interact with the PS3838 API.
5
+ All endpoints required for basic usage (sports, leagues, fixtures, odds,
6
+ totals, placing bets, client balance, etc.) are included.
7
+
8
+ Environment Variables:
9
+ PS3838_LOGIN: str # The account username
10
+ PS3838_PASSWORD: str # The account password
11
+
12
+ Python Version: 3.12.8 (Strict type hints)
13
+ """
14
+
15
+ import os
16
+ import base64
17
+ import uuid
18
+ import requests
19
+
20
+
21
+ from typing import Literal, TypedDict, Any, NotRequired
22
+ from typing import cast
23
+ from typing import Callable, ParamSpec, TypeVar
24
+ from functools import wraps
25
+
26
+ from ps3838api.models.errors import (
27
+ AccessBlockedError,
28
+ BaseballOnlyArgumentError,
29
+ PS3838APIError,
30
+ WrongEndpoint,
31
+ )
32
+ from ps3838api.models.fixtures import FixturesResponse
33
+ from ps3838api.models.lines import LineResponse
34
+ from ps3838api.models.odds import OddsResponse
35
+ from ps3838api.models.bets import (
36
+ BetType,
37
+ BetsResponse,
38
+ FillType,
39
+ OddsFormat,
40
+ PlaceStraightBetResponse,
41
+ Side,
42
+ Team,
43
+ )
44
+
45
+ ###############################################################################
46
+ # Environment Variables & Authorization
47
+ ###############################################################################
48
+
49
+ _USERNAME: str = os.environ.get("PS3838_LOGIN", "")
50
+ _PASSWORD: str = os.environ.get("PS3838_PASSWORD", "")
51
+
52
+ if not _USERNAME or not _PASSWORD:
53
+ raise ValueError("PS3838_LOGIN and PS3838_PASSWORD must be set in environment.")
54
+
55
+ # Basic HTTP Auth header
56
+ _token: str = base64.b64encode(f"{_USERNAME}:{_PASSWORD}".encode("utf-8")).decode(
57
+ "utf-8"
58
+ )
59
+ _HEADERS = {
60
+ "Authorization": f"Basic {_token}",
61
+ "User-Agent": "Mozilla/5.0 (PS3838 client)",
62
+ "Content-Type": "application/json",
63
+ }
64
+
65
+
66
+ ###############################################################################
67
+ # API Base
68
+ ###############################################################################
69
+
70
+ _BASE_URL: str = "https://api.ps3838.com"
71
+
72
+
73
+ ###############################################################################
74
+ # TypedDict Classes
75
+ ###############################################################################
76
+
77
+
78
+ class BalanceData(TypedDict):
79
+ availableBalance: float
80
+ outstandingTransactions: float
81
+ givenCredit: float
82
+ currency: str
83
+
84
+
85
+ class PeriodData(TypedDict, total=False):
86
+ number: NotRequired[int]
87
+ description: NotRequired[str]
88
+ shortDescription: NotRequired[str]
89
+ spreadDescription: NotRequired[str]
90
+ moneylineDescription: NotRequired[str]
91
+ totalDescription: NotRequired[str]
92
+ team1TotalDescription: NotRequired[str]
93
+ team2TotalDescription: NotRequired[str]
94
+ spreadShortDescription: NotRequired[str]
95
+ moneylineShortDescription: NotRequired[str]
96
+ totalShortDescription: NotRequired[str]
97
+ team1TotalShortDescription: NotRequired[str]
98
+ team2TotalShortDescription: NotRequired[str]
99
+
100
+
101
+ class LeagueV3(TypedDict):
102
+ id: int
103
+ name: str
104
+ homeTeamType: NotRequired[str] # Usually "Team1" or "Team2"
105
+ hasOfferings: NotRequired[bool]
106
+ container: NotRequired[str] # Region/country (e.g., "England")
107
+ allowRoundRobins: NotRequired[bool]
108
+ leagueSpecialsCount: NotRequired[int]
109
+ eventSpecialsCount: NotRequired[int]
110
+ eventCount: NotRequired[int]
111
+
112
+
113
+ ###############################################################################
114
+ # A normal SPORTS dict (subset or full). You can move this to a JSON if desired.
115
+ ###############################################################################
116
+ _SPORTS: dict[int, str] = {
117
+ 1: "Badminton",
118
+ 2: "Bandy",
119
+ 3: "Baseball",
120
+ 4: "Basketball",
121
+ 5: "Beach Volleyball",
122
+ 6: "Boxing",
123
+ 7: "Chess",
124
+ 8: "Cricket",
125
+ 9: "Curling",
126
+ 10: "Darts",
127
+ 13: "Field Hockey",
128
+ 14: "Floorball",
129
+ 15: "Football",
130
+ 16: "Futsal",
131
+ 17: "Golf",
132
+ 18: "Handball",
133
+ 19: "Hockey",
134
+ 20: "Horse Racing Specials",
135
+ 21: "Lacrosse",
136
+ 22: "Mixed Martial Arts",
137
+ 23: "Other Sports",
138
+ 24: "Politics",
139
+ 26: "Rugby League",
140
+ 27: "Rugby Union",
141
+ 28: "Snooker",
142
+ 29: "Soccer",
143
+ 30: "Softball",
144
+ 31: "Squash",
145
+ 32: "Table Tennis",
146
+ 33: "Tennis",
147
+ 34: "Volleyball",
148
+ 36: "Water Polo",
149
+ 37: "Padel Tennis",
150
+ 39: "Aussie Rules",
151
+ 40: "Alpine Skiing",
152
+ 41: "Biathlon",
153
+ 42: "Ski Jumping",
154
+ 43: "Cross Country",
155
+ 44: "Formula 1",
156
+ 45: "Cycling",
157
+ 46: "Bobsleigh",
158
+ 47: "Figure Skating",
159
+ 48: "Freestyle Skiing",
160
+ 49: "Luge",
161
+ 50: "Nordic Combined",
162
+ 51: "Short Track",
163
+ 52: "Skeleton",
164
+ 53: "Snow Boarding",
165
+ 54: "Speed Skating",
166
+ 55: "Olympics",
167
+ 56: "Athletics",
168
+ 57: "Crossfit",
169
+ 58: "Entertainment",
170
+ 59: "Archery",
171
+ 60: "Drone Racing",
172
+ 62: "Poker",
173
+ 63: "Motorsport",
174
+ 64: "Simulated Games",
175
+ 65: "Sumo",
176
+ }
177
+
178
+ SOCCER_SPORT_ID = 29
179
+ BASEBALL_SPORT_ID = 3
180
+
181
+
182
+ P = ParamSpec("P")
183
+ R = TypeVar("R")
184
+
185
+
186
+ def raise_ps3838_api_errors(
187
+ func: Callable[P, requests.Response],
188
+ ) -> Callable[P, dict[str, Any]]:
189
+ @wraps(func)
190
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> dict[str, Any]:
191
+ response = func(*args, **kwargs)
192
+
193
+ try:
194
+ response.raise_for_status()
195
+ result = response.json()
196
+ except requests.exceptions.HTTPError as e:
197
+ if e.response and e.response.status_code == 405:
198
+ raise WrongEndpoint()
199
+ raise AccessBlockedError(
200
+ e.response.status_code if e.response else "Unknown"
201
+ )
202
+ except requests.exceptions.JSONDecodeError:
203
+ raise AccessBlockedError("Empty response")
204
+
205
+ match result:
206
+ case {"code": str(code), "message": str(message)}:
207
+ raise PS3838APIError(code=code, message=message)
208
+ case _:
209
+ return result
210
+
211
+ return wrapper
212
+
213
+
214
+ ###############################################################################
215
+ # Helper to make GET requests
216
+ ###############################################################################
217
+ @raise_ps3838_api_errors
218
+ def _get(endpoint: str, params: dict[str, Any] | None = None):
219
+ """
220
+ Internal helper to perform GET requests to the PS3838 API.
221
+ Returns JSON-decoded response as Any. Raise errors on non-200.
222
+ """
223
+ url: str = f"{_BASE_URL}{endpoint}"
224
+ response = requests.get(url, headers=_HEADERS, params=params or {})
225
+ return response
226
+
227
+
228
+ ###############################################################################
229
+ # Helper to make POST requests (for placing bets, etc.)
230
+ ###############################################################################
231
+ @raise_ps3838_api_errors
232
+ def _post(endpoint: str, body: dict[str, Any]):
233
+ """
234
+ Internal helper to perform POST requests to the PS3838 API.
235
+ Returns JSON-decoded response as Any. Raise errors on non-200.
236
+ """
237
+ url: str = f"{_BASE_URL}{endpoint}"
238
+ response = requests.post(url, headers=_HEADERS, json=body)
239
+ return response
240
+
241
+
242
+ ###############################################################################
243
+ # Endpoints
244
+ ###############################################################################
245
+
246
+
247
+ def get_client_balance() -> BalanceData:
248
+ """
249
+ Returns current client balance, outstanding transactions, credit, and currency.
250
+ GET https://api.ps3838.com/v1/client/balance
251
+ """
252
+ endpoint = "/v1/client/balance"
253
+ data: Any = _get(endpoint)
254
+ # We expect data like:
255
+ # {
256
+ # "availableBalance": float,
257
+ # "outstandingTransactions": float,
258
+ # "givenCredit": float,
259
+ # "currency": "USD"
260
+ # }
261
+ return cast(BalanceData, data)
262
+
263
+
264
+ def get_periods(sport_id: int = SOCCER_SPORT_ID) -> list[PeriodData]:
265
+ """
266
+ Returns all periods for a given sport.
267
+ GET https://api.ps3838.com/v1/periods?sportId={sport_id}
268
+ """
269
+ endpoint = "/v1/periods"
270
+ data = _get(endpoint, params={"sportId": str(sport_id)})
271
+ # Typically the response is { "periods": [ { ...PeriodData... }, ... ] }
272
+ # We'll return just the list of PeriodData.
273
+ periods_data = data.get("periods", [])
274
+ return cast(list[PeriodData], periods_data)
275
+
276
+
277
+ def get_sports() -> Any:
278
+ """
279
+ GET https://api.ps3838.com/v3/sports
280
+ Returns available sports. Fields uncertain, so use Any.
281
+ """
282
+ endpoint = "/v3/sports"
283
+ return _get(endpoint)
284
+
285
+
286
+ def get_leagues(sport_id: int = SOCCER_SPORT_ID) -> list[LeagueV3]:
287
+ """
288
+ GET https://api.ps3838.com/v3/leagues?sportId={sport_id}
289
+ Returns leagues for a particular sport. Fields uncertain, so use Any.
290
+ """
291
+ endpoint = "/v3/leagues"
292
+ data = _get(endpoint, params={"sportId": sport_id})
293
+ leagues_data = data.get("leagues", [])
294
+ return cast(list[LeagueV3], leagues_data)
295
+
296
+
297
+ def get_fixtures(
298
+ sport_id: int = SOCCER_SPORT_ID,
299
+ league_ids: list[int] | None = None,
300
+ is_live: bool | None = None,
301
+ since: int | None = None,
302
+ event_ids: list[int] | None = None,
303
+ settled: bool = False,
304
+ ) -> FixturesResponse:
305
+ """
306
+ GET https://api.ps3838.com/v3/fixtures or /v3/fixtures/settled
307
+ Query parameters:
308
+ sportId, leagueIds, isLive, since, eventIds, ...
309
+ Returns fixtures data. Use Any for uncertain fields.
310
+ """
311
+ subpath = "/v3/fixtures/settled" if settled else "/v3/fixtures"
312
+ endpoint = f"{subpath}"
313
+
314
+ params: dict[str, Any] = {"sportId": sport_id}
315
+ if league_ids:
316
+ params["leagueIds"] = ",".join(map(str, league_ids))
317
+ if is_live is not None:
318
+ params["isLive"] = int(is_live)
319
+ if since is not None:
320
+ params["since"] = since
321
+ if event_ids:
322
+ params["eventIds"] = ",".join(map(str, event_ids))
323
+
324
+ return cast(FixturesResponse, _get(endpoint, params))
325
+
326
+
327
+ def get_odds(
328
+ sport_id: int = SOCCER_SPORT_ID,
329
+ is_special: bool = False,
330
+ league_ids: list[int] | None = None,
331
+ odds_format: OddsFormat = "DECIMAL",
332
+ since: int | None = None,
333
+ is_live: bool | None = None,
334
+ event_ids: list[int] | None = None,
335
+ ) -> OddsResponse:
336
+ """
337
+ GET Straight Odds (v3) or GET Special Odds (v2) for non-settled events.
338
+ - If is_special=True -> https://api.ps3838.com/v2/odds/special
339
+ - Else -> https://api.ps3838.com/v3/odds
340
+ Allows filtering by leagueIds, eventIds, etc.
341
+ """
342
+ if is_special:
343
+ endpoint = "/v2/odds/special"
344
+ else:
345
+ endpoint = "/v3/odds"
346
+
347
+ params: dict[str, Any] = {
348
+ "sportId": sport_id,
349
+ "oddsFormat": odds_format,
350
+ }
351
+ if league_ids:
352
+ params["leagueIds"] = ",".join(map(str, league_ids))
353
+ if since is not None:
354
+ params["since"] = since
355
+ if is_live is not None:
356
+ params["isLive"] = int(is_live)
357
+ if event_ids:
358
+ params["eventIds"] = ",".join(map(str, event_ids))
359
+
360
+ return cast(OddsResponse, _get(endpoint, params))
361
+
362
+
363
+ def get_special_fixtures(
364
+ sport_id: int = SOCCER_SPORT_ID,
365
+ league_ids: list[int] | None = None,
366
+ event_id: int | None = None,
367
+ ) -> Any:
368
+ """
369
+ GET https://api.ps3838.com/v2/fixtures/special
370
+ Returns special fixtures for non-settled events in a given sport.
371
+ Possibly filter by leagueIds or eventId.
372
+ """
373
+ endpoint = "/v2/fixtures/special"
374
+ params: dict[str, Any] = {"sportId": sport_id, "oddsFormat": "Decimal"}
375
+
376
+ if league_ids:
377
+ params["leagueIds"] = ",".join(map(str, league_ids))
378
+ if event_id is not None:
379
+ params["eventId"] = event_id
380
+
381
+ return _get(endpoint, params)
382
+
383
+
384
+ def get_line(
385
+ league_id: int,
386
+ event_id: int,
387
+ period_number: int,
388
+ bet_type: Literal["SPREAD", "MONEYLINE", "TOTAL_POINTS", "TEAM_TOTAL_POINTS"],
389
+ handicap: float,
390
+ team: Literal["Team1", "Team2", "Draw"] | None = None,
391
+ side: Literal["OVER", "UNDER"] | None = None,
392
+ sport_id: int = SOCCER_SPORT_ID,
393
+ odds_format: str = "Decimal",
394
+ ) -> LineResponse:
395
+ """
396
+ GET https://api.ps3838.com/v2/line
397
+ or known in docs as "Get Straight Line (v2)".
398
+ Use this to get the exact line, odds, and limit for a single bet (Spread, Total, etc.)
399
+
400
+ side is required for TOTAL_POINTS and TEAM_TOTAL_POINTS
401
+ """
402
+ endpoint = "/v2/line"
403
+ params: dict[str, Any] = {
404
+ "sportId": sport_id,
405
+ "leagueId": league_id,
406
+ "eventId": event_id,
407
+ "periodNumber": period_number,
408
+ "betType": bet_type,
409
+ "handicap": handicap,
410
+ "oddsFormat": odds_format,
411
+ }
412
+ if team:
413
+ params["team"] = team
414
+ if side:
415
+ params["side"] = side
416
+
417
+ return cast(LineResponse, _get(endpoint, params))
418
+
419
+
420
+ # parameters are key only, because all are very important
421
+ def place_straigh_bet(
422
+ *,
423
+ stake: float,
424
+ event_id: int,
425
+ bet_type: BetType,
426
+ line_id: int | None,
427
+ # EVENT INFO (JUST GUESSING)
428
+ period_number: int = 0,
429
+ sport_id: int = SOCCER_SPORT_ID,
430
+ # ALT LINE ID
431
+ alt_line_id: int | None = None,
432
+ # BET UUID
433
+ unique_request_id: str | None = None,
434
+ # BETS PARAMETERS
435
+ odds_format: OddsFormat = "DECIMAL",
436
+ fill_type: FillType = "NORMAL",
437
+ accept_better_line: bool = True,
438
+ win_risk_stake: Literal["WIN", "RISK"] = "RISK",
439
+ # BASEBALL ONLY
440
+ pitcher1_must_start: bool = True,
441
+ pitcher2_must_start: bool = True,
442
+ # TEAM
443
+ team: Team | None = None,
444
+ # SIDE (FOR TOTALS)
445
+ side: Side | None = None,
446
+ # HANDICAP (OR POINTS)
447
+ handicap: float | None = None,
448
+ ) -> PlaceStraightBetResponse:
449
+ if unique_request_id is None:
450
+ unique_request_id = str(uuid.uuid1())
451
+ if sport_id != BASEBALL_SPORT_ID:
452
+ if not pitcher1_must_start or not pitcher2_must_start:
453
+ raise BaseballOnlyArgumentError()
454
+ params: dict[str, Any] = {
455
+ "oddsFormat": odds_format,
456
+ "uniqueRequestId": unique_request_id,
457
+ "acceptBetterLine": accept_better_line,
458
+ "stake": stake,
459
+ "winRiskStake": win_risk_stake,
460
+ "pitcher1MustStart": pitcher1_must_start,
461
+ "pitcher2MustStart": pitcher2_must_start,
462
+ "fillType": fill_type,
463
+ "sportId": sport_id,
464
+ "eventId": event_id,
465
+ "periodNumber": period_number,
466
+ "betType": bet_type,
467
+ }
468
+ if team is not None:
469
+ params["team"] = team
470
+ if line_id is not None:
471
+ params["lineId"] = line_id
472
+ if alt_line_id is not None:
473
+ params["altLineId"] = alt_line_id
474
+ if side is not None:
475
+ params["side"] = side
476
+ if handicap is not None:
477
+ params["handicap"] = handicap
478
+
479
+ endpoint = "/v2/bets/place"
480
+ data = _post(endpoint, params)
481
+ return cast(PlaceStraightBetResponse, data)
482
+
483
+
484
+ def get_bets(
485
+ bet_ids: list[int] | None = None,
486
+ unique_request_ids: list[str] | None = None,
487
+ since: int | None = None,
488
+ ) -> BetsResponse:
489
+ """
490
+ GET https://api.ps3838.com/v3/bets
491
+ Retrieve status for placed bets. Filter by betId or since.
492
+ If bet is in PENDING_ACCEPTANCE state, you can poll this every 5s to see if accepted or rejected.
493
+ """
494
+ endpoint = "/v3/bets"
495
+ params: dict[str, Any] = {}
496
+ if bet_ids:
497
+ # Usually you can pass betIds= comma separated.
498
+ params["betIds"] = ",".join(map(str, bet_ids))
499
+ if unique_request_ids:
500
+ params["uniqueRequestIds"] = ",".join(unique_request_ids)
501
+ if since is not None:
502
+ params["since"] = since
503
+
504
+ return cast(BetsResponse, _get(endpoint, params))
505
+
506
+
507
+ class BettingStatusResponse(TypedDict):
508
+ status: Literal[
509
+ "ALL_BETTING_ENABLED", "ALL_LIVE_BETTING_CLOSED", "ALL_BETTING_CLOSED"
510
+ ]
511
+
512
+
513
+ def get_betting_status() -> BettingStatusResponse:
514
+ """
515
+ GET https://api.ps3838.com/v1/bets/betting-status
516
+
517
+ Returns:
518
+ BettingStatusResponse: A dict containing the current betting status.
519
+ The 'status' field can be one of:
520
+ - "ALL_BETTING_ENABLED"
521
+ - "ALL_LIVE_BETTING_CLOSED"
522
+ - "ALL_BETTING_CLOSED"
523
+
524
+ Note:
525
+ During maintenance windows, betting might be disabled.
526
+ """
527
+ endpoint: str = "/v1/bets/betting-status"
528
+ return cast(BettingStatusResponse, _get(endpoint, {}))
ps3838api/logic.py ADDED
@@ -0,0 +1,146 @@
1
+ from ps3838api.models.event import NoSuchLeagueFixtures, NoSuchOddsAvailable
2
+ from ps3838api.models.fixtures import FixtureV3, FixturesLeagueV3, FixturesResponse
3
+ from ps3838api.models.odds import OddsEventV3, OddsLeagueV3, OddsResponse
4
+ from ps3838api.models.tank import EventInfo
5
+
6
+
7
+ def merge_odds_response(old: OddsResponse, new: OddsResponse) -> OddsResponse:
8
+ """
9
+ Merge a snapshot OddsResponse (old) with a delta OddsResponse (new).
10
+ - Leagues are matched by league["id"].
11
+ - Events are matched by event["id"].
12
+ - Periods are matched by period["number"].
13
+ - Any period present in 'new' entirely replaces the same period number in 'old'.
14
+ - Periods not present in 'new' remain as they were in 'old'.
15
+
16
+ Returns a merged OddsResponse that includes updated odds and periods, retaining
17
+ old entries when no changes were reported in the delta.
18
+
19
+ Based on "How to get odds changes?" from https://ps3838api.github.io/FAQs.html
20
+ """
21
+ # Index the old leagues by their IDs
22
+ league_index: dict[int, OddsLeagueV3] = {
23
+ league["id"]: league for league in old.get("leagues", [])
24
+ }
25
+
26
+ # Loop through the new leagues
27
+ for new_league in new.get("leagues", []):
28
+ lid = new_league["id"]
29
+
30
+ # If it's an entirely new league, just store it
31
+ if lid not in league_index:
32
+ league_index[lid] = new_league
33
+ continue
34
+
35
+ # Otherwise merge it with the existing league
36
+ old_league = league_index[lid]
37
+ old_event_index = {event["id"]: event for event in old_league.get("events", [])}
38
+
39
+ # Loop through the new events
40
+ for new_event in new_league.get("events", []):
41
+ eid = new_event["id"]
42
+
43
+ # If it's an entirely new event, just store it
44
+ if eid not in old_event_index:
45
+ old_event_index[eid] = new_event
46
+ continue
47
+
48
+ # Otherwise, merge with the existing event
49
+ old_event = old_event_index[eid]
50
+
51
+ # Periods: build an index by 'number' from the old event
52
+ old_period_index = {
53
+ p["number"]: p for p in old_event.get("periods", []) if "number" in p
54
+ }
55
+
56
+ # Take all the new event's periods and override or insert them by 'number'
57
+ for new_period in new_event.get("periods", []):
58
+ if "number" not in new_period:
59
+ continue
60
+ old_period_index[new_period["number"]] = new_period
61
+
62
+ # Merge top-level fields: new event fields override old ones
63
+ merged_event = old_event.copy()
64
+ merged_event.update(new_event)
65
+
66
+ # Rebuild the merged_event's periods from the updated dictionary
67
+ merged_event["periods"] = list(old_period_index.values())
68
+
69
+ # Store back in the event index
70
+ old_event_index[eid] = merged_event
71
+
72
+ # Rebuild league's events list from the merged event index
73
+ old_league["events"] = list(old_event_index.values())
74
+
75
+ return {
76
+ "sportId": new.get("sportId", old["sportId"]),
77
+ # Always take the latest `last` timestamp from the new (delta) response
78
+ "last": new["last"],
79
+ # Rebuild leagues list
80
+ "leagues": list(league_index.values()),
81
+ }
82
+
83
+
84
+ def merge_fixtures(old: FixturesResponse, new: FixturesResponse) -> FixturesResponse:
85
+ league_index: dict[int, FixturesLeagueV3] = {
86
+ league["id"]: league for league in old.get("league", [])
87
+ }
88
+
89
+ for new_league in new.get("league", []):
90
+ lid = new_league["id"]
91
+ if lid in league_index:
92
+ old_events = {e["id"]: e for e in league_index[lid]["events"]}
93
+ for event in new_league["events"]:
94
+ old_events[event["id"]] = event # override or insert
95
+ league_index[lid]["events"] = list(old_events.values())
96
+ else:
97
+ league_index[lid] = new_league # new league entirely
98
+ return {
99
+ "sportId": new.get("sportId", old["sportId"]),
100
+ "last": new["last"],
101
+ "league": list(league_index.values()),
102
+ }
103
+
104
+
105
+ def find_league_in_fixtures(
106
+ fixtures: FixturesResponse, league: str, league_id: int
107
+ ) -> FixturesLeagueV3 | NoSuchLeagueFixtures:
108
+ for leagueV3 in fixtures["league"]:
109
+ if leagueV3["id"] == league_id:
110
+ return leagueV3
111
+ else:
112
+ return NoSuchLeagueFixtures(league)
113
+
114
+ def find_fixtureV3_in_league(leagueV3: FixturesLeagueV3, event_id: int) -> FixtureV3:
115
+ for eventV3 in leagueV3['events']:
116
+ if eventV3["id"] == event_id:
117
+ return eventV3
118
+ raise ValueError('No such event')
119
+
120
+
121
+ def filter_odds(
122
+ odds: OddsResponse, event_id: int, league_id: int | None = None
123
+ ) -> OddsEventV3 | NoSuchOddsAvailable:
124
+ """passing `league_id` makes search in json faster"""
125
+ for league in odds["leagues"]:
126
+ if league_id and league_id != league["id"]:
127
+ continue
128
+ for fixture in league["events"]:
129
+ if fixture["id"] == event_id:
130
+ return fixture
131
+ return NoSuchOddsAvailable(event_id)
132
+
133
+
134
+ def normalize_to_set(name: str) -> set[str]:
135
+ return set(
136
+ name.replace(" II", " 2").replace(" I", "").lower().replace("-", " ").split()
137
+ )
138
+
139
+
140
+ def find_event_by_id(fixtures: FixturesResponse, event: EventInfo) -> FixtureV3 | None:
141
+ for leagueV3 in fixtures["league"]:
142
+ if leagueV3["id"] == event["leagueId"]:
143
+ for fixtureV3 in leagueV3["events"]:
144
+ if fixtureV3["id"] == event["eventId"]:
145
+ return fixtureV3
146
+ return None