ps3838api 1.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.
ps3838api/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ import logging
2
+ from pathlib import Path
3
+
4
+ MODULE_DIR = Path(__file__).resolve().parent
5
+ ROOT_MODULE_DIR = MODULE_DIR.parent.parent
6
+
7
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
@@ -0,0 +1,45 @@
1
+ """
2
+ PACKAGE: ps3838api.api
3
+
4
+ This package exposes the :class:`Client` and the legacy convenience helpers that
5
+ use a shared default client (imported from :mod:`ps3838api.api.default_client`).
6
+ """
7
+
8
+ from ps3838api.models.client import BalanceData, LeagueV3, PeriodData
9
+ from ps3838api.models.sports import BASEBALL_SPORT_ID, SOCCER_SPORT_ID
10
+
11
+ from .client import DEFAULT_API_BASE_URL, PinnacleClient
12
+ from .default_client import (
13
+ export_my_bets, # pyright: ignore[reportDeprecated]
14
+ get_betting_status, # pyright: ignore[reportDeprecated]
15
+ get_client_balance, # pyright: ignore[reportDeprecated]
16
+ get_fixtures, # pyright: ignore[reportDeprecated]
17
+ get_leagues, # pyright: ignore[reportDeprecated]
18
+ get_line, # pyright: ignore[reportDeprecated]
19
+ get_odds, # pyright: ignore[reportDeprecated]
20
+ get_periods, # pyright: ignore[reportDeprecated]
21
+ get_special_fixtures, # pyright: ignore[reportDeprecated]
22
+ get_sports, # pyright: ignore[reportDeprecated]
23
+ place_straigh_bet, # pyright: ignore[reportDeprecated]
24
+ )
25
+
26
+ __all__ = [
27
+ "PinnacleClient",
28
+ "DEFAULT_API_BASE_URL", # legacy
29
+ "SOCCER_SPORT_ID", # legacy
30
+ "BASEBALL_SPORT_ID", # legacy
31
+ "get_client_balance",
32
+ "get_periods",
33
+ "get_sports",
34
+ "get_leagues",
35
+ "get_fixtures",
36
+ "get_odds",
37
+ "get_special_fixtures",
38
+ "get_line",
39
+ "place_straigh_bet",
40
+ "get_betting_status",
41
+ "BalanceData", # legacy, was in ps3838api.api
42
+ "PeriodData", # was in ps3838api.api
43
+ "LeagueV3", # was in ps3838api.api
44
+ "export_my_bets",
45
+ ]
@@ -0,0 +1,516 @@
1
+ """
2
+ Client implementation for the PS3838 API.
3
+
4
+ The Client exposes all endpoints required by the public helper functions while
5
+ encapsulating session management, credentials, and error handling.
6
+ """
7
+
8
+ import base64
9
+ import os
10
+ import uuid
11
+ from datetime import datetime, timedelta, timezone
12
+ from typing import Any, Literal, cast, overload
13
+
14
+ import requests
15
+ from requests import Response, Session
16
+
17
+ from ps3838api.api.v4client import V4PinnacleClient
18
+ from ps3838api.models.bets import (
19
+ BetList,
20
+ BetsResponse,
21
+ BetStatus,
22
+ BetType,
23
+ FillType,
24
+ OddsFormat,
25
+ PlaceStraightBetResponse,
26
+ Side,
27
+ SortDir,
28
+ Team,
29
+ )
30
+ from ps3838api.models.client import BalanceData, BettingStatusResponse, LeagueV3, PeriodData
31
+ from ps3838api.models.errors import (
32
+ AccessBlockedError,
33
+ BaseballOnlyArgumentError,
34
+ PS3838APIError,
35
+ WrongEndpoint,
36
+ )
37
+ from ps3838api.models.fixtures import FixturesResponse
38
+ from ps3838api.models.lines import LineResponse
39
+ from ps3838api.models.odds import OddsResponse
40
+ from ps3838api.models.sports import BASEBALL_SPORT_ID, SOCCER_SPORT_ID, Sport
41
+
42
+ DEFAULT_API_BASE_URL = "https://api.ps3838.com"
43
+
44
+
45
+ class PinnacleClient:
46
+ """Stateful PS3838 API client backed by ``requests.Session``."""
47
+
48
+ def __init__(
49
+ self,
50
+ login: str | None = None,
51
+ password: str | None = None,
52
+ api_base_url: str | None = None,
53
+ default_sport: Sport = SOCCER_SPORT_ID,
54
+ *,
55
+ session: Session | None = None,
56
+ ) -> None:
57
+ # prepare login and password
58
+ self.default_sport = default_sport
59
+ self._login = login or os.environ.get("PS3838_LOGIN") or os.environ.get("PINNACLE_LOGIN")
60
+ self._password = password or os.environ.get("PS3838_PASSWORD") or os.environ.get("PINNACLE_PASSWORD")
61
+ if not self._login or not self._password:
62
+ raise ValueError(
63
+ "login and password must be provided either via "
64
+ "Client() arguments or PINNACLE_LOGIN/PS3838_LOGIN and PINNACLE_PASSWORD/PS3838_PASSWORD"
65
+ "environment variables."
66
+ )
67
+
68
+ env_base_url = os.environ.get("PS3838_API_BASE_URL") or os.environ.get("PINNACLE_API_BASE_URL")
69
+ resolved_base_url = api_base_url or env_base_url or DEFAULT_API_BASE_URL
70
+ self._base_url = resolved_base_url.rstrip("/")
71
+ # prepare session and headers
72
+ token = base64.b64encode(f"{self._login}:{self._password}".encode("utf-8"))
73
+ self._headers = {
74
+ "Authorization": f"Basic {token.decode('utf-8')}",
75
+ "User-Agent": "ps3838api (https://github.com/iliyasone/ps3838api)",
76
+ "Content-Type": "application/json",
77
+ }
78
+
79
+ self._session = session or requests.Session()
80
+ self._session.headers.update(self._headers)
81
+ # init v4 subclient
82
+ self.v4 = V4PinnacleClient(self)
83
+
84
+ # ------------------------------------------------------------------ #
85
+ # Core request helpers
86
+ # ------------------------------------------------------------------ #
87
+ def _handle_response(self, response: Response) -> Any:
88
+ try:
89
+ response.raise_for_status()
90
+ result: Any = response.json()
91
+ except requests.exceptions.HTTPError as exc:
92
+ if exc.response and exc.response.status_code == 405:
93
+ raise WrongEndpoint() from exc
94
+
95
+ payload: Any | None = None
96
+ if exc.response is not None:
97
+ try:
98
+ payload = exc.response.json()
99
+ except requests.exceptions.JSONDecodeError:
100
+ payload = None
101
+
102
+ if isinstance(payload, dict):
103
+ match payload:
104
+ case {"code": str(code), "message": str(message)}:
105
+ raise AccessBlockedError(message) from exc
106
+ case object():
107
+ pass
108
+
109
+ status_code = exc.response.status_code if exc.response else "Unknown"
110
+ raise AccessBlockedError(status_code) from exc
111
+ except requests.exceptions.JSONDecodeError as exc:
112
+ raise AccessBlockedError("Empty response") from exc
113
+
114
+ match result:
115
+ case {"code": str(code), "message": str(message)}:
116
+ raise PS3838APIError(code=code, message=message)
117
+ case _:
118
+ return result
119
+
120
+ def _request(
121
+ self,
122
+ method: Literal["GET", "POST"],
123
+ endpoint: str,
124
+ *,
125
+ params: dict[str, Any] | None = None,
126
+ body: dict[str, Any] | None = None,
127
+ ) -> Any:
128
+ url = f"{self._base_url}{endpoint}"
129
+ response = self._session.request(method, url, params=params, json=body)
130
+ return self._handle_response(response)
131
+
132
+ def _get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
133
+ return self._request("GET", endpoint, params=params)
134
+
135
+ def _post(self, endpoint: str, body: dict[str, Any]) -> Any:
136
+ return self._request("POST", endpoint, body=body)
137
+
138
+ # ------------------------------------------------------------------ #
139
+ # API endpoints
140
+ # ------------------------------------------------------------------ #
141
+ def get_client_balance(self) -> BalanceData:
142
+ endpoint = "/v1/client/balance"
143
+ data = self._get(endpoint)
144
+ return cast(BalanceData, data)
145
+
146
+ def get_periods(self, sport_id: int | None = None) -> list[PeriodData]:
147
+ resolved_sport_id = sport_id if sport_id is not None else self.default_sport
148
+ endpoint = "/v1/periods"
149
+ response = self._get(endpoint, params={"sportId": str(resolved_sport_id)})
150
+ periods_data = response.get("periods", [])
151
+ return cast(list[PeriodData], periods_data)
152
+
153
+ def get_sports(self) -> Any:
154
+ endpoint = "/v3/sports"
155
+ return self._get(endpoint)
156
+
157
+ def get_leagues(self, sport_id: int | None = None) -> list[LeagueV3]:
158
+ resolved_sport_id = sport_id if sport_id is not None else self.default_sport
159
+ endpoint = "/v3/leagues"
160
+ data = self._get(endpoint, params={"sportId": resolved_sport_id})
161
+ leagues_data = data.get("leagues", [])
162
+ return cast(list[LeagueV3], leagues_data)
163
+
164
+ def get_fixtures(
165
+ self,
166
+ sport_id: int | None = None,
167
+ league_ids: list[int] | None = None,
168
+ is_live: bool | None = None,
169
+ since: int | None = None,
170
+ event_ids: list[int] | None = None,
171
+ settled: bool = False,
172
+ ) -> FixturesResponse:
173
+ subpath = "/v3/fixtures/settled" if settled else "/v3/fixtures"
174
+ endpoint = f"{subpath}"
175
+
176
+ resolved_sport_id = sport_id if sport_id is not None else self.default_sport
177
+
178
+ params: dict[str, Any] = {"sportId": resolved_sport_id}
179
+ if league_ids:
180
+ params["leagueIds"] = ",".join(map(str, league_ids))
181
+ if is_live is not None:
182
+ params["isLive"] = int(is_live)
183
+ if since is not None:
184
+ params["since"] = since
185
+ if event_ids:
186
+ params["eventIds"] = ",".join(map(str, event_ids))
187
+
188
+ return cast(FixturesResponse, self._get(endpoint, params))
189
+
190
+ def get_odds(
191
+ self,
192
+ sport_id: int | None = None,
193
+ is_special: bool = False,
194
+ league_ids: list[int] | None = None,
195
+ odds_format: OddsFormat = "DECIMAL",
196
+ since: int | None = None,
197
+ is_live: bool | None = None,
198
+ event_ids: list[int] | None = None,
199
+ ) -> OddsResponse:
200
+ endpoint = "/v2/odds/special" if is_special else "/v3/odds"
201
+
202
+ resolved_sport_id = sport_id if sport_id is not None else self.default_sport
203
+
204
+ params: dict[str, Any] = {
205
+ "sportId": resolved_sport_id,
206
+ "oddsFormat": odds_format,
207
+ }
208
+ if league_ids:
209
+ params["leagueIds"] = ",".join(map(str, league_ids))
210
+ if since is not None:
211
+ params["since"] = since
212
+ if is_live is not None:
213
+ params["isLive"] = int(is_live)
214
+ if event_ids:
215
+ params["eventIds"] = ",".join(map(str, event_ids))
216
+
217
+ return cast(OddsResponse, self._get(endpoint, params))
218
+
219
+ def get_special_fixtures(
220
+ self,
221
+ sport_id: int | None = None,
222
+ league_ids: list[int] | None = None,
223
+ event_id: int | None = None,
224
+ ) -> Any:
225
+ endpoint = "/v2/fixtures/special"
226
+ resolved_sport_id = sport_id if sport_id is not None else self.default_sport
227
+ params: dict[str, Any] = {"sportId": resolved_sport_id, "oddsFormat": "Decimal"}
228
+
229
+ if league_ids:
230
+ params["leagueIds"] = ",".join(map(str, league_ids))
231
+ if event_id is not None:
232
+ params["eventId"] = event_id
233
+
234
+ return self._get(endpoint, params)
235
+
236
+ def get_line(
237
+ self,
238
+ league_id: int,
239
+ event_id: int,
240
+ period_number: int,
241
+ bet_type: Literal["SPREAD", "MONEYLINE", "TOTAL_POINTS", "TEAM_TOTAL_POINTS"],
242
+ handicap: float,
243
+ team: Literal["Team1", "Team2", "Draw"] | None = None,
244
+ side: Literal["OVER", "UNDER"] | None = None,
245
+ sport_id: int | None = None,
246
+ odds_format: str = "Decimal",
247
+ ) -> LineResponse:
248
+ endpoint = "/v2/line"
249
+ resolved_sport_id = sport_id if sport_id is not None else self.default_sport
250
+ params: dict[str, Any] = {
251
+ "sportId": resolved_sport_id,
252
+ "leagueId": league_id,
253
+ "eventId": event_id,
254
+ "periodNumber": period_number,
255
+ "betType": bet_type,
256
+ "handicap": handicap,
257
+ "oddsFormat": odds_format,
258
+ }
259
+ if team:
260
+ params["team"] = team
261
+ if side:
262
+ params["side"] = side
263
+
264
+ return cast(LineResponse, self._get(endpoint, params))
265
+
266
+ def place_straight_bet(
267
+ self,
268
+ *,
269
+ stake: float,
270
+ event_id: int,
271
+ bet_type: BetType,
272
+ line_id: int | None,
273
+ period_number: int = 0,
274
+ sport_id: int | None = None,
275
+ alt_line_id: int | None = None,
276
+ unique_request_id: str | None = None,
277
+ odds_format: OddsFormat = "DECIMAL",
278
+ fill_type: FillType = "NORMAL",
279
+ accept_better_line: bool = True,
280
+ win_risk_stake: Literal["WIN", "RISK"] = "RISK",
281
+ pitcher1_must_start: bool = True,
282
+ pitcher2_must_start: bool = True,
283
+ team: Team | None = None,
284
+ side: Side | None = None,
285
+ handicap: float | None = None,
286
+ ) -> PlaceStraightBetResponse:
287
+ if unique_request_id is None:
288
+ unique_request_id = str(uuid.uuid1())
289
+
290
+ resolved_sport_id = sport_id if sport_id is not None else self.default_sport
291
+
292
+ if resolved_sport_id != BASEBALL_SPORT_ID:
293
+ if not pitcher1_must_start or not pitcher2_must_start:
294
+ raise BaseballOnlyArgumentError()
295
+ params: dict[str, Any] = {
296
+ "oddsFormat": odds_format,
297
+ "uniqueRequestId": unique_request_id,
298
+ "acceptBetterLine": accept_better_line,
299
+ "stake": stake,
300
+ "winRiskStake": win_risk_stake,
301
+ "pitcher1MustStart": pitcher1_must_start,
302
+ "pitcher2MustStart": pitcher2_must_start,
303
+ "fillType": fill_type,
304
+ "sportId": resolved_sport_id,
305
+ "eventId": event_id,
306
+ "periodNumber": period_number,
307
+ "betType": bet_type,
308
+ }
309
+ if team is not None:
310
+ params["team"] = team
311
+ if line_id is not None:
312
+ params["lineId"] = line_id
313
+ if alt_line_id is not None:
314
+ params["altLineId"] = alt_line_id
315
+ if side is not None:
316
+ params["side"] = side
317
+ if handicap is not None:
318
+ params["handicap"] = handicap
319
+
320
+ endpoint = "/v2/bets/place"
321
+ data = self._post(endpoint, params)
322
+ return cast(PlaceStraightBetResponse, data)
323
+
324
+ @overload
325
+ def get_bets(
326
+ self,
327
+ *,
328
+ unique_request_ids: list[str],
329
+ ) -> "BetsResponse":
330
+ """Get bets by unique request IDs.
331
+
332
+ A comma separated list of `uniqueRequestId` from the place bet request.
333
+ If specified, it's highest priority, all other parameters are ignored.
334
+ Maximum is 10 ids. If client has bet id, preferred way is to use `betIds`
335
+ query parameter, you can use `uniqueRequestIds` when you do not have bet id.
336
+
337
+ There are 2 cases when client may not have a bet id:
338
+
339
+ 1. When you bet on live event with live delay, place bet response in that
340
+ case does not return bet id, so client can query bet status by
341
+ `uniqueRequestIds`.
342
+ 2. In case of any network issues when client is not sure what happened
343
+ with his place bet request. Empty response means that the bet was not
344
+ placed.
345
+
346
+ Note that there is a restriction: querying by uniqueRequestIds is supported
347
+ for straight and special bets and only up to 30 min from the moment the
348
+ bet was placed.
349
+
350
+ Args:
351
+ unique_request_ids: List of unique request IDs. Maximum is 10 ids.
352
+
353
+ Returns:
354
+ BetsResponse containing matching bets.
355
+ """
356
+ ...
357
+
358
+ @overload
359
+ def get_bets(
360
+ self,
361
+ *,
362
+ bet_ids: list[int],
363
+ ) -> "BetsResponse":
364
+ """Get bets by bet IDs.
365
+
366
+ A comma separated list of bet ids. When betids is submitted, no other
367
+ parameter is necessary. Maximum is 100 ids. Works for all non settled
368
+ bets and all bets settled in the last 30 days.
369
+
370
+ Args:
371
+ bet_ids: List of bet IDs. Maximum is 100 ids.
372
+
373
+ Returns:
374
+ BetsResponse containing matching bets.
375
+ """
376
+ ...
377
+
378
+ @overload
379
+ def get_bets(
380
+ self,
381
+ *,
382
+ betlist: BetList,
383
+ from_date: datetime,
384
+ to_date: datetime,
385
+ bet_statuses: list[BetStatus] | None = ...,
386
+ sort_dir: SortDir = ...,
387
+ page_size: int = ...,
388
+ from_record: int = ...,
389
+ bet_type: list[BetType] | None = ...,
390
+ ) -> "BetsResponse":
391
+ """Get bets by date range and bet list type.
392
+
393
+ Args:
394
+ betlist: Type of bet list to return (SETTLED, RUNNING, ALL).
395
+ from_date: Start date of the requested period. Required when betlist
396
+ parameter is submitted. Start date can be up to 30 days in the past.
397
+ Expected format is ISO8601 - can be set to just date or date and time.
398
+ to_date: End date of the requested period. Required when betlist
399
+ parameter is submitted. Expected format is ISO8601 - can be set to
400
+ just date or date and time. toDate value is exclusive, meaning it
401
+ cannot be equal to fromDate.
402
+ bet_statuses: Type of bet statuses to return (WON, LOSE, CANCELLED,
403
+ REFUNDED, NOT_ACCEPTED, ACCEPTED, PENDING_ACCEPTANCE). This works
404
+ only in conjunction with betlist, as additional filter.
405
+ sort_dir: Sort direction by postedAt/settledAt (ASC, DESC). Respected
406
+ only when querying by date range. Defaults to ASC.
407
+ page_size: Page size. Max is 1000. Respected only when querying by date
408
+ range. Defaults to 1000.
409
+ from_record: Starting record (inclusive) of the result. Respected only
410
+ when querying by date range. To fetch next page set it to toRecord+1.
411
+ Defaults to 0.
412
+ bet_type: A comma separated list of bet types (SPREAD, MONEYLINE,
413
+ TOTAL_POINTS, TEAM_TOTAL_POINTS, SPECIAL, PARLAY, TEASER, MANUAL).
414
+
415
+ Returns:
416
+ BetsResponse containing matching bets.
417
+ """
418
+ ...
419
+
420
+ def get_bets(
421
+ self,
422
+ *,
423
+ bet_ids: list[int] | None = None,
424
+ unique_request_ids: list[str] | None = None,
425
+ betlist: BetList | None = None,
426
+ bet_statuses: list[BetStatus] | None = None,
427
+ from_date: datetime | None = None,
428
+ to_date: datetime | None = None,
429
+ sort_dir: SortDir = "ASC",
430
+ page_size: int = 1000,
431
+ from_record: int = 0,
432
+ bet_type: list[BetType] | None = None,
433
+ ) -> "BetsResponse":
434
+ endpoint = "/v3/bets"
435
+ params: dict[str, Any] = {}
436
+
437
+ if unique_request_ids is not None:
438
+ if not unique_request_ids:
439
+ raise ValueError("uniqueRequestIds must not be empty")
440
+ if len(unique_request_ids) > 10:
441
+ raise ValueError("uniqueRequestIds max is 10")
442
+ params["uniqueRequestIds"] = ",".join(unique_request_ids)
443
+ return cast("BetsResponse", self._get(endpoint, params))
444
+
445
+ if bet_ids is not None:
446
+ if not bet_ids:
447
+ raise ValueError("betIds must not be empty")
448
+ if len(bet_ids) > 100:
449
+ raise ValueError("betIds max is 100")
450
+ params["betIds"] = ",".join(map(str, bet_ids))
451
+ return cast("BetsResponse", self._get(endpoint, params))
452
+
453
+ if betlist is None:
454
+ raise ValueError("betlist is required when betIds and uniqueRequestIds are not provided")
455
+ if from_date is None or to_date is None:
456
+ raise ValueError("fromDate and toDate are required when betlist is submitted")
457
+ if to_date <= from_date:
458
+ raise ValueError("toDate must be exclusive and greater than fromDate")
459
+ if from_date < datetime.now(timezone.utc) - timedelta(days=30):
460
+ raise ValueError("fromDate cannot be more than 30 days in the past")
461
+ if not (1 <= page_size <= 1000):
462
+ raise ValueError("pageSize must be between 1 and 1000")
463
+ if from_record < 0:
464
+ raise ValueError("fromRecord must be >= 0")
465
+
466
+ params["betlist"] = betlist
467
+ params["fromDate"] = from_date.isoformat()
468
+ params["toDate"] = to_date.isoformat()
469
+ params["sortDir"] = sort_dir
470
+ params["pageSize"] = page_size
471
+ params["fromRecord"] = from_record
472
+
473
+ if bet_statuses:
474
+ params["betStatuses"] = ",".join(bet_statuses)
475
+ if bet_type:
476
+ params["betType"] = ",".join(bet_type)
477
+
478
+ return cast("BetsResponse", self._get(endpoint, params))
479
+
480
+ def get_betting_status(self) -> BettingStatusResponse:
481
+ endpoint = "/v1/bets/betting-status"
482
+ return cast(BettingStatusResponse, self._get(endpoint, {}))
483
+
484
+ def export_my_bets(
485
+ self,
486
+ *,
487
+ from_datetime: datetime,
488
+ to_datetime: datetime,
489
+ d: int = -1,
490
+ status: Literal["UNSETTLED", "SETTLED"] = "SETTLED",
491
+ sd: bool = False,
492
+ bet_type: str = "WAGER",
493
+ product: str = "SB,PP,BG",
494
+ locale: str = "en_US",
495
+ timezone: str = "GMT-4",
496
+ ) -> bytes:
497
+ url = "https://www.ps3838.com/member-service/v2/export/my-bets/all"
498
+
499
+ params: dict[str, Any] = {
500
+ "f": from_datetime.strftime("%Y-%m-%d %H:%M:%S"),
501
+ "t": to_datetime.strftime("%Y-%m-%d %H:%M:%S"),
502
+ "d": d,
503
+ "s": status,
504
+ "sd": str(sd).lower(),
505
+ "type": bet_type,
506
+ "product": product,
507
+ "locale": locale,
508
+ "timezone": timezone,
509
+ }
510
+
511
+ response = self._session.get(url, headers=self._headers, params=params)
512
+ response.raise_for_status()
513
+ return response.content
514
+
515
+
516
+ __all__ = ["PinnacleClient", "DEFAULT_API_BASE_URL"]