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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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
@@ -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()
@@ -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,9 @@
1
+ from __future__ import annotations
2
+
3
+ from ...models.campus_user import CampusUser
4
+ from .base import AsyncResource
5
+
6
+
7
+ class AsyncCampusUsersResource(AsyncResource[CampusUser]):
8
+ path = "/campus_users"
9
+ model = CampusUser
@@ -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,9 @@
1
+ from __future__ import annotations
2
+
3
+ from ...models.event import Event
4
+ from .base import AsyncResource
5
+
6
+
7
+ class AsyncEventsResource(AsyncResource[Event]):
8
+ path = "/events"
9
+ model = Event
@@ -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
+ )