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
intra42/_auth.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""OAuth2 client-credentials token acquisition and caching.
|
|
2
|
+
|
|
3
|
+
:class:`TokenManager` is shared, mutable state used from both the sync and
|
|
4
|
+
async clients. Both paths guard the same cached-token state with a lock and use
|
|
5
|
+
double-checked locking so concurrent callers queued on the lock don't each
|
|
6
|
+
trigger a redundant refresh.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from .exceptions import AuthenticationError
|
|
18
|
+
|
|
19
|
+
TOKEN_URL = "https://api.intra.42.fr/oauth/token"
|
|
20
|
+
|
|
21
|
+
DEFAULT_LEEWAY = 60.0 # Refresh before expire
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TokenManager:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
client_id: str,
|
|
28
|
+
client_secret: str,
|
|
29
|
+
*,
|
|
30
|
+
token_url: str = TOKEN_URL,
|
|
31
|
+
leeway: float = DEFAULT_LEEWAY,
|
|
32
|
+
) -> None:
|
|
33
|
+
self._client_id = client_id
|
|
34
|
+
self._client_secret = client_secret
|
|
35
|
+
self._token_url = token_url
|
|
36
|
+
self._leeway = leeway
|
|
37
|
+
|
|
38
|
+
self._access_token: str | None = None
|
|
39
|
+
self._expires_at: float = 0.0
|
|
40
|
+
|
|
41
|
+
self._lock = threading.Lock()
|
|
42
|
+
self._alock = asyncio.Lock()
|
|
43
|
+
|
|
44
|
+
def _is_valid(self) -> bool:
|
|
45
|
+
return self._access_token is not None and time.time() < self._expires_at - self._leeway
|
|
46
|
+
|
|
47
|
+
def _body(self) -> dict[str, str]:
|
|
48
|
+
return {
|
|
49
|
+
"grant_type": "client_credentials",
|
|
50
|
+
"client_id": self._client_id,
|
|
51
|
+
"client_secret": self._client_secret,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
def _store(self, payload: dict[str, object]) -> str:
|
|
55
|
+
token = payload.get("access_token")
|
|
56
|
+
if not isinstance(token, str):
|
|
57
|
+
raise AuthenticationError(
|
|
58
|
+
"Token endpoint response did not include an access_token",
|
|
59
|
+
)
|
|
60
|
+
expires_in = payload.get("expires_in", 7200)
|
|
61
|
+
try:
|
|
62
|
+
expires_in = float(expires_in) # type: ignore[arg-type]
|
|
63
|
+
except (TypeError, ValueError):
|
|
64
|
+
expires_in = 7200.0
|
|
65
|
+
self._access_token = token
|
|
66
|
+
self._expires_at = time.time() + expires_in
|
|
67
|
+
return token
|
|
68
|
+
|
|
69
|
+
def ensure_token(self, http: httpx.Client) -> str:
|
|
70
|
+
if self._is_valid():
|
|
71
|
+
return self._access_token # type: ignore[return-value]
|
|
72
|
+
with self._lock:
|
|
73
|
+
if self._is_valid():
|
|
74
|
+
return self._access_token # type: ignore[return-value]
|
|
75
|
+
try:
|
|
76
|
+
response = http.post(self._token_url, data=self._body())
|
|
77
|
+
except httpx.TransportError as exc:
|
|
78
|
+
raise AuthenticationError(f"Failed to reach token endpoint: {exc}") from exc
|
|
79
|
+
if response.status_code != 200:
|
|
80
|
+
raise AuthenticationError(
|
|
81
|
+
f"Token endpoint rejected credentials (status {response.status_code})",
|
|
82
|
+
status_code=response.status_code,
|
|
83
|
+
response=response,
|
|
84
|
+
)
|
|
85
|
+
return self._store(response.json())
|
|
86
|
+
|
|
87
|
+
async def aensure_token(self, http: httpx.AsyncClient) -> str:
|
|
88
|
+
if self._is_valid():
|
|
89
|
+
return self._access_token # type: ignore[return-value]
|
|
90
|
+
async with self._alock:
|
|
91
|
+
if self._is_valid():
|
|
92
|
+
return self._access_token # type: ignore[return-value]
|
|
93
|
+
try:
|
|
94
|
+
response = await http.post(self._token_url, data=self._body())
|
|
95
|
+
except httpx.TransportError as exc:
|
|
96
|
+
raise AuthenticationError(f"Failed to reach token endpoint: {exc}") from exc
|
|
97
|
+
if response.status_code != 200:
|
|
98
|
+
raise AuthenticationError(
|
|
99
|
+
f"Token endpoint rejected credentials (status {response.status_code})",
|
|
100
|
+
status_code=response.status_code,
|
|
101
|
+
response=response,
|
|
102
|
+
)
|
|
103
|
+
return self._store(response.json())
|
intra42/_config.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Client configuration knobs, shared by the sync and async clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
DEFAULT_BASE_URL = "https://api.intra.42.fr/v2"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True)
|
|
11
|
+
class ClientConfig:
|
|
12
|
+
base_url: str = DEFAULT_BASE_URL
|
|
13
|
+
timeout: float = 10.0
|
|
14
|
+
max_retries: int = 3
|
|
15
|
+
rate: float = 2.0
|
|
16
|
+
burst: int = 2
|
|
17
|
+
hourly_quota: int = 1200
|
intra42/_pagination.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Pure helper for parsing RFC 5988 ``Link`` headers.
|
|
2
|
+
|
|
3
|
+
42's API paginates list endpoints and advertises the next/first/last/prev
|
|
4
|
+
page URLs via a standard ``Link`` header:
|
|
5
|
+
<https://api.intra.42.fr/v2/users?page=2>; rel="next",
|
|
6
|
+
<https://api.intra.42.fr/v2/users?page=42>; rel="last"
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
_LINK_RE = re.compile(r'<([^>]+)>\s*;\s*rel="([^"]+)"')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_link_header(header_value: str | None) -> dict[str, str]:
|
|
17
|
+
"""Parse a ``Link`` header into a ``{rel: url}`` dict.
|
|
18
|
+
|
|
19
|
+
Returns an empty dict for ``None`` or malformed input.
|
|
20
|
+
"""
|
|
21
|
+
if not header_value:
|
|
22
|
+
return {}
|
|
23
|
+
return {rel: url for url, rel in _LINK_RE.findall(header_value)}
|
intra42/_query_params.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Pure helpers for building 42 API query strings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _stringify(value: Any) -> str:
|
|
10
|
+
if isinstance(value, bool):
|
|
11
|
+
return "true" if value else "false"
|
|
12
|
+
return str(value)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _join(value: Any) -> str:
|
|
16
|
+
if isinstance(value, (list, tuple, set)):
|
|
17
|
+
return ",".join(_stringify(v) for v in value)
|
|
18
|
+
return _stringify(value)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_query_params(
|
|
22
|
+
*,
|
|
23
|
+
filters: Mapping[str, Any] | None = None,
|
|
24
|
+
sort: Sequence[str] | None = None,
|
|
25
|
+
page_size: int | None = None,
|
|
26
|
+
page_number: int | None = None,
|
|
27
|
+
ranges: Mapping[str, tuple[Any, Any]] | None = None,
|
|
28
|
+
) -> dict[str, str]:
|
|
29
|
+
"""Assemble a flat ``{param_name: value}`` dict ready to pass to httpx.
|
|
30
|
+
|
|
31
|
+
- ``filters={"campus_id": 1}`` -> ``{"filter[campus_id]": "1"}``
|
|
32
|
+
- ``filters={"campus_id": [1, 2]}`` -> ``{"filter[campus_id]": "1,2"}``
|
|
33
|
+
- ``sort=["-level", "login"]`` -> ``{"sort": "-level,login"}``
|
|
34
|
+
- ``page_size=30`` -> ``{"page[size]": "30"}``
|
|
35
|
+
- ``ranges={"id": (1, 100)}`` -> ``{"range[id]": "1,100"}``
|
|
36
|
+
"""
|
|
37
|
+
params: dict[str, str] = {}
|
|
38
|
+
|
|
39
|
+
for key, value in (filters or {}).items():
|
|
40
|
+
params[f"filter[{key}]"] = _join(value)
|
|
41
|
+
|
|
42
|
+
if sort:
|
|
43
|
+
params["sort"] = ",".join(sort)
|
|
44
|
+
|
|
45
|
+
if page_size is not None:
|
|
46
|
+
params["page[size]"] = str(page_size)
|
|
47
|
+
|
|
48
|
+
if page_number is not None:
|
|
49
|
+
params["page[number]"] = str(page_number)
|
|
50
|
+
|
|
51
|
+
for key, (start, end) in (ranges or {}).items():
|
|
52
|
+
params[f"range[{key}]"] = f"{_stringify(start)},{_stringify(end)}"
|
|
53
|
+
|
|
54
|
+
return params
|
intra42/_rate_limit.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Automatic request pacing to stay under the 42 API's rate limits.
|
|
2
|
+
|
|
3
|
+
Combines a token bucket (per-second pacing) with an hourly quota counter and
|
|
4
|
+
a ``Retry-After`` cooldown gate shared by both the sync and async
|
|
5
|
+
acquire paths.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
|
|
15
|
+
DEFAULT_RATE = 2.0 # requests per second
|
|
16
|
+
DEFAULT_BURST = 2 # bucket capacity
|
|
17
|
+
DEFAULT_HOURLY_QUOTA = 1200
|
|
18
|
+
HOUR_SECONDS = 3600.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RateLimiter:
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
rate: float = DEFAULT_RATE,
|
|
26
|
+
burst: int = DEFAULT_BURST,
|
|
27
|
+
hourly_quota: int = DEFAULT_HOURLY_QUOTA,
|
|
28
|
+
monotonic: Callable[[], float] = time.monotonic,
|
|
29
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
30
|
+
) -> None:
|
|
31
|
+
|
|
32
|
+
self._rate = rate
|
|
33
|
+
self._capacity = float(burst)
|
|
34
|
+
self._hourly_quota = hourly_quota
|
|
35
|
+
self._monotonic = monotonic
|
|
36
|
+
self._sleep = sleep
|
|
37
|
+
|
|
38
|
+
now = monotonic()
|
|
39
|
+
self._tokens = self._capacity
|
|
40
|
+
self._last_refill = now
|
|
41
|
+
|
|
42
|
+
self._hour_window_start = now
|
|
43
|
+
self._hour_count = 0
|
|
44
|
+
|
|
45
|
+
self._retry_after_until = 0.0
|
|
46
|
+
|
|
47
|
+
self._lock = threading.Lock()
|
|
48
|
+
self._alock = asyncio.Lock()
|
|
49
|
+
|
|
50
|
+
def _refill(self, now: float) -> None:
|
|
51
|
+
elapsed = now - self._last_refill
|
|
52
|
+
if elapsed > 0:
|
|
53
|
+
self._tokens = min(self._capacity, self._tokens + elapsed * self._rate)
|
|
54
|
+
self._last_refill = now
|
|
55
|
+
|
|
56
|
+
if now - self._hour_window_start >= HOUR_SECONDS:
|
|
57
|
+
self._hour_window_start = now
|
|
58
|
+
self._hour_count = 0
|
|
59
|
+
|
|
60
|
+
def _compute_wait(self, now: float) -> float:
|
|
61
|
+
self._refill(now)
|
|
62
|
+
|
|
63
|
+
waits = [0.0]
|
|
64
|
+
|
|
65
|
+
if self._tokens < 1.0:
|
|
66
|
+
waits.append((1.0 - self._tokens) / self._rate)
|
|
67
|
+
|
|
68
|
+
if self._hour_count >= self._hourly_quota:
|
|
69
|
+
waits.append(self._hour_window_start + HOUR_SECONDS - now)
|
|
70
|
+
|
|
71
|
+
if self._retry_after_until > now:
|
|
72
|
+
waits.append(self._retry_after_until - now)
|
|
73
|
+
|
|
74
|
+
return max(waits)
|
|
75
|
+
|
|
76
|
+
def _consume(self) -> None:
|
|
77
|
+
# Called immediately after the wait, while still holding the lock.
|
|
78
|
+
now = self._monotonic()
|
|
79
|
+
self._refill(now)
|
|
80
|
+
self._tokens = max(0.0, self._tokens - 1.0)
|
|
81
|
+
self._hour_count += 1
|
|
82
|
+
|
|
83
|
+
# No lock needed: monotonically extending a float is safe to race,
|
|
84
|
+
# and only ever makes the next acquire() wait *more*, never less.
|
|
85
|
+
def notify_retry_after(self, seconds: float) -> None:
|
|
86
|
+
until = self._monotonic() + max(0.0, seconds)
|
|
87
|
+
if until > self._retry_after_until:
|
|
88
|
+
self._retry_after_until = until
|
|
89
|
+
|
|
90
|
+
def acquire(self) -> None:
|
|
91
|
+
with self._lock:
|
|
92
|
+
wait = self._compute_wait(self._monotonic())
|
|
93
|
+
if wait > 0:
|
|
94
|
+
self._sleep(wait)
|
|
95
|
+
self._consume()
|
|
96
|
+
|
|
97
|
+
async def aacquire(self) -> None:
|
|
98
|
+
async with self._alock:
|
|
99
|
+
wait = self._compute_wait(self._monotonic())
|
|
100
|
+
if wait > 0:
|
|
101
|
+
await asyncio.sleep(wait)
|
|
102
|
+
self._consume()
|
|
File without changes
|
intra42/_sync/client.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# GENERATED FILE — DO NOT EDIT BY HAND.
|
|
2
|
+
#
|
|
3
|
+
# Generated from the corresponding module under intra42._async by
|
|
4
|
+
# scripts/unasync_generate.py (via the `unasync` library). Edit the
|
|
5
|
+
# async source and re-run that script instead.
|
|
6
|
+
|
|
7
|
+
"""The async client, do not hand-maintain a parallel sync
|
|
8
|
+
implementation of the request logic here.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from .._auth import TokenManager
|
|
18
|
+
from .._config import DEFAULT_BASE_URL, ClientConfig
|
|
19
|
+
from .._rate_limit import RateLimiter
|
|
20
|
+
from ..exceptions import NetworkError, raise_for_status
|
|
21
|
+
from .resources.campus_users import CampusUsersResource
|
|
22
|
+
from .resources.campuses import CampusesResource
|
|
23
|
+
from .resources.events import EventsResource
|
|
24
|
+
from .resources.locations import LocationsResource
|
|
25
|
+
from .resources.users import UsersResource
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parse_retry_after(value: str | None) -> float:
|
|
29
|
+
"""Parse a ``Retry-After`` header value as seconds.
|
|
30
|
+
|
|
31
|
+
Assumed to be the integer-seconds form.
|
|
32
|
+
"""
|
|
33
|
+
if value is None:
|
|
34
|
+
return 1.0
|
|
35
|
+
try:
|
|
36
|
+
return max(0.0, float(value))
|
|
37
|
+
except ValueError:
|
|
38
|
+
return 1.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Client:
|
|
42
|
+
"""Async client for the 42 API, authenticated via OAuth2 client credentials."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
client_id: str,
|
|
47
|
+
client_secret: str,
|
|
48
|
+
*,
|
|
49
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
50
|
+
timeout: float = 10.0,
|
|
51
|
+
max_retries: int = 3,
|
|
52
|
+
config: ClientConfig | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
self._config = config or ClientConfig(
|
|
55
|
+
base_url=base_url, timeout=timeout, max_retries=max_retries
|
|
56
|
+
)
|
|
57
|
+
self._http = httpx.Client(timeout=self._config.timeout)
|
|
58
|
+
self._token_manager = TokenManager(client_id, client_secret)
|
|
59
|
+
self._rate_limiter = RateLimiter(
|
|
60
|
+
rate=self._config.rate,
|
|
61
|
+
burst=self._config.burst,
|
|
62
|
+
hourly_quota=self._config.hourly_quota,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
self.users = UsersResource(self)
|
|
66
|
+
self.campuses = CampusesResource(self)
|
|
67
|
+
self.campus_users = CampusUsersResource(self)
|
|
68
|
+
self.events = EventsResource(self)
|
|
69
|
+
self.locations = LocationsResource(self)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def base_url(self) -> str:
|
|
73
|
+
return self._config.base_url
|
|
74
|
+
|
|
75
|
+
def _build_url(self, path: str) -> str:
|
|
76
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
77
|
+
return path
|
|
78
|
+
return f"{self._config.base_url}{path}"
|
|
79
|
+
|
|
80
|
+
def request(
|
|
81
|
+
self,
|
|
82
|
+
method: str,
|
|
83
|
+
path: str,
|
|
84
|
+
*,
|
|
85
|
+
params: dict[str, Any] | None = None,
|
|
86
|
+
json: dict[str, Any] | None = None,
|
|
87
|
+
) -> httpx.Response:
|
|
88
|
+
"""Send one authenticated, rate-limited, error-mapped request.
|
|
89
|
+
|
|
90
|
+
Retries on 429 up to ``max_retries`` times, honoring the response's
|
|
91
|
+
``Retry-After`` header via the shared rate limiter's cooldown gate,
|
|
92
|
+
before raising :class:`intra42.exceptions.RateLimitError`.
|
|
93
|
+
"""
|
|
94
|
+
url = self._build_url(path)
|
|
95
|
+
response: httpx.Response | None = None
|
|
96
|
+
|
|
97
|
+
for attempt in range(self._config.max_retries + 1):
|
|
98
|
+
token = self._token_manager.ensure_token(self._http)
|
|
99
|
+
self._rate_limiter.acquire()
|
|
100
|
+
try:
|
|
101
|
+
response = self._http.request(
|
|
102
|
+
method,
|
|
103
|
+
url,
|
|
104
|
+
params=params,
|
|
105
|
+
json=json,
|
|
106
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
107
|
+
)
|
|
108
|
+
except httpx.TransportError as exc:
|
|
109
|
+
raise NetworkError(f"Request to {url} failed: {exc}") from exc
|
|
110
|
+
|
|
111
|
+
if response.status_code == 429:
|
|
112
|
+
self._rate_limiter.notify_retry_after(
|
|
113
|
+
_parse_retry_after(response.headers.get("Retry-After"))
|
|
114
|
+
)
|
|
115
|
+
if attempt < self._config.max_retries:
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
raise_for_status(response)
|
|
119
|
+
return response
|
|
120
|
+
|
|
121
|
+
assert response is not None # loop always runs at least once
|
|
122
|
+
raise_for_status(response)
|
|
123
|
+
return response # pragma: no cover - raise_for_status always raises on a 429
|
|
124
|
+
|
|
125
|
+
def close(self) -> None:
|
|
126
|
+
self._http.close()
|
|
127
|
+
|
|
128
|
+
def __enter__(self) -> Client:
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
132
|
+
self.close()
|
intra42/_sync/query.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# GENERATED FILE — DO NOT EDIT BY HAND.
|
|
2
|
+
#
|
|
3
|
+
# Generated from the corresponding module under intra42._async by
|
|
4
|
+
# scripts/unasync_generate.py (via the `unasync` library). Edit the
|
|
5
|
+
# async source and re-run that script instead.
|
|
6
|
+
|
|
7
|
+
"""Lazy, chainable, auto-paginating query builder.
|
|
8
|
+
|
|
9
|
+
``AsyncQuerySet`` is returned by a resource's ``.filter()``/``.sort()``/etc.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Callable, Iterator
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
16
|
+
|
|
17
|
+
from .._pagination import parse_link_header
|
|
18
|
+
from .._query_params import build_query_params
|
|
19
|
+
from ..models.base import FortyTwoModel
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from .client import Client
|
|
23
|
+
|
|
24
|
+
ModelT = TypeVar("ModelT", bound=FortyTwoModel)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class QuerySet(Generic[ModelT]):
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
client: Client,
|
|
31
|
+
path: str,
|
|
32
|
+
model: type[ModelT],
|
|
33
|
+
*,
|
|
34
|
+
bind: Callable[[ModelT], None] | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
self._client = client
|
|
37
|
+
self._path = path
|
|
38
|
+
self._model = model
|
|
39
|
+
# Set by the owning resource to attach nested-resource accessors
|
|
40
|
+
# (e.g. User.events) to each instance as it's parsed. See
|
|
41
|
+
# AsyncResource._bind_relations / FortyTwoModel._bind_relation.
|
|
42
|
+
self._bind = bind
|
|
43
|
+
self._filters: dict[str, Any] = {}
|
|
44
|
+
self._sort_fields: list[str] = []
|
|
45
|
+
self._page_size: int | None = None
|
|
46
|
+
self._ranges: dict[str, tuple[Any, Any]] = {}
|
|
47
|
+
|
|
48
|
+
def _clone(self) -> QuerySet[ModelT]:
|
|
49
|
+
clone = QuerySet(self._client, self._path, self._model, bind=self._bind)
|
|
50
|
+
clone._filters = dict(self._filters)
|
|
51
|
+
clone._sort_fields = list(self._sort_fields)
|
|
52
|
+
clone._page_size = self._page_size
|
|
53
|
+
clone._ranges = dict(self._ranges)
|
|
54
|
+
return clone
|
|
55
|
+
|
|
56
|
+
def filter(self, **kwargs: Any) -> QuerySet[ModelT]:
|
|
57
|
+
"""Add ``filter[field]=value`` constraints. Chainable, non-mutating."""
|
|
58
|
+
clone = self._clone()
|
|
59
|
+
clone._filters.update(kwargs)
|
|
60
|
+
return clone
|
|
61
|
+
|
|
62
|
+
def sort(self, *fields: str) -> QuerySet[ModelT]:
|
|
63
|
+
"""Set the ``sort`` param, e.g. ``.sort('-level', 'login')``."""
|
|
64
|
+
clone = self._clone()
|
|
65
|
+
clone._sort_fields = list(fields)
|
|
66
|
+
return clone
|
|
67
|
+
|
|
68
|
+
def page_size(self, n: int) -> QuerySet[ModelT]:
|
|
69
|
+
clone = self._clone()
|
|
70
|
+
clone._page_size = n
|
|
71
|
+
return clone
|
|
72
|
+
|
|
73
|
+
def range(self, field: str, start: Any, end: Any) -> QuerySet[ModelT]:
|
|
74
|
+
clone = self._clone()
|
|
75
|
+
clone._ranges[field] = (start, end)
|
|
76
|
+
return clone
|
|
77
|
+
|
|
78
|
+
def _initial_params(self) -> dict[str, str]:
|
|
79
|
+
return build_query_params(
|
|
80
|
+
filters=self._filters,
|
|
81
|
+
sort=self._sort_fields,
|
|
82
|
+
page_size=self._page_size,
|
|
83
|
+
ranges=self._ranges,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def __iter__(self) -> Iterator[ModelT]:
|
|
87
|
+
url: str | None = self._path
|
|
88
|
+
params: dict[str, str] | None = self._initial_params()
|
|
89
|
+
while url is not None:
|
|
90
|
+
response = self._client.request("GET", url, params=params)
|
|
91
|
+
for item in response.json():
|
|
92
|
+
instance = self._model.model_validate(item)
|
|
93
|
+
if self._bind is not None:
|
|
94
|
+
self._bind(instance)
|
|
95
|
+
yield instance
|
|
96
|
+
url = parse_link_header(response.headers.get("Link")).get("next")
|
|
97
|
+
params = None # the next URL already carries its full query string
|
|
98
|
+
|
|
99
|
+
def all(self) -> list[ModelT]:
|
|
100
|
+
return [item for item in self]
|
|
101
|
+
|
|
102
|
+
def first(self) -> ModelT | None:
|
|
103
|
+
for item in self:
|
|
104
|
+
return item
|
|
105
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# GENERATED FILE — DO NOT EDIT BY HAND.
|
|
2
|
+
#
|
|
3
|
+
# Generated from the corresponding module under intra42._async by
|
|
4
|
+
# scripts/unasync_generate.py (via the `unasync` library). Edit the
|
|
5
|
+
# async source and re-run that script instead.
|
|
6
|
+
|
|
7
|
+
"""Generic resource base class shared by all async resource managers.
|
|
8
|
+
|
|
9
|
+
Concrete resources (``AsyncUsersResource``, ``AsyncCampusesResource``, ...)
|
|
10
|
+
are thin subclasses that just set ``path`` and ``model``. ``.filter()``,
|
|
11
|
+
``.sort()``, ``.all()`` etc. delegate to :class:`AsyncQuerySet`, and the
|
|
12
|
+
resource itself is iterable (``async for x in client.users``) for the
|
|
13
|
+
unfiltered, unsorted case.
|
|
14
|
+
|
|
15
|
+
Resources whose model exposes nested sub-resources (e.g. ``User.events``,
|
|
16
|
+
``Campus.users``) override ``_bind_relations()`` to attach lazy accessor
|
|
17
|
+
factories to each freshly parsed instance.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections.abc import Iterator
|
|
23
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar, cast
|
|
24
|
+
|
|
25
|
+
from ...models.base import FortyTwoModel
|
|
26
|
+
from ..query import QuerySet
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from ..client import Client
|
|
30
|
+
|
|
31
|
+
ModelT = TypeVar("ModelT", bound=FortyTwoModel)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Resource(Generic[ModelT]):
|
|
35
|
+
path: ClassVar[str]
|
|
36
|
+
model: ClassVar[type[FortyTwoModel]]
|
|
37
|
+
|
|
38
|
+
def __init__(self, client: Client) -> None:
|
|
39
|
+
self._client = client
|
|
40
|
+
|
|
41
|
+
def _bind_relations(self, instance: ModelT) -> None:
|
|
42
|
+
"""Attach nested-resource accessors to a freshly parsed instance.
|
|
43
|
+
|
|
44
|
+
No-op by default. Applied to instances from both ``.get()`` and
|
|
45
|
+
queryset iteration, so relations work the same either way.
|
|
46
|
+
"""
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
def get(self, id: int | str) -> ModelT:
|
|
50
|
+
"""Fetch a single resource by id: ``GET {path}/{id}``."""
|
|
51
|
+
response = self._client.request("GET", f"{self.path}/{id}")
|
|
52
|
+
instance = cast(ModelT, self.model.model_validate(response.json()))
|
|
53
|
+
self._bind_relations(instance)
|
|
54
|
+
return instance
|
|
55
|
+
|
|
56
|
+
def _queryset(self, path: str | None = None) -> QuerySet[ModelT]:
|
|
57
|
+
return QuerySet(
|
|
58
|
+
self._client,
|
|
59
|
+
path or self.path,
|
|
60
|
+
cast(type[ModelT], self.model),
|
|
61
|
+
bind=self._bind_relations,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def filter(self, **kwargs: Any) -> QuerySet[ModelT]:
|
|
65
|
+
return self._queryset().filter(**kwargs)
|
|
66
|
+
|
|
67
|
+
def sort(self, *fields: str) -> QuerySet[ModelT]:
|
|
68
|
+
return self._queryset().sort(*fields)
|
|
69
|
+
|
|
70
|
+
def page_size(self, n: int) -> QuerySet[ModelT]:
|
|
71
|
+
return self._queryset().page_size(n)
|
|
72
|
+
|
|
73
|
+
def range(self, field: str, start: Any, end: Any) -> QuerySet[ModelT]:
|
|
74
|
+
return self._queryset().range(field, start, end)
|
|
75
|
+
|
|
76
|
+
def all(self) -> list[ModelT]:
|
|
77
|
+
return self._queryset().all()
|
|
78
|
+
|
|
79
|
+
def first(self) -> ModelT | None:
|
|
80
|
+
return self._queryset().first()
|
|
81
|
+
|
|
82
|
+
def __iter__(self) -> Iterator[ModelT]:
|
|
83
|
+
return self._queryset().__iter__()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# GENERATED FILE — DO NOT EDIT BY HAND.
|
|
2
|
+
#
|
|
3
|
+
# Generated from the corresponding module under intra42._async by
|
|
4
|
+
# scripts/unasync_generate.py (via the `unasync` library). Edit the
|
|
5
|
+
# async source and re-run that script instead.
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ...models.campus_user import CampusUser
|
|
10
|
+
from .base import Resource
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CampusUsersResource(Resource[CampusUser]):
|
|
14
|
+
path = "/campus_users"
|
|
15
|
+
model = CampusUser
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# GENERATED FILE — DO NOT EDIT BY HAND.
|
|
2
|
+
#
|
|
3
|
+
# Generated from the corresponding module under intra42._async by
|
|
4
|
+
# scripts/unasync_generate.py (via the `unasync` library). Edit the
|
|
5
|
+
# async source and re-run that script instead.
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ...models.campus import Campus
|
|
10
|
+
from .base import Resource
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CampusesResource(Resource[Campus]):
|
|
14
|
+
path = "/campus"
|
|
15
|
+
model = Campus
|
|
16
|
+
|
|
17
|
+
def _bind_relations(self, instance: Campus) -> None:
|
|
18
|
+
instance._bind_relation(
|
|
19
|
+
"users",
|
|
20
|
+
lambda: self._client.users._queryset(f"/campus/{instance.id}/users"),
|
|
21
|
+
)
|
|
22
|
+
instance._bind_relation(
|
|
23
|
+
"events",
|
|
24
|
+
lambda: self._client.events._queryset(f"/campus/{instance.id}/events"),
|
|
25
|
+
)
|
|
26
|
+
instance._bind_relation(
|
|
27
|
+
"locations",
|
|
28
|
+
lambda: self._client.locations._queryset(f"/campus/{instance.id}/locations"),
|
|
29
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# GENERATED FILE — DO NOT EDIT BY HAND.
|
|
2
|
+
#
|
|
3
|
+
# Generated from the corresponding module under intra42._async by
|
|
4
|
+
# scripts/unasync_generate.py (via the `unasync` library). Edit the
|
|
5
|
+
# async source and re-run that script instead.
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ...models.event import Event
|
|
10
|
+
from .base import Resource
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EventsResource(Resource[Event]):
|
|
14
|
+
path = "/events"
|
|
15
|
+
model = Event
|