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.
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)}
@@ -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
@@ -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