magisterial 0.1.0__tar.gz
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.
- magisterial-0.1.0/.github/workflows/ci.yml +20 -0
- magisterial-0.1.0/.github/workflows/publish.yml +24 -0
- magisterial-0.1.0/.gitignore +8 -0
- magisterial-0.1.0/LICENSE +21 -0
- magisterial-0.1.0/PKG-INFO +129 -0
- magisterial-0.1.0/README.md +103 -0
- magisterial-0.1.0/magisterial/__init__.py +44 -0
- magisterial-0.1.0/magisterial/_client.py +304 -0
- magisterial-0.1.0/magisterial/_exceptions.py +128 -0
- magisterial-0.1.0/magisterial/_pagination.py +96 -0
- magisterial-0.1.0/magisterial/_version.py +1 -0
- magisterial-0.1.0/magisterial/py.typed +0 -0
- magisterial-0.1.0/magisterial/resources/__init__.py +3 -0
- magisterial-0.1.0/magisterial/resources/alerts.py +114 -0
- magisterial-0.1.0/magisterial/resources/games.py +51 -0
- magisterial-0.1.0/magisterial/resources/persons.py +45 -0
- magisterial-0.1.0/magisterial/resources/players.py +208 -0
- magisterial-0.1.0/magisterial/resources/portal.py +93 -0
- magisterial-0.1.0/magisterial/resources/query.py +123 -0
- magisterial-0.1.0/magisterial/resources/reference.py +79 -0
- magisterial-0.1.0/magisterial/resources/teams.py +170 -0
- magisterial-0.1.0/magisterial/types.py +621 -0
- magisterial-0.1.0/openapi.json +4385 -0
- magisterial-0.1.0/pyproject.toml +40 -0
- magisterial-0.1.0/scripts/sync-types.sh +33 -0
- magisterial-0.1.0/tests/test_client.py +272 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.10", "3.12", "3.13"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python-version }}
|
|
19
|
+
- run: pip install -e . pytest anyio
|
|
20
|
+
- run: pytest -q
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Publishes on GitHub release. Uses PyPI Trusted Publishing (OIDC) — configure
|
|
4
|
+
# this repo as a trusted publisher for the `magisterial` project on PyPI;
|
|
5
|
+
# no API token secret needed.
|
|
6
|
+
|
|
7
|
+
on:
|
|
8
|
+
release:
|
|
9
|
+
types: [published]
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
publish:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
environment: pypi
|
|
15
|
+
permissions:
|
|
16
|
+
id-token: write
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
- uses: actions/setup-python@v5
|
|
20
|
+
with:
|
|
21
|
+
python-version: "3.12"
|
|
22
|
+
- run: pip install build
|
|
23
|
+
- run: python -m build
|
|
24
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Magisterial
|
|
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,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: magisterial
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Magisterial college sports data API
|
|
5
|
+
Project-URL: Homepage, https://magisterial.ai
|
|
6
|
+
Project-URL: Documentation, https://api.magisterial.ai/v1/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/bluemens/magisterial-python
|
|
8
|
+
Project-URL: API reference (llms.txt), https://api.magisterial.ai/v1/llms.txt
|
|
9
|
+
Author-email: Magisterial <comfere@magisterial.ai>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api,college sports,magisterial,ncaa,sdk,transfer portal
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: httpx>=0.24.0
|
|
24
|
+
Requires-Dist: pydantic>=2.5.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# Magisterial Python SDK
|
|
28
|
+
|
|
29
|
+
The official Python library for the [Magisterial](https://magisterial.ai) developer API —
|
|
30
|
+
college sports data across NCAA D1/D2/D3, NAIA, and NJCAA: players, teams, rosters,
|
|
31
|
+
cross-program careers, games, the live transfer portal, and an agent-backed
|
|
32
|
+
natural-language query endpoint.
|
|
33
|
+
|
|
34
|
+
- Interactive API reference: https://api.magisterial.ai/v1/docs
|
|
35
|
+
- OpenAPI spec: https://api.magisterial.ai/v1/openapi.json
|
|
36
|
+
- Agent-ready one-file reference: https://api.magisterial.ai/v1/llms.txt
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install magisterial
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Requires Python 3.10+.
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
Create an API key at [magisterial.ai/console/api-keys](https://magisterial.ai/console/api-keys)
|
|
49
|
+
and set `MAGISTERIAL_API_KEY` (or pass `api_key=` to the client).
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from magisterial import Magisterial
|
|
53
|
+
|
|
54
|
+
client = Magisterial()
|
|
55
|
+
|
|
56
|
+
# Search players (auto-pagination follows the cursor for you)
|
|
57
|
+
page = client.players.search(
|
|
58
|
+
sport="soccer", division="D1", gender="women",
|
|
59
|
+
position="Forward", sort_by="goals",
|
|
60
|
+
)
|
|
61
|
+
for player in page.auto_paging_iter():
|
|
62
|
+
print(player.name, player.team, player.stats.get("goals"))
|
|
63
|
+
|
|
64
|
+
# One player's full profile
|
|
65
|
+
player = client.players.get(184223, sport="soccer", division="D3")
|
|
66
|
+
|
|
67
|
+
# Live transfer portal (usage-billed; use `since` for incremental polling)
|
|
68
|
+
portal = client.portal.list(sport="basketball", division="D1", status="INC")
|
|
69
|
+
|
|
70
|
+
# Natural-language query (usage-billed): submit and wait for the answer
|
|
71
|
+
run = client.query.create_and_poll(
|
|
72
|
+
prompt="Who led the NESCAC in assists this season?",
|
|
73
|
+
sport="soccer", division="D3", gender="men",
|
|
74
|
+
)
|
|
75
|
+
print(run.answer)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Async
|
|
79
|
+
|
|
80
|
+
Every method is mirrored on `AsyncMagisterial`:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
import asyncio
|
|
84
|
+
from magisterial import AsyncMagisterial
|
|
85
|
+
|
|
86
|
+
async def main():
|
|
87
|
+
async with AsyncMagisterial() as client:
|
|
88
|
+
page = await client.players.search(sport="soccer", division="D1")
|
|
89
|
+
async for player in page.auto_paging_iter():
|
|
90
|
+
print(player.name)
|
|
91
|
+
|
|
92
|
+
asyncio.run(main())
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Errors
|
|
96
|
+
|
|
97
|
+
Non-2xx responses raise typed exceptions carrying the API's error envelope:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from magisterial import Magisterial, NotFoundError, RateLimitError
|
|
101
|
+
|
|
102
|
+
client = Magisterial()
|
|
103
|
+
try:
|
|
104
|
+
client.players.get(1, sport="soccer", division="D1")
|
|
105
|
+
except NotFoundError as e:
|
|
106
|
+
print(e.error_code) # "player_not_found"
|
|
107
|
+
except RateLimitError as e:
|
|
108
|
+
print(e.retry_after) # seconds, from the Retry-After header
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`BillingError` (402) means API billing is not enabled or the monthly budget is
|
|
112
|
+
exhausted — manage both in the [developer console](https://magisterial.ai/console).
|
|
113
|
+
|
|
114
|
+
### Retries
|
|
115
|
+
|
|
116
|
+
Idempotent requests (and `players.search`) are retried automatically on 429s,
|
|
117
|
+
5xx and connection failures — up to `max_retries` (default 2), honoring the
|
|
118
|
+
server's `Retry-After`. Billable creates (`query.create`, `alerts.create`)
|
|
119
|
+
are never retried automatically.
|
|
120
|
+
|
|
121
|
+
## Types
|
|
122
|
+
|
|
123
|
+
All request/response models live in `magisterial.types` and are generated from
|
|
124
|
+
the published OpenAPI spec (`scripts/sync-types.sh`), so they cannot drift
|
|
125
|
+
from the live API contract.
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Magisterial Python SDK
|
|
2
|
+
|
|
3
|
+
The official Python library for the [Magisterial](https://magisterial.ai) developer API —
|
|
4
|
+
college sports data across NCAA D1/D2/D3, NAIA, and NJCAA: players, teams, rosters,
|
|
5
|
+
cross-program careers, games, the live transfer portal, and an agent-backed
|
|
6
|
+
natural-language query endpoint.
|
|
7
|
+
|
|
8
|
+
- Interactive API reference: https://api.magisterial.ai/v1/docs
|
|
9
|
+
- OpenAPI spec: https://api.magisterial.ai/v1/openapi.json
|
|
10
|
+
- Agent-ready one-file reference: https://api.magisterial.ai/v1/llms.txt
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install magisterial
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Requires Python 3.10+.
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
Create an API key at [magisterial.ai/console/api-keys](https://magisterial.ai/console/api-keys)
|
|
23
|
+
and set `MAGISTERIAL_API_KEY` (or pass `api_key=` to the client).
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from magisterial import Magisterial
|
|
27
|
+
|
|
28
|
+
client = Magisterial()
|
|
29
|
+
|
|
30
|
+
# Search players (auto-pagination follows the cursor for you)
|
|
31
|
+
page = client.players.search(
|
|
32
|
+
sport="soccer", division="D1", gender="women",
|
|
33
|
+
position="Forward", sort_by="goals",
|
|
34
|
+
)
|
|
35
|
+
for player in page.auto_paging_iter():
|
|
36
|
+
print(player.name, player.team, player.stats.get("goals"))
|
|
37
|
+
|
|
38
|
+
# One player's full profile
|
|
39
|
+
player = client.players.get(184223, sport="soccer", division="D3")
|
|
40
|
+
|
|
41
|
+
# Live transfer portal (usage-billed; use `since` for incremental polling)
|
|
42
|
+
portal = client.portal.list(sport="basketball", division="D1", status="INC")
|
|
43
|
+
|
|
44
|
+
# Natural-language query (usage-billed): submit and wait for the answer
|
|
45
|
+
run = client.query.create_and_poll(
|
|
46
|
+
prompt="Who led the NESCAC in assists this season?",
|
|
47
|
+
sport="soccer", division="D3", gender="men",
|
|
48
|
+
)
|
|
49
|
+
print(run.answer)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Async
|
|
53
|
+
|
|
54
|
+
Every method is mirrored on `AsyncMagisterial`:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import asyncio
|
|
58
|
+
from magisterial import AsyncMagisterial
|
|
59
|
+
|
|
60
|
+
async def main():
|
|
61
|
+
async with AsyncMagisterial() as client:
|
|
62
|
+
page = await client.players.search(sport="soccer", division="D1")
|
|
63
|
+
async for player in page.auto_paging_iter():
|
|
64
|
+
print(player.name)
|
|
65
|
+
|
|
66
|
+
asyncio.run(main())
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Errors
|
|
70
|
+
|
|
71
|
+
Non-2xx responses raise typed exceptions carrying the API's error envelope:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from magisterial import Magisterial, NotFoundError, RateLimitError
|
|
75
|
+
|
|
76
|
+
client = Magisterial()
|
|
77
|
+
try:
|
|
78
|
+
client.players.get(1, sport="soccer", division="D1")
|
|
79
|
+
except NotFoundError as e:
|
|
80
|
+
print(e.error_code) # "player_not_found"
|
|
81
|
+
except RateLimitError as e:
|
|
82
|
+
print(e.retry_after) # seconds, from the Retry-After header
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`BillingError` (402) means API billing is not enabled or the monthly budget is
|
|
86
|
+
exhausted — manage both in the [developer console](https://magisterial.ai/console).
|
|
87
|
+
|
|
88
|
+
### Retries
|
|
89
|
+
|
|
90
|
+
Idempotent requests (and `players.search`) are retried automatically on 429s,
|
|
91
|
+
5xx and connection failures — up to `max_retries` (default 2), honoring the
|
|
92
|
+
server's `Retry-After`. Billable creates (`query.create`, `alerts.create`)
|
|
93
|
+
are never retried automatically.
|
|
94
|
+
|
|
95
|
+
## Types
|
|
96
|
+
|
|
97
|
+
All request/response models live in `magisterial.types` and are generated from
|
|
98
|
+
the published OpenAPI spec (`scripts/sync-types.sh`), so they cannot drift
|
|
99
|
+
from the live API contract.
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Magisterial — official Python SDK for the Magisterial developer API.
|
|
2
|
+
# Docs: https://api.magisterial.ai/v1/docs Spec: https://api.magisterial.ai/v1/openapi.json
|
|
3
|
+
|
|
4
|
+
from . import types
|
|
5
|
+
from ._client import AsyncMagisterial, Magisterial
|
|
6
|
+
from ._exceptions import (
|
|
7
|
+
APIConnectionError,
|
|
8
|
+
APIStatusError,
|
|
9
|
+
APITimeoutError,
|
|
10
|
+
AuthenticationError,
|
|
11
|
+
BadRequestError,
|
|
12
|
+
BillingError,
|
|
13
|
+
InternalServerError,
|
|
14
|
+
MagisterialError,
|
|
15
|
+
NotFoundError,
|
|
16
|
+
PermissionDeniedError,
|
|
17
|
+
QueryPollTimeout,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
UnprocessableEntityError,
|
|
20
|
+
)
|
|
21
|
+
from ._pagination import AsyncPage, SyncPage
|
|
22
|
+
from ._version import __version__
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Magisterial",
|
|
26
|
+
"AsyncMagisterial",
|
|
27
|
+
"SyncPage",
|
|
28
|
+
"AsyncPage",
|
|
29
|
+
"types",
|
|
30
|
+
"MagisterialError",
|
|
31
|
+
"APIConnectionError",
|
|
32
|
+
"APITimeoutError",
|
|
33
|
+
"APIStatusError",
|
|
34
|
+
"BadRequestError",
|
|
35
|
+
"AuthenticationError",
|
|
36
|
+
"BillingError",
|
|
37
|
+
"PermissionDeniedError",
|
|
38
|
+
"NotFoundError",
|
|
39
|
+
"UnprocessableEntityError",
|
|
40
|
+
"RateLimitError",
|
|
41
|
+
"InternalServerError",
|
|
42
|
+
"QueryPollTimeout",
|
|
43
|
+
"__version__",
|
|
44
|
+
]
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
# The Magisterial client core: auth, transport, retries, and JSON<->model
|
|
2
|
+
# plumbing shared by every resource namespace. Public entry points are
|
|
3
|
+
# ``Magisterial`` (sync) and ``AsyncMagisterial``.
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import random
|
|
9
|
+
from typing import Any, Dict, Mapping, Optional, Type, TypeVar
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from ._exceptions import (
|
|
15
|
+
APIConnectionError,
|
|
16
|
+
APIStatusError,
|
|
17
|
+
APITimeoutError,
|
|
18
|
+
MagisterialError,
|
|
19
|
+
error_from_response,
|
|
20
|
+
)
|
|
21
|
+
from ._version import __version__
|
|
22
|
+
|
|
23
|
+
M = TypeVar("M", bound=BaseModel)
|
|
24
|
+
|
|
25
|
+
DEFAULT_BASE_URL = "https://api.magisterial.ai"
|
|
26
|
+
DEFAULT_TIMEOUT = 30.0
|
|
27
|
+
DEFAULT_MAX_RETRIES = 2
|
|
28
|
+
|
|
29
|
+
# Methods safe to retry unconditionally. POSTs are retried only when the
|
|
30
|
+
# caller opts in (search is a read; query/alert creates are billable).
|
|
31
|
+
_IDEMPOTENT_METHODS = {"GET", "DELETE"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _clean_params(params: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
|
|
35
|
+
return {k: v for k, v in (params or {}).items() if v is not None}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _ClientConfig:
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
api_key: Optional[str],
|
|
42
|
+
base_url: Optional[str],
|
|
43
|
+
timeout: float,
|
|
44
|
+
max_retries: int,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.api_key = api_key or os.environ.get("MAGISTERIAL_API_KEY")
|
|
47
|
+
if not self.api_key:
|
|
48
|
+
raise MagisterialError(
|
|
49
|
+
"No API key provided. Pass api_key=... or set the "
|
|
50
|
+
"MAGISTERIAL_API_KEY environment variable. Create keys at "
|
|
51
|
+
"https://magisterial.ai/console/api-keys"
|
|
52
|
+
)
|
|
53
|
+
self.base_url = (
|
|
54
|
+
base_url
|
|
55
|
+
or os.environ.get("MAGISTERIAL_BASE_URL")
|
|
56
|
+
or DEFAULT_BASE_URL
|
|
57
|
+
).rstrip("/")
|
|
58
|
+
self.timeout = timeout
|
|
59
|
+
self.max_retries = max_retries
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def headers(self) -> Dict[str, str]:
|
|
63
|
+
return {
|
|
64
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
65
|
+
"Accept": "application/json",
|
|
66
|
+
"User-Agent": f"magisterial-python/{__version__}",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _retry_delay(response: Optional[httpx.Response], attempt: int) -> float:
|
|
71
|
+
"""Seconds to sleep before retry `attempt` (0-based), honoring Retry-After."""
|
|
72
|
+
if response is not None:
|
|
73
|
+
header = response.headers.get("Retry-After")
|
|
74
|
+
if header:
|
|
75
|
+
try:
|
|
76
|
+
return max(0.0, float(header))
|
|
77
|
+
except ValueError:
|
|
78
|
+
pass
|
|
79
|
+
# Exponential backoff with jitter: ~0.5s, ~1s, ~2s ...
|
|
80
|
+
return 0.5 * (2**attempt) * (1 + random.random() * 0.25)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _should_retry(response: httpx.Response) -> bool:
|
|
84
|
+
return response.status_code == 429 or response.status_code >= 500
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Magisterial:
|
|
88
|
+
"""Synchronous client for the Magisterial developer API.
|
|
89
|
+
|
|
90
|
+
>>> client = Magisterial() # reads MAGISTERIAL_API_KEY
|
|
91
|
+
>>> page = client.players.search(sport="soccer", division="D1")
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
api_key: Optional[str] = None,
|
|
97
|
+
*,
|
|
98
|
+
base_url: Optional[str] = None,
|
|
99
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
100
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
101
|
+
http_client: Optional[httpx.Client] = None,
|
|
102
|
+
) -> None:
|
|
103
|
+
self._config = _ClientConfig(api_key, base_url, timeout, max_retries)
|
|
104
|
+
self._http = http_client or httpx.Client(timeout=timeout)
|
|
105
|
+
|
|
106
|
+
from .resources.alerts import Alerts
|
|
107
|
+
from .resources.games import Games
|
|
108
|
+
from .resources.persons import Persons
|
|
109
|
+
from .resources.players import Players
|
|
110
|
+
from .resources.portal import Portal
|
|
111
|
+
from .resources.query import Query
|
|
112
|
+
from .resources.reference import Reference
|
|
113
|
+
from .resources.teams import Teams
|
|
114
|
+
|
|
115
|
+
self.reference = Reference(self)
|
|
116
|
+
self.players = Players(self)
|
|
117
|
+
self.teams = Teams(self)
|
|
118
|
+
self.persons = Persons(self)
|
|
119
|
+
self.games = Games(self)
|
|
120
|
+
self.portal = Portal(self)
|
|
121
|
+
self.query = Query(self)
|
|
122
|
+
self.alerts = Alerts(self)
|
|
123
|
+
|
|
124
|
+
# -- transport ---------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def request(
|
|
127
|
+
self,
|
|
128
|
+
method: str,
|
|
129
|
+
path: str,
|
|
130
|
+
*,
|
|
131
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
132
|
+
json: Optional[Any] = None,
|
|
133
|
+
retryable: Optional[bool] = None,
|
|
134
|
+
) -> Any:
|
|
135
|
+
"""Perform a request and return decoded JSON, retrying 429/5xx and
|
|
136
|
+
connection failures for idempotent (or explicitly retryable) calls."""
|
|
137
|
+
import time
|
|
138
|
+
|
|
139
|
+
url = self._config.base_url + path
|
|
140
|
+
can_retry = (
|
|
141
|
+
method in _IDEMPOTENT_METHODS if retryable is None else retryable
|
|
142
|
+
)
|
|
143
|
+
attempts = self._config.max_retries + 1 if can_retry else 1
|
|
144
|
+
last_exc: Optional[Exception] = None
|
|
145
|
+
|
|
146
|
+
for attempt in range(attempts):
|
|
147
|
+
try:
|
|
148
|
+
response = self._http.request(
|
|
149
|
+
method,
|
|
150
|
+
url,
|
|
151
|
+
params=_clean_params(params),
|
|
152
|
+
json=json,
|
|
153
|
+
headers=self._config.headers,
|
|
154
|
+
)
|
|
155
|
+
except httpx.TimeoutException as exc:
|
|
156
|
+
last_exc = APITimeoutError()
|
|
157
|
+
last_exc.__cause__ = exc
|
|
158
|
+
response = None
|
|
159
|
+
except httpx.HTTPError as exc:
|
|
160
|
+
last_exc = APIConnectionError(str(exc) or "Connection error.")
|
|
161
|
+
last_exc.__cause__ = exc
|
|
162
|
+
response = None
|
|
163
|
+
|
|
164
|
+
if response is not None:
|
|
165
|
+
if response.status_code < 400:
|
|
166
|
+
return response.json()
|
|
167
|
+
last_exc = error_from_response(response)
|
|
168
|
+
if not (attempt < attempts - 1 and _should_retry(response)):
|
|
169
|
+
raise last_exc
|
|
170
|
+
elif attempt >= attempts - 1:
|
|
171
|
+
raise last_exc # type: ignore[misc]
|
|
172
|
+
|
|
173
|
+
time.sleep(
|
|
174
|
+
_retry_delay(
|
|
175
|
+
getattr(last_exc, "response", None)
|
|
176
|
+
if isinstance(last_exc, APIStatusError)
|
|
177
|
+
else None,
|
|
178
|
+
attempt,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
raise last_exc # type: ignore[misc] # unreachable, defensive
|
|
183
|
+
|
|
184
|
+
def get_model(self, path: str, model: Type[M], **params: Any) -> M:
|
|
185
|
+
return model.model_validate(self.request("GET", path, params=params))
|
|
186
|
+
|
|
187
|
+
def close(self) -> None:
|
|
188
|
+
self._http.close()
|
|
189
|
+
|
|
190
|
+
def __enter__(self) -> "Magisterial":
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
def __exit__(self, *exc: Any) -> None:
|
|
194
|
+
self.close()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class AsyncMagisterial:
|
|
198
|
+
"""Asynchronous client for the Magisterial developer API.
|
|
199
|
+
|
|
200
|
+
>>> client = AsyncMagisterial()
|
|
201
|
+
>>> page = await client.players.search(sport="soccer", division="D1")
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
def __init__(
|
|
205
|
+
self,
|
|
206
|
+
api_key: Optional[str] = None,
|
|
207
|
+
*,
|
|
208
|
+
base_url: Optional[str] = None,
|
|
209
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
210
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
211
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
212
|
+
) -> None:
|
|
213
|
+
self._config = _ClientConfig(api_key, base_url, timeout, max_retries)
|
|
214
|
+
self._http = http_client or httpx.AsyncClient(timeout=timeout)
|
|
215
|
+
|
|
216
|
+
from .resources.alerts import AsyncAlerts
|
|
217
|
+
from .resources.games import AsyncGames
|
|
218
|
+
from .resources.persons import AsyncPersons
|
|
219
|
+
from .resources.players import AsyncPlayers
|
|
220
|
+
from .resources.portal import AsyncPortal
|
|
221
|
+
from .resources.query import AsyncQuery
|
|
222
|
+
from .resources.reference import AsyncReference
|
|
223
|
+
from .resources.teams import AsyncTeams
|
|
224
|
+
|
|
225
|
+
self.reference = AsyncReference(self)
|
|
226
|
+
self.players = AsyncPlayers(self)
|
|
227
|
+
self.teams = AsyncTeams(self)
|
|
228
|
+
self.persons = AsyncPersons(self)
|
|
229
|
+
self.games = AsyncGames(self)
|
|
230
|
+
self.portal = AsyncPortal(self)
|
|
231
|
+
self.query = AsyncQuery(self)
|
|
232
|
+
self.alerts = AsyncAlerts(self)
|
|
233
|
+
|
|
234
|
+
# -- transport ---------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
async def request(
|
|
237
|
+
self,
|
|
238
|
+
method: str,
|
|
239
|
+
path: str,
|
|
240
|
+
*,
|
|
241
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
242
|
+
json: Optional[Any] = None,
|
|
243
|
+
retryable: Optional[bool] = None,
|
|
244
|
+
) -> Any:
|
|
245
|
+
import asyncio
|
|
246
|
+
|
|
247
|
+
url = self._config.base_url + path
|
|
248
|
+
can_retry = (
|
|
249
|
+
method in _IDEMPOTENT_METHODS if retryable is None else retryable
|
|
250
|
+
)
|
|
251
|
+
attempts = self._config.max_retries + 1 if can_retry else 1
|
|
252
|
+
last_exc: Optional[Exception] = None
|
|
253
|
+
|
|
254
|
+
for attempt in range(attempts):
|
|
255
|
+
try:
|
|
256
|
+
response = await self._http.request(
|
|
257
|
+
method,
|
|
258
|
+
url,
|
|
259
|
+
params=_clean_params(params),
|
|
260
|
+
json=json,
|
|
261
|
+
headers=self._config.headers,
|
|
262
|
+
)
|
|
263
|
+
except httpx.TimeoutException as exc:
|
|
264
|
+
last_exc = APITimeoutError()
|
|
265
|
+
last_exc.__cause__ = exc
|
|
266
|
+
response = None
|
|
267
|
+
except httpx.HTTPError as exc:
|
|
268
|
+
last_exc = APIConnectionError(str(exc) or "Connection error.")
|
|
269
|
+
last_exc.__cause__ = exc
|
|
270
|
+
response = None
|
|
271
|
+
|
|
272
|
+
if response is not None:
|
|
273
|
+
if response.status_code < 400:
|
|
274
|
+
return response.json()
|
|
275
|
+
last_exc = error_from_response(response)
|
|
276
|
+
if not (attempt < attempts - 1 and _should_retry(response)):
|
|
277
|
+
raise last_exc
|
|
278
|
+
elif attempt >= attempts - 1:
|
|
279
|
+
raise last_exc # type: ignore[misc]
|
|
280
|
+
|
|
281
|
+
await asyncio.sleep(
|
|
282
|
+
_retry_delay(
|
|
283
|
+
getattr(last_exc, "response", None)
|
|
284
|
+
if isinstance(last_exc, APIStatusError)
|
|
285
|
+
else None,
|
|
286
|
+
attempt,
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
raise last_exc # type: ignore[misc] # unreachable, defensive
|
|
291
|
+
|
|
292
|
+
async def get_model(self, path: str, model: Type[M], **params: Any) -> M:
|
|
293
|
+
return model.model_validate(
|
|
294
|
+
await self.request("GET", path, params=params)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
async def close(self) -> None:
|
|
298
|
+
await self._http.aclose()
|
|
299
|
+
|
|
300
|
+
async def __aenter__(self) -> "AsyncMagisterial":
|
|
301
|
+
return self
|
|
302
|
+
|
|
303
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
304
|
+
await self.close()
|