55tech 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.
- 55tech-0.1.0.dist-info/METADATA +84 -0
- 55tech-0.1.0.dist-info/RECORD +26 -0
- 55tech-0.1.0.dist-info/WHEEL +4 -0
- 55tech-0.1.0.dist-info/licenses/LICENSE +21 -0
- fiftyfivetech/__init__.py +36 -0
- fiftyfivetech/bookmakers/__init__.py +8 -0
- fiftyfivetech/bookmakers/endpoints.py +30 -0
- fiftyfivetech/bookmakers/resources.py +36 -0
- fiftyfivetech/client.py +38 -0
- fiftyfivetech/connection.py +169 -0
- fiftyfivetech/endpoints.py +15 -0
- fiftyfivetech/exceptions.py +108 -0
- fiftyfivetech/fixtures/__init__.py +37 -0
- fiftyfivetech/fixtures/endpoints.py +158 -0
- fiftyfivetech/fixtures/resources.py +216 -0
- fiftyfivetech/markets/__init__.py +9 -0
- fiftyfivetech/markets/endpoints.py +37 -0
- fiftyfivetech/markets/resources.py +43 -0
- fiftyfivetech/py.typed +0 -0
- fiftyfivetech/resources.py +44 -0
- fiftyfivetech/streaming/__init__.py +55 -0
- fiftyfivetech/streaming/endpoints.py +61 -0
- fiftyfivetech/streaming/listener.py +256 -0
- fiftyfivetech/streaming/resources.py +224 -0
- fiftyfivetech/streaming/stream.py +353 -0
- fiftyfivetech/utils.py +254 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: 55tech
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the 55Tech REST and WebSocket APIs
|
|
5
|
+
Keywords: 55tech,odds,betting,bookmaker,api,sdk
|
|
6
|
+
Author: brandon-betdex
|
|
7
|
+
Author-email: brandon-betdex <brandon@betdex.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Dist: requests>=2.28
|
|
22
|
+
Requires-Dist: websocket-client>=1.6
|
|
23
|
+
Requires-Dist: zstandard>=0.22
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Project-URL: Documentation, https://docs.oddspapi.io
|
|
26
|
+
Project-URL: Homepage, https://oddspapi.io
|
|
27
|
+
Project-URL: Issues, https://github.com/brandon-betdex/55tech-sdk-python/issues
|
|
28
|
+
Project-URL: Repository, https://github.com/brandon-betdex/55tech-sdk-python
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# 55tech
|
|
32
|
+
|
|
33
|
+
A Python client for the [55Tech](https://oddspapi.io) REST and WebSocket APIs.
|
|
34
|
+
|
|
35
|
+
Unofficial: not affiliated with or endorsed by 55Tech.
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
pip install 55tech
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## REST
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from fiftyfivetech import FiftyFiveTechClient
|
|
45
|
+
|
|
46
|
+
client = FiftyFiveTechClient(api_key)
|
|
47
|
+
|
|
48
|
+
markets = client.get_markets(sport_id=10).markets
|
|
49
|
+
fixtures = client.get_fixtures(sport_id=10).fixtures
|
|
50
|
+
odds = client.get_fixture_odds(fixtures[0].fixture_id, bookmakers=["pinnacle"]).odds
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Errors derive from `FiftyFiveTechError`.
|
|
54
|
+
|
|
55
|
+
## Streaming
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import threading
|
|
59
|
+
from fiftyfivetech.streaming import FixtureBook, Reconnect, SnapshotRequired, StreamListener
|
|
60
|
+
|
|
61
|
+
listener = StreamListener()
|
|
62
|
+
stream = client.create_stream(listener, channels=["odds", "bookmakers"],
|
|
63
|
+
bookmakers=["pinnacle"], sport_ids=[10])
|
|
64
|
+
threading.Thread(target=stream.start, daemon=True).start()
|
|
65
|
+
|
|
66
|
+
# Seed each fixture you follow, after the stream is connected
|
|
67
|
+
listener.apply_snapshot(client.get_fixture_odds(fixture_id, bookmakers=["pinnacle"]), ["pinnacle"])
|
|
68
|
+
|
|
69
|
+
while True:
|
|
70
|
+
update = listener.output_queue.get()
|
|
71
|
+
if isinstance(update, FixtureBook):
|
|
72
|
+
if update.is_tradeable("pinnacle"): # has odds, not stale, not suspended
|
|
73
|
+
record = update.get("pinnacle", outcome_id)
|
|
74
|
+
...
|
|
75
|
+
elif isinstance(update, SnapshotRequired):
|
|
76
|
+
... # updates were missed: call apply_snapshot again for each fixture you follow
|
|
77
|
+
elif isinstance(update, Reconnect):
|
|
78
|
+
stream.stop() # the gateway is about to close: start() again
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The listener merges stream updates and snapshots into one `FixtureBook` per fixture, keeping the later `changed_at` per record.
|
|
82
|
+
`remove_fixture` frees a finished one.
|
|
83
|
+
|
|
84
|
+
`start()` raises `FiftyFiveTechSocketError` when the connection drops; call it again to reconnect and resume.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
fiftyfivetech/__init__.py,sha256=2cVdjLzDpDHGIYgTsEVMCUVlHUNTo_2-naPFniNImZM,1133
|
|
2
|
+
fiftyfivetech/bookmakers/__init__.py,sha256=CjYMI3rrXLQIKrGy6_E2n8tDxoWPP2_efp3CymitMzo,229
|
|
3
|
+
fiftyfivetech/bookmakers/endpoints.py,sha256=vVoOi4WwnPVLdvH0dV3cHFsExHprTQx3fESYWAh0syU,925
|
|
4
|
+
fiftyfivetech/bookmakers/resources.py,sha256=rkX3CVxp0qqgezlbUa2QB9EFlwkeEKh7VWTkhhTOk4w,1153
|
|
5
|
+
fiftyfivetech/client.py,sha256=ReClD_ctQ8WRCkDwB5R_opoI1ScD6-w3SgoT-mJpHtQ,1087
|
|
6
|
+
fiftyfivetech/connection.py,sha256=KIrviXM2Z75mTXUvr4ifT9AbO_lPVgaWuGFmxxPhFjo,6460
|
|
7
|
+
fiftyfivetech/endpoints.py,sha256=vYJoC0xvkQfgZoOVu6fr-1IpeGW24Si1ebtZ-glgl-M,412
|
|
8
|
+
fiftyfivetech/exceptions.py,sha256=AZx1yFc74POcHcwEM2l7HhkrzAWhpGtBLUAmOp3haxs,2984
|
|
9
|
+
fiftyfivetech/fixtures/__init__.py,sha256=YyLRt2-Qx3zhaXKe-7XjDg2foojJcB6KdN3ccrbOQBM,700
|
|
10
|
+
fiftyfivetech/fixtures/endpoints.py,sha256=PwEcMAYPcO8pGwSnpjWU00ClaA0aas4gcRihIWHUnUE,6438
|
|
11
|
+
fiftyfivetech/fixtures/resources.py,sha256=Q2C6fHsE8eGmGuoKA56D8pFP365s16YetGdmF02E3LY,6487
|
|
12
|
+
fiftyfivetech/markets/__init__.py,sha256=oc2V_VqGDf8S13DCb63yEwFn_i-tyswWvZe_lV_XimM,241
|
|
13
|
+
fiftyfivetech/markets/endpoints.py,sha256=1vi5vtcX2KySZmj0HruJM-VlQPrlWcJopyljdPspuzE,1380
|
|
14
|
+
fiftyfivetech/markets/resources.py,sha256=bIeqCszjvbthKPJ3kUIazw1A0IzNjuGGzjjQuY2VRt8,1454
|
|
15
|
+
fiftyfivetech/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
fiftyfivetech/resources.py,sha256=gNobotez-3Tp2rTjOIibBopH6JgMzuck4XeBUpWXgro,1318
|
|
17
|
+
fiftyfivetech/streaming/__init__.py,sha256=Alu0sfbJUajJqseTXdB7dztEBhRDMY4uOCT7T8jrg9M,1158
|
|
18
|
+
fiftyfivetech/streaming/endpoints.py,sha256=orb_Nu-xXKZBKI42K4PNmPRu2GC9rC8no-xJmG75FtI,2477
|
|
19
|
+
fiftyfivetech/streaming/listener.py,sha256=6XEW6YUu2oZ6hLJYCu-c8QOOj1jmFI6hStyzu4j0lT4,10448
|
|
20
|
+
fiftyfivetech/streaming/resources.py,sha256=fcMCqCYzX7DdJTI5373m1MQi_XnAcruLF1mqApKdhB4,7084
|
|
21
|
+
fiftyfivetech/streaming/stream.py,sha256=V-SYyHUIMcyqMuJh2QeFU0jFZWTOR9JIVtPLwGpuac8,14430
|
|
22
|
+
fiftyfivetech/utils.py,sha256=hs21EBs6HljwaQcpXSmrWnlzsMSpZ3s3XbxCOwgEhbI,8816
|
|
23
|
+
55tech-0.1.0.dist-info/licenses/LICENSE,sha256=gZdVQd_H29XbJ3Q1DxxA9RL38EUr_lpFgLEDhiaRqYA,1072
|
|
24
|
+
55tech-0.1.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
|
|
25
|
+
55tech-0.1.0.dist-info/METADATA,sha256=Vv_pqLKWEeNi_hp10TGKEEXQV9CB_nCxIKgykyWXyTc,3074
|
|
26
|
+
55tech-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, brandon-betdex
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from fiftyfivetech import bookmakers, fixtures, markets, streaming, utils
|
|
2
|
+
from fiftyfivetech.client import FiftyFiveTechClient
|
|
3
|
+
from fiftyfivetech.connection import DEFAULT_BASE_URL, DEFAULT_STREAM_URL, FiftyFiveTechConnection
|
|
4
|
+
from fiftyfivetech.exceptions import (
|
|
5
|
+
FiftyFiveTechAPIError,
|
|
6
|
+
FiftyFiveTechDecodeError,
|
|
7
|
+
FiftyFiveTechError,
|
|
8
|
+
FiftyFiveTechInvalidRequestError,
|
|
9
|
+
FiftyFiveTechSocketError,
|
|
10
|
+
FiftyFiveTechStreamAuthenticationError,
|
|
11
|
+
FiftyFiveTechTransportError,
|
|
12
|
+
)
|
|
13
|
+
from fiftyfivetech.streaming import FiftyFiveTechStream, StreamListener
|
|
14
|
+
from fiftyfivetech.utils import DEFAULT_BATCH_SIZE
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"DEFAULT_BASE_URL",
|
|
18
|
+
"DEFAULT_BATCH_SIZE",
|
|
19
|
+
"DEFAULT_STREAM_URL",
|
|
20
|
+
"FiftyFiveTechAPIError",
|
|
21
|
+
"FiftyFiveTechClient",
|
|
22
|
+
"FiftyFiveTechConnection",
|
|
23
|
+
"FiftyFiveTechDecodeError",
|
|
24
|
+
"FiftyFiveTechError",
|
|
25
|
+
"FiftyFiveTechInvalidRequestError",
|
|
26
|
+
"FiftyFiveTechSocketError",
|
|
27
|
+
"FiftyFiveTechStream",
|
|
28
|
+
"FiftyFiveTechStreamAuthenticationError",
|
|
29
|
+
"FiftyFiveTechTransportError",
|
|
30
|
+
"StreamListener",
|
|
31
|
+
"bookmakers",
|
|
32
|
+
"fixtures",
|
|
33
|
+
"markets",
|
|
34
|
+
"streaming",
|
|
35
|
+
"utils",
|
|
36
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
|
|
3
|
+
from fiftyfivetech.bookmakers.resources import BookmakersResponse
|
|
4
|
+
from fiftyfivetech.endpoints import Endpoints
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BookmakersEndpoints(Endpoints):
|
|
8
|
+
"""
|
|
9
|
+
The bookmaker catalogue.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def get_bookmakers(
|
|
13
|
+
self,
|
|
14
|
+
*,
|
|
15
|
+
bookmakers: Sequence[str] | None = None,
|
|
16
|
+
player_props: bool | None = None,
|
|
17
|
+
) -> BookmakersResponse:
|
|
18
|
+
"""
|
|
19
|
+
Fetch the bookmakers the key can see, with their freshness bounds:
|
|
20
|
+
``GET /bookmakers``.
|
|
21
|
+
|
|
22
|
+
:param bookmakers: Slugs to fetch; all if omitted.
|
|
23
|
+
:param player_props: Only bookmakers offering player markets, if ``True``.
|
|
24
|
+
:returns: The bookmakers.
|
|
25
|
+
"""
|
|
26
|
+
return self.conn.call(
|
|
27
|
+
BookmakersResponse, "GET", "/bookmakers", wrap="bookmakers", params={
|
|
28
|
+
"bookmakers": bookmakers,
|
|
29
|
+
"playerProps": player_props,
|
|
30
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fiftyfivetech.resources import BaseModel, Response
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(kw_only=True, slots=True)
|
|
9
|
+
class Bookmaker(BaseModel):
|
|
10
|
+
"""
|
|
11
|
+
A bookmaker the key can see (``GET /bookmakers``).
|
|
12
|
+
"""
|
|
13
|
+
slug: str
|
|
14
|
+
bookmaker_name: str
|
|
15
|
+
active: bool
|
|
16
|
+
domain: str | None = None
|
|
17
|
+
websocket_pregame: bool | None = None
|
|
18
|
+
websocket_live: bool | None = None
|
|
19
|
+
player_props: bool | None = None
|
|
20
|
+
max_delay_pregame_in_sec: float | None = None
|
|
21
|
+
max_delay_live_in_sec: float | None = None
|
|
22
|
+
max_delay_pregame_main_in_sec: float | None = None
|
|
23
|
+
available_countries: list[str] = field(default_factory=list)
|
|
24
|
+
available_sports: str | None = None
|
|
25
|
+
limit_currency: str | None = None
|
|
26
|
+
stale_threshold_sec: Any = None
|
|
27
|
+
stale_odds_since: datetime | None = None
|
|
28
|
+
last_odds_at: datetime | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(kw_only=True, slots=True)
|
|
32
|
+
class BookmakersResponse(Response):
|
|
33
|
+
"""
|
|
34
|
+
The bookmaker catalogue (``GET /bookmakers``; the array is wrapped as ``bookmakers``).
|
|
35
|
+
"""
|
|
36
|
+
bookmakers: list[Bookmaker] = field(default_factory=list)
|
fiftyfivetech/client.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
|
|
3
|
+
from fiftyfivetech.bookmakers import BookmakersEndpoints
|
|
4
|
+
from fiftyfivetech.connection import DEFAULT_BASE_URL, DEFAULT_STREAM_URL, FiftyFiveTechConnection
|
|
5
|
+
from fiftyfivetech.fixtures import FixturesEndpoints
|
|
6
|
+
from fiftyfivetech.markets import MarketsEndpoints
|
|
7
|
+
from fiftyfivetech.streaming import StreamingEndpoints
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FiftyFiveTechClient(
|
|
11
|
+
BookmakersEndpoints,
|
|
12
|
+
MarketsEndpoints,
|
|
13
|
+
FixturesEndpoints,
|
|
14
|
+
StreamingEndpoints,
|
|
15
|
+
):
|
|
16
|
+
"""
|
|
17
|
+
Top-level client for 55Tech.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
api_key: str,
|
|
23
|
+
*,
|
|
24
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
25
|
+
stream_url: str = DEFAULT_STREAM_URL,
|
|
26
|
+
session: requests.Session | None = None,
|
|
27
|
+
timeout: float | None = 30.0,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""
|
|
30
|
+
Create the client and its connection; see ``FiftyFiveTechConnection`` for parameters.
|
|
31
|
+
"""
|
|
32
|
+
super().__init__(FiftyFiveTechConnection(
|
|
33
|
+
api_key,
|
|
34
|
+
base_url=base_url,
|
|
35
|
+
stream_url=stream_url,
|
|
36
|
+
session=session,
|
|
37
|
+
timeout=timeout,
|
|
38
|
+
))
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from fiftyfivetech.exceptions import (
|
|
10
|
+
FiftyFiveTechAPIError,
|
|
11
|
+
FiftyFiveTechDecodeError,
|
|
12
|
+
FiftyFiveTechInvalidRequestError,
|
|
13
|
+
FiftyFiveTechTransportError,
|
|
14
|
+
)
|
|
15
|
+
from fiftyfivetech.utils import decode, format_query_params
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
DEFAULT_BASE_URL = "https://v5.oddspapi.io/en"
|
|
19
|
+
DEFAULT_STREAM_URL = "wss://v5.oddspapi.io/ws"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FiftyFiveTechConnection:
|
|
23
|
+
"""
|
|
24
|
+
Where and how to reach the 55Tech API.
|
|
25
|
+
|
|
26
|
+
Every endpoint function takes one first::
|
|
27
|
+
|
|
28
|
+
conn = FiftyFiveTechConnection(api_key)
|
|
29
|
+
markets.get_markets(conn, sport_id=10)
|
|
30
|
+
|
|
31
|
+
:param api_key: API key, sent as the ``X-API-Key`` header and in the stream login.
|
|
32
|
+
:param base_url: API root, including the language segment: one of ``en``, ``es``,
|
|
33
|
+
``fr``, ``pt``, ``de``, ``it``, ``ru``, ``zh``. Only names are translated.
|
|
34
|
+
:param stream_url: WebSocket gateway URL.
|
|
35
|
+
:param session: ``requests.Session`` to send through; created if omitted. Mount
|
|
36
|
+
your own adapter on it for retries or TCP keepalives.
|
|
37
|
+
:param timeout: Seconds to wait per request; ``None`` waits forever.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
api_key: str,
|
|
43
|
+
*,
|
|
44
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
45
|
+
stream_url: str = DEFAULT_STREAM_URL,
|
|
46
|
+
session: requests.Session | None = None,
|
|
47
|
+
timeout: float | None = 30.0,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""
|
|
50
|
+
Create a connection; see the class docstring for parameters.
|
|
51
|
+
"""
|
|
52
|
+
self.api_key = api_key
|
|
53
|
+
self.base_url = base_url.rstrip("/")
|
|
54
|
+
self.stream_url = stream_url
|
|
55
|
+
self.http = session or requests.Session()
|
|
56
|
+
self.timeout = timeout
|
|
57
|
+
|
|
58
|
+
def request(
|
|
59
|
+
self,
|
|
60
|
+
method: str,
|
|
61
|
+
path: str,
|
|
62
|
+
*,
|
|
63
|
+
params: Mapping[str, Any] | None = None,
|
|
64
|
+
json: Any = None,
|
|
65
|
+
) -> Any:
|
|
66
|
+
"""
|
|
67
|
+
Send a request to any path, e.g. one this SDK does not cover yet.
|
|
68
|
+
|
|
69
|
+
:param method: HTTP method.
|
|
70
|
+
:param path: Path below ``base_url``, e.g. ``"/sports"``.
|
|
71
|
+
:param params: Query parameters; ``None`` dropped, lists comma-joined,
|
|
72
|
+
booleans lower-case.
|
|
73
|
+
:param json: Request body.
|
|
74
|
+
:returns: The decoded JSON body, or ``None`` if empty.
|
|
75
|
+
:raises FiftyFiveTechAPIError: On a non-2xx response.
|
|
76
|
+
:raises FiftyFiveTechTransportError: If no response was received.
|
|
77
|
+
:raises FiftyFiveTechDecodeError: If a 2xx body is not JSON.
|
|
78
|
+
"""
|
|
79
|
+
return self._send(method, path, params, json)[0]
|
|
80
|
+
|
|
81
|
+
def call(
|
|
82
|
+
self,
|
|
83
|
+
model: Any,
|
|
84
|
+
method: str,
|
|
85
|
+
path: str,
|
|
86
|
+
*,
|
|
87
|
+
path_params: Mapping[str, str] | None = None,
|
|
88
|
+
params: Mapping[str, Any] | None = None,
|
|
89
|
+
json: Any = None,
|
|
90
|
+
wrap: str | None = None,
|
|
91
|
+
) -> Any:
|
|
92
|
+
"""
|
|
93
|
+
Send a request and decode the body into ``model``, stamped with its timings.
|
|
94
|
+
|
|
95
|
+
:param model: Response model to decode into.
|
|
96
|
+
:param method: HTTP method.
|
|
97
|
+
:param path: Path below ``base_url``, with ``{placeholders}``.
|
|
98
|
+
:param path_params: Placeholder values; URL-escaped.
|
|
99
|
+
:param params: Query parameters, encoded as in ``request``.
|
|
100
|
+
:param json: Request body.
|
|
101
|
+
:param wrap: For an array body, the key to wrap it under, e.g. ``"markets"``.
|
|
102
|
+
:returns: The decoded model.
|
|
103
|
+
:raises FiftyFiveTechInvalidRequestError: If a path parameter is empty.
|
|
104
|
+
:raises FiftyFiveTechDecodeError: If the body does not fit ``model``.
|
|
105
|
+
"""
|
|
106
|
+
if path_params:
|
|
107
|
+
missing = [k for k, v in path_params.items() if not v]
|
|
108
|
+
if missing:
|
|
109
|
+
raise FiftyFiveTechInvalidRequestError(
|
|
110
|
+
f"{path} needs a value for {', '.join(missing)}")
|
|
111
|
+
path = path.format_map(
|
|
112
|
+
{k: quote(str(v), safe="") for k, v in path_params.items()})
|
|
113
|
+
body, sent_at, received_at, latency_ms = self._send(method, path, params, json)
|
|
114
|
+
if wrap is not None:
|
|
115
|
+
if not isinstance(body, list):
|
|
116
|
+
raise FiftyFiveTechDecodeError(
|
|
117
|
+
f"{method} {path}: expected a JSON array, got {body!r:.100}")
|
|
118
|
+
body = {wrap: body}
|
|
119
|
+
try:
|
|
120
|
+
result = decode(model, body or {})
|
|
121
|
+
except FiftyFiveTechDecodeError as exc:
|
|
122
|
+
raise FiftyFiveTechDecodeError(
|
|
123
|
+
f"{method} {path}: body does not fit {model.__name__}") from exc
|
|
124
|
+
result.sent_at = sent_at
|
|
125
|
+
result.received_at = received_at
|
|
126
|
+
result.latency_ms = latency_ms
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
def _send(
|
|
130
|
+
self,
|
|
131
|
+
method: str,
|
|
132
|
+
path: str,
|
|
133
|
+
params: Mapping[str, Any] | None,
|
|
134
|
+
json: Any,
|
|
135
|
+
) -> tuple[Any, datetime, datetime, float]:
|
|
136
|
+
"""
|
|
137
|
+
Send a request, timing it.
|
|
138
|
+
|
|
139
|
+
:returns: The decoded JSON body (or ``None`` if empty), when it was sent and
|
|
140
|
+
when the response arrived (UTC), and the round trip in milliseconds.
|
|
141
|
+
"""
|
|
142
|
+
sent_at = datetime.now(timezone.utc)
|
|
143
|
+
started = time.perf_counter()
|
|
144
|
+
try:
|
|
145
|
+
response = self.http.request(
|
|
146
|
+
method,
|
|
147
|
+
self.base_url + path,
|
|
148
|
+
params=format_query_params(params or {}),
|
|
149
|
+
json=json,
|
|
150
|
+
# The header keeps the key out of URLs, and so out of logs and errors.
|
|
151
|
+
headers={"Accept": "application/json", "X-API-Key": self.api_key},
|
|
152
|
+
timeout=self.timeout,
|
|
153
|
+
)
|
|
154
|
+
except requests.RequestException as exc:
|
|
155
|
+
raise FiftyFiveTechTransportError(
|
|
156
|
+
f"{method} {path} failed: {exc}", sent_at=sent_at,
|
|
157
|
+
latency_ms=(time.perf_counter() - started) * 1000) from exc
|
|
158
|
+
# The monotonic clock times the trip; the wall clock can jump.
|
|
159
|
+
latency_ms = (time.perf_counter() - started) * 1000
|
|
160
|
+
received_at = datetime.now(timezone.utc)
|
|
161
|
+
if not response.ok:
|
|
162
|
+
raise FiftyFiveTechAPIError(
|
|
163
|
+
response, sent_at=sent_at, received_at=received_at, latency_ms=latency_ms)
|
|
164
|
+
if not response.content:
|
|
165
|
+
return None, sent_at, received_at, latency_ms
|
|
166
|
+
try:
|
|
167
|
+
return response.json(), sent_at, received_at, latency_ms
|
|
168
|
+
except ValueError as exc:
|
|
169
|
+
raise FiftyFiveTechDecodeError(f"{method} {path} returned a non-JSON body") from exc
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from fiftyfivetech.connection import FiftyFiveTechConnection
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Endpoints:
|
|
5
|
+
"""
|
|
6
|
+
Base for the endpoint groups: holds the ``FiftyFiveTechConnection`` they send through.
|
|
7
|
+
|
|
8
|
+
:param conn: Connection to send through.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def __init__(self, conn: FiftyFiveTechConnection) -> None:
|
|
12
|
+
"""
|
|
13
|
+
Create the group; see the class docstring for parameters.
|
|
14
|
+
"""
|
|
15
|
+
self.conn = conn
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FiftyFiveTechError(Exception):
|
|
8
|
+
"""
|
|
9
|
+
Base class for every error this SDK raises.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FiftyFiveTechInvalidRequestError(FiftyFiveTechError):
|
|
14
|
+
"""
|
|
15
|
+
A call is missing a required value, or has one out of range; raised before
|
|
16
|
+
anything is sent.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FiftyFiveTechDecodeError(FiftyFiveTechError):
|
|
21
|
+
"""
|
|
22
|
+
A 2xx response body or stream frame could not be decoded; the cause is the ``__cause__``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FiftyFiveTechTransportError(FiftyFiveTechError):
|
|
27
|
+
"""
|
|
28
|
+
The request got no response; the ``requests`` error is the ``__cause__``.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, message: str, *, sent_at: datetime, latency_ms: float) -> None:
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.sent_at = sent_at
|
|
34
|
+
self.latency_ms = latency_ms
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class FiftyFiveTechSocketError(FiftyFiveTechError):
|
|
38
|
+
"""
|
|
39
|
+
The stream's socket could not connect, failed, or was closed by the server;
|
|
40
|
+
any underlying error is the ``__cause__``.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self, message: str, *, close_code: int | None = None, close_reason: str | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
super().__init__(message)
|
|
47
|
+
self.close_code = close_code
|
|
48
|
+
self.close_reason = close_reason
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class FiftyFiveTechStreamAuthenticationError(FiftyFiveTechError):
|
|
52
|
+
"""
|
|
53
|
+
The stream refused the login, or did not answer it in time.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, message: str, reply: Any = None) -> None:
|
|
57
|
+
super().__init__(message)
|
|
58
|
+
self.reply = reply
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class FiftyFiveTechAPIError(FiftyFiveTechError):
|
|
62
|
+
"""
|
|
63
|
+
The API answered with a non-2xx status.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
response: requests.Response,
|
|
69
|
+
*,
|
|
70
|
+
sent_at: datetime,
|
|
71
|
+
received_at: datetime,
|
|
72
|
+
latency_ms: float,
|
|
73
|
+
) -> None:
|
|
74
|
+
try:
|
|
75
|
+
body: Any = response.json()
|
|
76
|
+
except ValueError:
|
|
77
|
+
body = response.text
|
|
78
|
+
|
|
79
|
+
code = message = details = None
|
|
80
|
+
if isinstance(body, dict):
|
|
81
|
+
code = body.get("code")
|
|
82
|
+
message = body.get("message")
|
|
83
|
+
details = body.get("details")
|
|
84
|
+
|
|
85
|
+
self.status_code = response.status_code
|
|
86
|
+
self.code = code
|
|
87
|
+
self.message = message
|
|
88
|
+
self.details = details
|
|
89
|
+
self.body = body
|
|
90
|
+
self.response = response
|
|
91
|
+
self.sent_at = sent_at
|
|
92
|
+
self.received_at = received_at
|
|
93
|
+
self.latency_ms = latency_ms
|
|
94
|
+
summary = message or response.reason or "error"
|
|
95
|
+
if code:
|
|
96
|
+
summary += f" ({code})"
|
|
97
|
+
super().__init__(f"{self.status_code} {summary}")
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def retry_after(self) -> float | None:
|
|
101
|
+
"""
|
|
102
|
+
Seconds to wait before retrying, on a 429: the ``Retry-After`` header.
|
|
103
|
+
"""
|
|
104
|
+
value = self.response.headers.get("Retry-After")
|
|
105
|
+
try:
|
|
106
|
+
return float(value) if value is not None else None
|
|
107
|
+
except ValueError:
|
|
108
|
+
return None
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from fiftyfivetech.fixtures.endpoints import FixturesEndpoints
|
|
2
|
+
from fiftyfivetech.fixtures.resources import (
|
|
3
|
+
BookmakerFixtureMeta,
|
|
4
|
+
Clock,
|
|
5
|
+
Fixture,
|
|
6
|
+
FixtureMapping,
|
|
7
|
+
FixtureMappingResponse,
|
|
8
|
+
FixtureOddsResponse,
|
|
9
|
+
FixturesResponse,
|
|
10
|
+
FixtureStatus,
|
|
11
|
+
OddsRecord,
|
|
12
|
+
Participants,
|
|
13
|
+
Score,
|
|
14
|
+
Season,
|
|
15
|
+
Sport,
|
|
16
|
+
Tournament,
|
|
17
|
+
Venue,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"FixturesEndpoints",
|
|
22
|
+
"BookmakerFixtureMeta",
|
|
23
|
+
"Clock",
|
|
24
|
+
"Fixture",
|
|
25
|
+
"FixtureMapping",
|
|
26
|
+
"FixtureMappingResponse",
|
|
27
|
+
"FixtureOddsResponse",
|
|
28
|
+
"FixtureStatus",
|
|
29
|
+
"FixturesResponse",
|
|
30
|
+
"OddsRecord",
|
|
31
|
+
"Participants",
|
|
32
|
+
"Score",
|
|
33
|
+
"Season",
|
|
34
|
+
"Sport",
|
|
35
|
+
"Tournament",
|
|
36
|
+
"Venue",
|
|
37
|
+
]
|