42api 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.
- 42api-0.1.0.dist-info/METADATA +137 -0
- 42api-0.1.0.dist-info/RECORD +39 -0
- 42api-0.1.0.dist-info/WHEEL +4 -0
- 42api-0.1.0.dist-info/licenses/LICENSE +21 -0
- intra42/__init__.py +53 -0
- intra42/_async/__init__.py +0 -0
- intra42/_async/client.py +126 -0
- intra42/_async/query.py +99 -0
- intra42/_async/resources/__init__.py +0 -0
- intra42/_async/resources/base.py +77 -0
- intra42/_async/resources/campus_users.py +9 -0
- intra42/_async/resources/campuses.py +23 -0
- intra42/_async/resources/events.py +9 -0
- intra42/_async/resources/locations.py +30 -0
- intra42/_async/resources/users.py +23 -0
- intra42/_auth.py +103 -0
- intra42/_config.py +17 -0
- intra42/_pagination.py +23 -0
- intra42/_query_params.py +54 -0
- intra42/_rate_limit.py +102 -0
- intra42/_sync/__init__.py +0 -0
- intra42/_sync/client.py +132 -0
- intra42/_sync/query.py +105 -0
- intra42/_sync/resources/__init__.py +0 -0
- intra42/_sync/resources/base.py +83 -0
- intra42/_sync/resources/campus_users.py +15 -0
- intra42/_sync/resources/campuses.py +29 -0
- intra42/_sync/resources/events.py +15 -0
- intra42/_sync/resources/locations.py +34 -0
- intra42/_sync/resources/users.py +29 -0
- intra42/exceptions.py +88 -0
- intra42/models/__init__.py +0 -0
- intra42/models/base.py +47 -0
- intra42/models/campus.py +66 -0
- intra42/models/campus_user.py +16 -0
- intra42/models/event.py +41 -0
- intra42/models/location.py +26 -0
- intra42/models/user.py +73 -0
- intra42/py.typed +0 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: 42api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Object-oriented Python client for the 42 School API (api.intra.42.fr)
|
|
5
|
+
Project-URL: Homepage, https://github.com/Shadowaker/42api
|
|
6
|
+
Author-email: Dan <danyridolfo98@gmail.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: 42,42school,api,client,intra
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: httpx>=0.27
|
|
23
|
+
Requires-Dist: pydantic>=2.0
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# 42api
|
|
27
|
+
|
|
28
|
+
An object-oriented Python client for the [42 School API](https://api.intra.42.fr)
|
|
29
|
+
(`api.intra.42.fr`) that handles OAuth2 authentication, request pacing, and
|
|
30
|
+
pagination for you, so you can work with typed resources instead of raw JSON
|
|
31
|
+
and HTTP plumbing.
|
|
32
|
+
|
|
33
|
+
- **Sync and async** — `Client` and `AsyncClient` share one codebase and the
|
|
34
|
+
same interface.
|
|
35
|
+
- **Typed models** — resources are [Pydantic v2](https://docs.pydantic.dev/)
|
|
36
|
+
models with IDE-friendly autocomplete.
|
|
37
|
+
- **Automatic rate limiting** — requests are paced to stay under 42's limits;
|
|
38
|
+
`429`s are retried with backoff automatically.
|
|
39
|
+
- **Lazy pagination** — iterate a query and it walks every page for you.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uv add 42api
|
|
45
|
+
# or: pip install 42api
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from intra42 import Client
|
|
52
|
+
|
|
53
|
+
with Client(client_id="...", client_secret="...") as client:
|
|
54
|
+
user = client.users.get("jdoe")
|
|
55
|
+
print(user.login, user.email)
|
|
56
|
+
|
|
57
|
+
for user in client.users.filter(campus_id=1).sort("-level"):
|
|
58
|
+
print(user.login)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Async is the same shape:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import asyncio
|
|
65
|
+
from intra42 import AsyncClient
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def main() -> None:
|
|
69
|
+
async with AsyncClient(client_id="...", client_secret="...") as client:
|
|
70
|
+
user = await client.users.get("jdoe")
|
|
71
|
+
async for user in client.users.filter(campus_id=1).sort("-level"):
|
|
72
|
+
print(user.login)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
asyncio.run(main())
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Fetched instances expose their nested resources directly — no need to
|
|
79
|
+
build the scoped path yourself:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
campus = client.campuses.get(1)
|
|
83
|
+
for event in campus.events: # GET /campus/1/events
|
|
84
|
+
print(event.name)
|
|
85
|
+
|
|
86
|
+
user = client.users.get("jdoe")
|
|
87
|
+
for cu in user.campus_users: # GET /users/jdoe/campus_users
|
|
88
|
+
print(cu.campus_id, cu.is_primary)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
These (`campus.events`, `campus.users`, `user.events`, `user.campus_users`)
|
|
92
|
+
are only available on instances fetched via a client — a manually
|
|
93
|
+
constructed model raises a clear `RuntimeError` if accessed.
|
|
94
|
+
|
|
95
|
+
Get your `client_id`/`client_secret` by registering an app at
|
|
96
|
+
https://profile.intra.42.fr/oauth/applications. This library uses the
|
|
97
|
+
**client credentials** flow, so it accesses the API as your app rather than
|
|
98
|
+
as a specific logged-in user.
|
|
99
|
+
|
|
100
|
+
## Errors
|
|
101
|
+
|
|
102
|
+
All errors subclass `intra42.FortyTwoAPIError`, with subclasses for common
|
|
103
|
+
HTTP statuses: `AuthenticationError` (401), `PermissionDeniedError` (403),
|
|
104
|
+
`NotFoundError` (404), `ValidationError` (422), `RateLimitError` (429, only
|
|
105
|
+
raised once the built-in retry budget is exhausted), `ServerError` (5xx),
|
|
106
|
+
and `NetworkError` (connection/timeout failures).
|
|
107
|
+
|
|
108
|
+
## Status
|
|
109
|
+
|
|
110
|
+
Early-stage: currently covers `users`, `campuses`, `campus_users`,
|
|
111
|
+
`events`, and `locations` (including its `graph` analytics endpoint). More
|
|
112
|
+
resources are added incrementally on top of the same client/model/
|
|
113
|
+
query-builder pattern.
|
|
114
|
+
|
|
115
|
+
## Development
|
|
116
|
+
|
|
117
|
+
Uses [uv](https://docs.astral.sh/uv/) for dependency management.
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
uv sync # install dependencies
|
|
121
|
+
uv run pytest # run tests
|
|
122
|
+
uv run ruff check . && uv run ruff format --check . # lint
|
|
123
|
+
uv run mypy # type check
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The sync client (`intra42._sync`) is generated from the async client
|
|
127
|
+
(`intra42._async`) via [`unasync`](https://github.com/python-trio/unasync) —
|
|
128
|
+
edit the async source and regenerate:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
uv run python scripts/unasync_generate.py # regenerate
|
|
132
|
+
uv run python scripts/unasync_generate.py --check # verify no drift (CI)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
intra42/__init__.py,sha256=oQUa2-hzW7xSlBHlRd5P8JaIUY1nIPtsaKI9OS6DcRA,1226
|
|
2
|
+
intra42/_auth.py,sha256=N5QS8V7iF-TetN4adu0z7aouFZxwODP0Dmc1aIoflCc,3728
|
|
3
|
+
intra42/_config.py,sha256=aMZnkOoNrV9MNS0SGIgBKDlQ2Suugc0bkeaIIilRvWA,395
|
|
4
|
+
intra42/_pagination.py,sha256=eZkLQ07aHZ28_jRuJEiT3G_z2eVypweYAAoNcPSVxrk,705
|
|
5
|
+
intra42/_query_params.py,sha256=Fhyb43lNVa_YQB1OEYpBZ754IO-4zRbnN-FX-ttKdfQ,1624
|
|
6
|
+
intra42/_rate_limit.py,sha256=EX6dY34dUhmi31dA1gFAL3_1TtaY0Yjjg4Eag_dZUv0,3060
|
|
7
|
+
intra42/exceptions.py,sha256=diScpCHtki2M9RxuLNunnmKgssnojnlBvTbyvXYmPfI,2226
|
|
8
|
+
intra42/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
intra42/_async/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
intra42/_async/client.py,sha256=1HDWstyGvUxuV9i8kzaVN-XuwDiGmTnugjQl0Io-ye4,4194
|
|
11
|
+
intra42/_async/query.py,sha256=Nzeaz0M7hBxb7tpyQXj7Ge-kArXqN35htH0HGz63hFA,3443
|
|
12
|
+
intra42/_async/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
intra42/_async/resources/base.py,sha256=3GerWioCDjtOwNQvSFSmj0SgS_x-GncXdEdjbIkQSQo,2723
|
|
14
|
+
intra42/_async/resources/campus_users.py,sha256=9QtlgU96BqnIlJZwL9A_yNxaas00QisHzonjuPRtrOk,224
|
|
15
|
+
intra42/_async/resources/campuses.py,sha256=4IqeaffPAeHZZxqhV21AJ60Jczb3iQyGXb0xE82NqWg,709
|
|
16
|
+
intra42/_async/resources/events.py,sha256=kX4E1pNj31pfpazQOU6ePldxGSyq0QiGMwRpwcAUWHs,192
|
|
17
|
+
intra42/_async/resources/locations.py,sha256=nsbwH6GO9SdhPMPX_iPz2AcjfNc3RvxzLT7xqDg-54M,1027
|
|
18
|
+
intra42/_async/resources/users.py,sha256=-i3FACKLLAHYuRrkszQKQtPknBJXNpRZkeox3jEEhxE,713
|
|
19
|
+
intra42/_sync/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
|
+
intra42/_sync/client.py,sha256=q-cwntxt5NKOt7XgBLBygjP_6Tlua9BDbPZc9jHv0Cs,4294
|
|
21
|
+
intra42/_sync/query.py,sha256=VHm2hW_Rhe-lpA0oLlEVxOS-D4lfsLJlOPj5tJzvfbE,3577
|
|
22
|
+
intra42/_sync/resources/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
23
|
+
intra42/_sync/resources/base.py,sha256=meS7yQ9EglCiUTVFvc1keEdjwk6gFxO0fGu8N9Qd0IU,2851
|
|
24
|
+
intra42/_sync/resources/campus_users.py,sha256=RNflq09LBX6Ldm_Z0vwa_kyPkIsNRJLL55Ju098f8YA,435
|
|
25
|
+
intra42/_sync/resources/campuses.py,sha256=F0_UslCm2ZHZUqFvN9o50navrCOb1z-XBqTLxUQ6Xm8,920
|
|
26
|
+
intra42/_sync/resources/events.py,sha256=ARUhCUAGwjpREXr9TVO7n_3lxQeyu7cM8RxphGUrvOc,403
|
|
27
|
+
intra42/_sync/resources/locations.py,sha256=oCV1CCo4F-p5x27vW07fu2bmZLypUYjBITTt7khhFA0,1212
|
|
28
|
+
intra42/_sync/resources/users.py,sha256=1orQnarQTC8cbOwVZoiEBACnSlrFYQNjoH4XNYxa7s0,924
|
|
29
|
+
intra42/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
30
|
+
intra42/models/base.py,sha256=WWjA_Tqhonop_0oEOg7AM7lON8PMggRX0UCmY_fP1wg,1796
|
|
31
|
+
intra42/models/campus.py,sha256=6rQkLx1AEjUteQZ7qDrXDxY9uIRuCalUtM3rkstik2Q,1918
|
|
32
|
+
intra42/models/campus_user.py,sha256=CGlyUvQMBR0qROne6UYnNfnp69mecfIF2MXZjawz0So,397
|
|
33
|
+
intra42/models/event.py,sha256=2xNWEWnNeP7JzjphbdlKYNHVkVyjhQBSCWLRmNwrRdg,1157
|
|
34
|
+
intra42/models/location.py,sha256=kkgLqQb_5zceWxLL6NP7TXigVXiyaVLzUlcWCBfCgyo,617
|
|
35
|
+
intra42/models/user.py,sha256=eu5vmxWr0M2Esd_WWaGEGmMZwUW5a9Si2QRt_kS_Q-A,2270
|
|
36
|
+
42api-0.1.0.dist-info/METADATA,sha256=jdmielmsT9l4S1HiVKYYcSfzevjNcrX9U8CeC0qv8EE,4448
|
|
37
|
+
42api-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
38
|
+
42api-0.1.0.dist-info/licenses/LICENSE,sha256=x8aMiBy-p92-XG6NJxIs-1FbBp_Rk0i-IzlPdEzoHso,1060
|
|
39
|
+
42api-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dan
|
|
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.
|
intra42/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""intra42 — an object-oriented Python client for the 42 School API.
|
|
2
|
+
|
|
3
|
+
from intra42 import Client
|
|
4
|
+
|
|
5
|
+
with Client(client_id, client_secret) as client:
|
|
6
|
+
user = client.users.get("jdoe")
|
|
7
|
+
for user in client.users.filter(campus_id=1).sort("-level"):
|
|
8
|
+
...
|
|
9
|
+
|
|
10
|
+
An async client with the same interface is available as ``AsyncClient``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from ._async.client import AsyncClient
|
|
16
|
+
from ._config import ClientConfig
|
|
17
|
+
from ._sync.client import Client
|
|
18
|
+
from .exceptions import (
|
|
19
|
+
AuthenticationError,
|
|
20
|
+
FortyTwoAPIError,
|
|
21
|
+
NetworkError,
|
|
22
|
+
NotFoundError,
|
|
23
|
+
PermissionDeniedError,
|
|
24
|
+
RateLimitError,
|
|
25
|
+
ServerError,
|
|
26
|
+
ValidationError,
|
|
27
|
+
)
|
|
28
|
+
from .models.campus import Campus
|
|
29
|
+
from .models.campus_user import CampusUser
|
|
30
|
+
from .models.event import Event
|
|
31
|
+
from .models.location import Location
|
|
32
|
+
from .models.user import User
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"AsyncClient",
|
|
36
|
+
"Client",
|
|
37
|
+
"ClientConfig",
|
|
38
|
+
"User",
|
|
39
|
+
"Campus",
|
|
40
|
+
"CampusUser",
|
|
41
|
+
"Event",
|
|
42
|
+
"Location",
|
|
43
|
+
"FortyTwoAPIError",
|
|
44
|
+
"AuthenticationError",
|
|
45
|
+
"PermissionDeniedError",
|
|
46
|
+
"NotFoundError",
|
|
47
|
+
"ValidationError",
|
|
48
|
+
"RateLimitError",
|
|
49
|
+
"ServerError",
|
|
50
|
+
"NetworkError",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
__version__ = "0.1.0"
|
|
File without changes
|
intra42/_async/client.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""The async client, do not hand-maintain a parallel sync
|
|
2
|
+
implementation of the request logic here.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from .._auth import TokenManager
|
|
12
|
+
from .._config import DEFAULT_BASE_URL, ClientConfig
|
|
13
|
+
from .._rate_limit import RateLimiter
|
|
14
|
+
from ..exceptions import NetworkError, raise_for_status
|
|
15
|
+
from .resources.campus_users import AsyncCampusUsersResource
|
|
16
|
+
from .resources.campuses import AsyncCampusesResource
|
|
17
|
+
from .resources.events import AsyncEventsResource
|
|
18
|
+
from .resources.locations import AsyncLocationsResource
|
|
19
|
+
from .resources.users import AsyncUsersResource
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parse_retry_after(value: str | None) -> float:
|
|
23
|
+
"""Parse a ``Retry-After`` header value as seconds.
|
|
24
|
+
|
|
25
|
+
Assumed to be the integer-seconds form.
|
|
26
|
+
"""
|
|
27
|
+
if value is None:
|
|
28
|
+
return 1.0
|
|
29
|
+
try:
|
|
30
|
+
return max(0.0, float(value))
|
|
31
|
+
except ValueError:
|
|
32
|
+
return 1.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AsyncClient:
|
|
36
|
+
"""Async client for the 42 API, authenticated via OAuth2 client credentials."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
client_id: str,
|
|
41
|
+
client_secret: str,
|
|
42
|
+
*,
|
|
43
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
44
|
+
timeout: float = 10.0,
|
|
45
|
+
max_retries: int = 3,
|
|
46
|
+
config: ClientConfig | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
self._config = config or ClientConfig(
|
|
49
|
+
base_url=base_url, timeout=timeout, max_retries=max_retries
|
|
50
|
+
)
|
|
51
|
+
self._http = httpx.AsyncClient(timeout=self._config.timeout)
|
|
52
|
+
self._token_manager = TokenManager(client_id, client_secret)
|
|
53
|
+
self._rate_limiter = RateLimiter(
|
|
54
|
+
rate=self._config.rate,
|
|
55
|
+
burst=self._config.burst,
|
|
56
|
+
hourly_quota=self._config.hourly_quota,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
self.users = AsyncUsersResource(self)
|
|
60
|
+
self.campuses = AsyncCampusesResource(self)
|
|
61
|
+
self.campus_users = AsyncCampusUsersResource(self)
|
|
62
|
+
self.events = AsyncEventsResource(self)
|
|
63
|
+
self.locations = AsyncLocationsResource(self)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def base_url(self) -> str:
|
|
67
|
+
return self._config.base_url
|
|
68
|
+
|
|
69
|
+
def _build_url(self, path: str) -> str:
|
|
70
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
71
|
+
return path
|
|
72
|
+
return f"{self._config.base_url}{path}"
|
|
73
|
+
|
|
74
|
+
async def request(
|
|
75
|
+
self,
|
|
76
|
+
method: str,
|
|
77
|
+
path: str,
|
|
78
|
+
*,
|
|
79
|
+
params: dict[str, Any] | None = None,
|
|
80
|
+
json: dict[str, Any] | None = None,
|
|
81
|
+
) -> httpx.Response:
|
|
82
|
+
"""Send one authenticated, rate-limited, error-mapped request.
|
|
83
|
+
|
|
84
|
+
Retries on 429 up to ``max_retries`` times, honoring the response's
|
|
85
|
+
``Retry-After`` header via the shared rate limiter's cooldown gate,
|
|
86
|
+
before raising :class:`intra42.exceptions.RateLimitError`.
|
|
87
|
+
"""
|
|
88
|
+
url = self._build_url(path)
|
|
89
|
+
response: httpx.Response | None = None
|
|
90
|
+
|
|
91
|
+
for attempt in range(self._config.max_retries + 1):
|
|
92
|
+
token = await self._token_manager.aensure_token(self._http)
|
|
93
|
+
await self._rate_limiter.aacquire()
|
|
94
|
+
try:
|
|
95
|
+
response = await self._http.request(
|
|
96
|
+
method,
|
|
97
|
+
url,
|
|
98
|
+
params=params,
|
|
99
|
+
json=json,
|
|
100
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
101
|
+
)
|
|
102
|
+
except httpx.TransportError as exc:
|
|
103
|
+
raise NetworkError(f"Request to {url} failed: {exc}") from exc
|
|
104
|
+
|
|
105
|
+
if response.status_code == 429:
|
|
106
|
+
self._rate_limiter.notify_retry_after(
|
|
107
|
+
_parse_retry_after(response.headers.get("Retry-After"))
|
|
108
|
+
)
|
|
109
|
+
if attempt < self._config.max_retries:
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
raise_for_status(response)
|
|
113
|
+
return response
|
|
114
|
+
|
|
115
|
+
assert response is not None # loop always runs at least once
|
|
116
|
+
raise_for_status(response)
|
|
117
|
+
return response # pragma: no cover - raise_for_status always raises on a 429
|
|
118
|
+
|
|
119
|
+
async def aclose(self) -> None:
|
|
120
|
+
await self._http.aclose()
|
|
121
|
+
|
|
122
|
+
async def __aenter__(self) -> AsyncClient:
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
126
|
+
await self.aclose()
|
intra42/_async/query.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Lazy, chainable, auto-paginating query builder.
|
|
2
|
+
|
|
3
|
+
``AsyncQuerySet`` is returned by a resource's ``.filter()``/``.sort()``/etc.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import AsyncIterator, Callable
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
10
|
+
|
|
11
|
+
from .._pagination import parse_link_header
|
|
12
|
+
from .._query_params import build_query_params
|
|
13
|
+
from ..models.base import FortyTwoModel
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .client import AsyncClient
|
|
17
|
+
|
|
18
|
+
ModelT = TypeVar("ModelT", bound=FortyTwoModel)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AsyncQuerySet(Generic[ModelT]):
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
client: AsyncClient,
|
|
25
|
+
path: str,
|
|
26
|
+
model: type[ModelT],
|
|
27
|
+
*,
|
|
28
|
+
bind: Callable[[ModelT], None] | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
self._client = client
|
|
31
|
+
self._path = path
|
|
32
|
+
self._model = model
|
|
33
|
+
# Set by the owning resource to attach nested-resource accessors
|
|
34
|
+
# (e.g. User.events) to each instance as it's parsed. See
|
|
35
|
+
# AsyncResource._bind_relations / FortyTwoModel._bind_relation.
|
|
36
|
+
self._bind = bind
|
|
37
|
+
self._filters: dict[str, Any] = {}
|
|
38
|
+
self._sort_fields: list[str] = []
|
|
39
|
+
self._page_size: int | None = None
|
|
40
|
+
self._ranges: dict[str, tuple[Any, Any]] = {}
|
|
41
|
+
|
|
42
|
+
def _clone(self) -> AsyncQuerySet[ModelT]:
|
|
43
|
+
clone = AsyncQuerySet(self._client, self._path, self._model, bind=self._bind)
|
|
44
|
+
clone._filters = dict(self._filters)
|
|
45
|
+
clone._sort_fields = list(self._sort_fields)
|
|
46
|
+
clone._page_size = self._page_size
|
|
47
|
+
clone._ranges = dict(self._ranges)
|
|
48
|
+
return clone
|
|
49
|
+
|
|
50
|
+
def filter(self, **kwargs: Any) -> AsyncQuerySet[ModelT]:
|
|
51
|
+
"""Add ``filter[field]=value`` constraints. Chainable, non-mutating."""
|
|
52
|
+
clone = self._clone()
|
|
53
|
+
clone._filters.update(kwargs)
|
|
54
|
+
return clone
|
|
55
|
+
|
|
56
|
+
def sort(self, *fields: str) -> AsyncQuerySet[ModelT]:
|
|
57
|
+
"""Set the ``sort`` param, e.g. ``.sort('-level', 'login')``."""
|
|
58
|
+
clone = self._clone()
|
|
59
|
+
clone._sort_fields = list(fields)
|
|
60
|
+
return clone
|
|
61
|
+
|
|
62
|
+
def page_size(self, n: int) -> AsyncQuerySet[ModelT]:
|
|
63
|
+
clone = self._clone()
|
|
64
|
+
clone._page_size = n
|
|
65
|
+
return clone
|
|
66
|
+
|
|
67
|
+
def range(self, field: str, start: Any, end: Any) -> AsyncQuerySet[ModelT]:
|
|
68
|
+
clone = self._clone()
|
|
69
|
+
clone._ranges[field] = (start, end)
|
|
70
|
+
return clone
|
|
71
|
+
|
|
72
|
+
def _initial_params(self) -> dict[str, str]:
|
|
73
|
+
return build_query_params(
|
|
74
|
+
filters=self._filters,
|
|
75
|
+
sort=self._sort_fields,
|
|
76
|
+
page_size=self._page_size,
|
|
77
|
+
ranges=self._ranges,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
async def __aiter__(self) -> AsyncIterator[ModelT]:
|
|
81
|
+
url: str | None = self._path
|
|
82
|
+
params: dict[str, str] | None = self._initial_params()
|
|
83
|
+
while url is not None:
|
|
84
|
+
response = await self._client.request("GET", url, params=params)
|
|
85
|
+
for item in response.json():
|
|
86
|
+
instance = self._model.model_validate(item)
|
|
87
|
+
if self._bind is not None:
|
|
88
|
+
self._bind(instance)
|
|
89
|
+
yield instance
|
|
90
|
+
url = parse_link_header(response.headers.get("Link")).get("next")
|
|
91
|
+
params = None # the next URL already carries its full query string
|
|
92
|
+
|
|
93
|
+
async def all(self) -> list[ModelT]:
|
|
94
|
+
return [item async for item in self]
|
|
95
|
+
|
|
96
|
+
async def first(self) -> ModelT | None:
|
|
97
|
+
async for item in self:
|
|
98
|
+
return item
|
|
99
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Generic resource base class shared by all async resource managers.
|
|
2
|
+
|
|
3
|
+
Concrete resources (``AsyncUsersResource``, ``AsyncCampusesResource``, ...)
|
|
4
|
+
are thin subclasses that just set ``path`` and ``model``. ``.filter()``,
|
|
5
|
+
``.sort()``, ``.all()`` etc. delegate to :class:`AsyncQuerySet`, and the
|
|
6
|
+
resource itself is iterable (``async for x in client.users``) for the
|
|
7
|
+
unfiltered, unsorted case.
|
|
8
|
+
|
|
9
|
+
Resources whose model exposes nested sub-resources (e.g. ``User.events``,
|
|
10
|
+
``Campus.users``) override ``_bind_relations()`` to attach lazy accessor
|
|
11
|
+
factories to each freshly parsed instance.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import AsyncIterator
|
|
17
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar, cast
|
|
18
|
+
|
|
19
|
+
from ...models.base import FortyTwoModel
|
|
20
|
+
from ..query import AsyncQuerySet
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from ..client import AsyncClient
|
|
24
|
+
|
|
25
|
+
ModelT = TypeVar("ModelT", bound=FortyTwoModel)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AsyncResource(Generic[ModelT]):
|
|
29
|
+
path: ClassVar[str]
|
|
30
|
+
model: ClassVar[type[FortyTwoModel]]
|
|
31
|
+
|
|
32
|
+
def __init__(self, client: AsyncClient) -> None:
|
|
33
|
+
self._client = client
|
|
34
|
+
|
|
35
|
+
def _bind_relations(self, instance: ModelT) -> None:
|
|
36
|
+
"""Attach nested-resource accessors to a freshly parsed instance.
|
|
37
|
+
|
|
38
|
+
No-op by default. Applied to instances from both ``.get()`` and
|
|
39
|
+
queryset iteration, so relations work the same either way.
|
|
40
|
+
"""
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
async def get(self, id: int | str) -> ModelT:
|
|
44
|
+
"""Fetch a single resource by id: ``GET {path}/{id}``."""
|
|
45
|
+
response = await self._client.request("GET", f"{self.path}/{id}")
|
|
46
|
+
instance = cast(ModelT, self.model.model_validate(response.json()))
|
|
47
|
+
self._bind_relations(instance)
|
|
48
|
+
return instance
|
|
49
|
+
|
|
50
|
+
def _queryset(self, path: str | None = None) -> AsyncQuerySet[ModelT]:
|
|
51
|
+
return AsyncQuerySet(
|
|
52
|
+
self._client,
|
|
53
|
+
path or self.path,
|
|
54
|
+
cast(type[ModelT], self.model),
|
|
55
|
+
bind=self._bind_relations,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def filter(self, **kwargs: Any) -> AsyncQuerySet[ModelT]:
|
|
59
|
+
return self._queryset().filter(**kwargs)
|
|
60
|
+
|
|
61
|
+
def sort(self, *fields: str) -> AsyncQuerySet[ModelT]:
|
|
62
|
+
return self._queryset().sort(*fields)
|
|
63
|
+
|
|
64
|
+
def page_size(self, n: int) -> AsyncQuerySet[ModelT]:
|
|
65
|
+
return self._queryset().page_size(n)
|
|
66
|
+
|
|
67
|
+
def range(self, field: str, start: Any, end: Any) -> AsyncQuerySet[ModelT]:
|
|
68
|
+
return self._queryset().range(field, start, end)
|
|
69
|
+
|
|
70
|
+
async def all(self) -> list[ModelT]:
|
|
71
|
+
return await self._queryset().all()
|
|
72
|
+
|
|
73
|
+
async def first(self) -> ModelT | None:
|
|
74
|
+
return await self._queryset().first()
|
|
75
|
+
|
|
76
|
+
def __aiter__(self) -> AsyncIterator[ModelT]:
|
|
77
|
+
return self._queryset().__aiter__()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ...models.campus import Campus
|
|
4
|
+
from .base import AsyncResource
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AsyncCampusesResource(AsyncResource[Campus]):
|
|
8
|
+
path = "/campus"
|
|
9
|
+
model = Campus
|
|
10
|
+
|
|
11
|
+
def _bind_relations(self, instance: Campus) -> None:
|
|
12
|
+
instance._bind_relation(
|
|
13
|
+
"users",
|
|
14
|
+
lambda: self._client.users._queryset(f"/campus/{instance.id}/users"),
|
|
15
|
+
)
|
|
16
|
+
instance._bind_relation(
|
|
17
|
+
"events",
|
|
18
|
+
lambda: self._client.events._queryset(f"/campus/{instance.id}/events"),
|
|
19
|
+
)
|
|
20
|
+
instance._bind_relation(
|
|
21
|
+
"locations",
|
|
22
|
+
lambda: self._client.locations._queryset(f"/campus/{instance.id}/locations"),
|
|
23
|
+
)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ...models.location import Location
|
|
4
|
+
from .base import AsyncResource
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AsyncLocationsResource(AsyncResource[Location]):
|
|
8
|
+
path = "/locations"
|
|
9
|
+
model = Location
|
|
10
|
+
|
|
11
|
+
async def graph(
|
|
12
|
+
self, *, field: str | None = None, interval: str | None = None
|
|
13
|
+
) -> dict[str, int]:
|
|
14
|
+
"""Grouped temporal counts (``GET /locations/graph...``).
|
|
15
|
+
|
|
16
|
+
Not a paginated list of ``Location`` objects — returns a raw
|
|
17
|
+
``{date_string: count}`` mapping, counting occurrences of `field`
|
|
18
|
+
(default ``begin_at``) bucketed by `interval` (default
|
|
19
|
+
``month_of_year``) from the first occurrence to now.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
path = "/locations/graph"
|
|
23
|
+
if interval is not None and field is None:
|
|
24
|
+
field = "begin_at"
|
|
25
|
+
if field is not None:
|
|
26
|
+
path += f"/on/{field}"
|
|
27
|
+
if interval is not None:
|
|
28
|
+
path += f"/by/{interval}"
|
|
29
|
+
response = await self._client.request("GET", path)
|
|
30
|
+
return response.json()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ...models.user import User
|
|
4
|
+
from .base import AsyncResource
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AsyncUsersResource(AsyncResource[User]):
|
|
8
|
+
path = "/users"
|
|
9
|
+
model = User
|
|
10
|
+
|
|
11
|
+
def _bind_relations(self, instance: User) -> None:
|
|
12
|
+
instance._bind_relation(
|
|
13
|
+
"events",
|
|
14
|
+
lambda: self._client.events._queryset(f"/users/{instance.id}/events"),
|
|
15
|
+
)
|
|
16
|
+
instance._bind_relation(
|
|
17
|
+
"campus_users",
|
|
18
|
+
lambda: self._client.campus_users._queryset(f"/users/{instance.id}/campus_users"),
|
|
19
|
+
)
|
|
20
|
+
instance._bind_relation(
|
|
21
|
+
"locations",
|
|
22
|
+
lambda: self._client.locations._queryset(f"/users/{instance.id}/locations"),
|
|
23
|
+
)
|