42api 0.1.1__tar.gz → 0.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. {42api-0.1.1 → 42api-0.1.3}/.gitignore +4 -1
  2. {42api-0.1.1 → 42api-0.1.3}/PKG-INFO +9 -7
  3. {42api-0.1.1 → 42api-0.1.3}/README.md +8 -6
  4. {42api-0.1.1 → 42api-0.1.3}/pyproject.toml +1 -1
  5. {42api-0.1.1 → 42api-0.1.3}/src/intra42/__init__.py +10 -2
  6. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_async/client.py +8 -0
  7. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_async/query.py +26 -3
  8. 42api-0.1.3/src/intra42/_async/resources/accreditations.py +16 -0
  9. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_async/resources/cursus.py +4 -0
  10. 42api-0.1.3/src/intra42/_async/resources/exams.py +23 -0
  11. 42api-0.1.3/src/intra42/_async/resources/projects.py +19 -0
  12. 42api-0.1.3/src/intra42/_async/resources/projects_users.py +22 -0
  13. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_async/resources/users.py +4 -0
  14. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_sync/client.py +8 -0
  15. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_sync/query.py +26 -3
  16. 42api-0.1.3/src/intra42/_sync/resources/accreditations.py +22 -0
  17. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_sync/resources/cursus.py +4 -0
  18. 42api-0.1.3/src/intra42/_sync/resources/exams.py +27 -0
  19. 42api-0.1.3/src/intra42/_sync/resources/projects.py +25 -0
  20. 42api-0.1.3/src/intra42/_sync/resources/projects_users.py +26 -0
  21. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_sync/resources/users.py +4 -0
  22. 42api-0.1.3/src/intra42/models/accreditation.py +29 -0
  23. {42api-0.1.1 → 42api-0.1.3}/src/intra42/models/cursus.py +6 -0
  24. 42api-0.1.3/src/intra42/models/exam.py +32 -0
  25. 42api-0.1.3/src/intra42/models/project.py +106 -0
  26. 42api-0.1.3/src/intra42/models/project_user.py +74 -0
  27. {42api-0.1.1 → 42api-0.1.3}/src/intra42/models/user.py +6 -0
  28. 42api-0.1.3/tests/integration/resources/test_resources_accreditations.py +78 -0
  29. 42api-0.1.3/tests/integration/resources/test_resources_exams.py +159 -0
  30. 42api-0.1.3/tests/integration/resources/test_resources_projects.py +193 -0
  31. 42api-0.1.3/tests/integration/resources/test_resources_projects_users.py +176 -0
  32. 42api-0.1.3/tests/integration/test_pagination_walk.py +141 -0
  33. {42api-0.1.1 → 42api-0.1.3}/tests/unit/test_public_api.py +8 -0
  34. {42api-0.1.1 → 42api-0.1.3}/uv.lock +2 -2
  35. 42api-0.1.1/tests/integration/test_pagination_walk.py +0 -82
  36. {42api-0.1.1 → 42api-0.1.3}/.github/workflows/ci.yml +0 -0
  37. {42api-0.1.1 → 42api-0.1.3}/.github/workflows/publish.yml +0 -0
  38. {42api-0.1.1 → 42api-0.1.3}/.pre-commit-config.yaml +0 -0
  39. {42api-0.1.1 → 42api-0.1.3}/LICENSE +0 -0
  40. {42api-0.1.1 → 42api-0.1.3}/scripts/unasync_generate.py +0 -0
  41. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_async/__init__.py +0 -0
  42. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_async/resources/__init__.py +0 -0
  43. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_async/resources/base.py +0 -0
  44. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_async/resources/campus_users.py +0 -0
  45. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_async/resources/campuses.py +0 -0
  46. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_async/resources/events.py +0 -0
  47. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_async/resources/locations.py +0 -0
  48. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_auth.py +0 -0
  49. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_config.py +0 -0
  50. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_pagination.py +0 -0
  51. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_query_params.py +0 -0
  52. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_rate_limit.py +0 -0
  53. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_sync/__init__.py +0 -0
  54. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_sync/resources/__init__.py +0 -0
  55. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_sync/resources/base.py +0 -0
  56. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_sync/resources/campus_users.py +0 -0
  57. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_sync/resources/campuses.py +0 -0
  58. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_sync/resources/events.py +0 -0
  59. {42api-0.1.1 → 42api-0.1.3}/src/intra42/_sync/resources/locations.py +0 -0
  60. {42api-0.1.1 → 42api-0.1.3}/src/intra42/exceptions.py +0 -0
  61. {42api-0.1.1 → 42api-0.1.3}/src/intra42/models/__init__.py +0 -0
  62. {42api-0.1.1 → 42api-0.1.3}/src/intra42/models/base.py +0 -0
  63. {42api-0.1.1 → 42api-0.1.3}/src/intra42/models/campus.py +0 -0
  64. {42api-0.1.1 → 42api-0.1.3}/src/intra42/models/campus_user.py +0 -0
  65. {42api-0.1.1 → 42api-0.1.3}/src/intra42/models/event.py +0 -0
  66. {42api-0.1.1 → 42api-0.1.3}/src/intra42/models/location.py +0 -0
  67. {42api-0.1.1 → 42api-0.1.3}/src/intra42/py.typed +0 -0
  68. {42api-0.1.1 → 42api-0.1.3}/tests/conftest.py +0 -0
  69. {42api-0.1.1 → 42api-0.1.3}/tests/integration/resources/test_resources_campus_users.py +0 -0
  70. {42api-0.1.1 → 42api-0.1.3}/tests/integration/resources/test_resources_campuses.py +0 -0
  71. {42api-0.1.1 → 42api-0.1.3}/tests/integration/resources/test_resources_cursus.py +0 -0
  72. {42api-0.1.1 → 42api-0.1.3}/tests/integration/resources/test_resources_events.py +0 -0
  73. {42api-0.1.1 → 42api-0.1.3}/tests/integration/resources/test_resources_locations.py +0 -0
  74. {42api-0.1.1 → 42api-0.1.3}/tests/integration/test_client_integration.py +0 -0
  75. {42api-0.1.1 → 42api-0.1.3}/tests/integration/test_nested_access.py +0 -0
  76. {42api-0.1.1 → 42api-0.1.3}/tests/unit/test_auth.py +0 -0
  77. {42api-0.1.1 → 42api-0.1.3}/tests/unit/test_models_campus.py +0 -0
  78. {42api-0.1.1 → 42api-0.1.3}/tests/unit/test_models_user.py +0 -0
  79. {42api-0.1.1 → 42api-0.1.3}/tests/unit/test_pagination.py +0 -0
  80. {42api-0.1.1 → 42api-0.1.3}/tests/unit/test_query_builder.py +0 -0
  81. {42api-0.1.1 → 42api-0.1.3}/tests/unit/test_rate_limit.py +0 -0
@@ -218,4 +218,7 @@ __marimo__/
218
218
  # Streamlit
219
219
  .streamlit/secrets.toml
220
220
 
221
- assets/
221
+ assets/
222
+
223
+ # agent
224
+ .claude
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: 42api
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Object-oriented Python client for the 42 School API (api.intra.42.fr)
5
5
  Project-URL: Homepage, https://github.com/Shadowaker/42api
6
6
  Author-email: Dan <danyridolfo98@gmail.com>
@@ -55,7 +55,7 @@ with Client(client_id="...", client_secret="...") as client:
55
55
  user = client.users.get("jdoe")
56
56
  print(user.login, user.email)
57
57
 
58
- for user in client.users.filter(campus_id=1).sort("-level"):
58
+ for user in client.users.filter(primary_campus_id=1).sort("-pool_year"):
59
59
  print(user.login)
60
60
  ```
61
61
 
@@ -69,7 +69,7 @@ from intra42 import AsyncClient
69
69
  async def main() -> None:
70
70
  async with AsyncClient(client_id="...", client_secret="...") as client:
71
71
  user = await client.users.get("jdoe")
72
- async for user in client.users.filter(campus_id=1).sort("-level"):
72
+ async for user in client.users.filter(primary_campus_id=1).sort("-pool_year"):
73
73
  print(user.login)
74
74
 
75
75
 
@@ -108,10 +108,12 @@ and `NetworkError` (connection/timeout failures).
108
108
 
109
109
  ## Status
110
110
 
111
- Early-stage: currently covers `users`, `campuses`, `campus_users`,
112
- `cursus`, `events`, and `locations` (including its `graph` analytics
113
- endpoint). More resources are added incrementally on top of the same
114
- client/model/query-builder pattern.
111
+ Early-stage: currently covers `users`, `accreditations`, `campuses`,
112
+ `campus_users`, `cursus`, `events`, `exams`, `locations`, `projects`, and
113
+ `projects_users` (`exams`, `locations`, and `projects_users` each include
114
+ their `graph` analytics endpoint). More resources are added incrementally
115
+ on top of the same client/model/query-builder pattern.
116
+ The api with restricted access are not implemented currently.
115
117
 
116
118
  ## Development
117
119
 
@@ -29,7 +29,7 @@ with Client(client_id="...", client_secret="...") as client:
29
29
  user = client.users.get("jdoe")
30
30
  print(user.login, user.email)
31
31
 
32
- for user in client.users.filter(campus_id=1).sort("-level"):
32
+ for user in client.users.filter(primary_campus_id=1).sort("-pool_year"):
33
33
  print(user.login)
34
34
  ```
35
35
 
@@ -43,7 +43,7 @@ from intra42 import AsyncClient
43
43
  async def main() -> None:
44
44
  async with AsyncClient(client_id="...", client_secret="...") as client:
45
45
  user = await client.users.get("jdoe")
46
- async for user in client.users.filter(campus_id=1).sort("-level"):
46
+ async for user in client.users.filter(primary_campus_id=1).sort("-pool_year"):
47
47
  print(user.login)
48
48
 
49
49
 
@@ -82,10 +82,12 @@ and `NetworkError` (connection/timeout failures).
82
82
 
83
83
  ## Status
84
84
 
85
- Early-stage: currently covers `users`, `campuses`, `campus_users`,
86
- `cursus`, `events`, and `locations` (including its `graph` analytics
87
- endpoint). More resources are added incrementally on top of the same
88
- client/model/query-builder pattern.
85
+ Early-stage: currently covers `users`, `accreditations`, `campuses`,
86
+ `campus_users`, `cursus`, `events`, `exams`, `locations`, `projects`, and
87
+ `projects_users` (`exams`, `locations`, and `projects_users` each include
88
+ their `graph` analytics endpoint). More resources are added incrementally
89
+ on top of the same client/model/query-builder pattern.
90
+ The api with restricted access are not implemented currently.
89
91
 
90
92
  ## Development
91
93
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "42api"
3
- version = "0.1.1"
3
+ version = "0.1.3"
4
4
  description = "Object-oriented Python client for the 42 School API (api.intra.42.fr)"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -4,7 +4,7 @@
4
4
 
5
5
  with Client(client_id, client_secret) as client:
6
6
  user = client.users.get("jdoe")
7
- for user in client.users.filter(campus_id=1).sort("-level"):
7
+ for user in client.users.filter(primary_campus_id=1).sort("-pool_year"):
8
8
  ...
9
9
 
10
10
  An async client with the same interface is available as ``AsyncClient``.
@@ -25,11 +25,15 @@ from .exceptions import (
25
25
  ServerError,
26
26
  ValidationError,
27
27
  )
28
+ from .models.accreditation import Accreditation
28
29
  from .models.campus import Campus
29
30
  from .models.campus_user import CampusUser
30
31
  from .models.cursus import Cursus
31
32
  from .models.event import Event
33
+ from .models.exam import Exam
32
34
  from .models.location import Location
35
+ from .models.project import Project
36
+ from .models.project_user import ProjectUser
33
37
  from .models.user import User
34
38
 
35
39
  __all__ = [
@@ -37,11 +41,15 @@ __all__ = [
37
41
  "Client",
38
42
  "ClientConfig",
39
43
  "User",
44
+ "Accreditation",
40
45
  "Campus",
41
46
  "CampusUser",
42
47
  "Cursus",
43
48
  "Event",
49
+ "Exam",
44
50
  "Location",
51
+ "Project",
52
+ "ProjectUser",
45
53
  "FortyTwoAPIError",
46
54
  "AuthenticationError",
47
55
  "PermissionDeniedError",
@@ -52,4 +60,4 @@ __all__ = [
52
60
  "NetworkError",
53
61
  ]
54
62
 
55
- __version__ = "0.1.1"
63
+ __version__ = "0.1.3"
@@ -12,11 +12,15 @@ from .._auth import TokenManager
12
12
  from .._config import DEFAULT_BASE_URL, ClientConfig
13
13
  from .._rate_limit import RateLimiter
14
14
  from ..exceptions import NetworkError, raise_for_status
15
+ from .resources.accreditations import AsyncAccreditationsResource
15
16
  from .resources.campus_users import AsyncCampusUsersResource
16
17
  from .resources.campuses import AsyncCampusesResource
17
18
  from .resources.cursus import AsyncCursusResource
18
19
  from .resources.events import AsyncEventsResource
20
+ from .resources.exams import AsyncExamsResource
19
21
  from .resources.locations import AsyncLocationsResource
22
+ from .resources.projects import AsyncProjectsResource
23
+ from .resources.projects_users import AsyncProjectUsersResource
20
24
  from .resources.users import AsyncUsersResource
21
25
 
22
26
 
@@ -58,11 +62,15 @@ class AsyncClient:
58
62
  )
59
63
 
60
64
  self.users = AsyncUsersResource(self)
65
+ self.accreditations = AsyncAccreditationsResource(self)
61
66
  self.campuses = AsyncCampusesResource(self)
62
67
  self.campus_users = AsyncCampusUsersResource(self)
63
68
  self.cursus = AsyncCursusResource(self)
64
69
  self.events = AsyncEventsResource(self)
70
+ self.exams = AsyncExamsResource(self)
65
71
  self.locations = AsyncLocationsResource(self)
72
+ self.projects = AsyncProjectsResource(self)
73
+ self.projects_users = AsyncProjectUsersResource(self)
66
74
 
67
75
  @property
68
76
  def base_url(self) -> str:
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  from collections.abc import AsyncIterator, Callable
9
9
  from typing import TYPE_CHECKING, Any, Generic, TypeVar
10
+ from urllib.parse import parse_qsl, urlsplit
10
11
 
11
12
  from .._pagination import parse_link_header
12
13
  from .._query_params import build_query_params
@@ -78,8 +79,20 @@ class AsyncQuerySet(Generic[ModelT]):
78
79
  )
79
80
 
80
81
  async def __aiter__(self) -> AsyncIterator[ModelT]:
82
+ # `base_params` (filter/sort/page_size/range) is re-applied on every
83
+ # page, not just the first. Confirmed against the live API: the
84
+ # `next` link's own query string always uses a *bare* `page=N`
85
+ # cursor — a different, incompatible pagination scheme from the
86
+ # bracketed `page[size]`/`page[number]` this library sends, and the
87
+ # server returns 400 if both appear on the same request. So rather
88
+ # than forwarding the link's query string as-is (which either loses
89
+ # page_size or actively errors once you try to keep it), we extract
90
+ # just the page *number* from the link and reissue it as
91
+ # `page[number]`, combined with our own persisted page_size/filter/
92
+ # sort/range.
93
+ base_params = self._initial_params()
81
94
  url: str | None = self._path
82
- params: dict[str, str] | None = self._initial_params()
95
+ params: dict[str, str] | None = base_params
83
96
  while url is not None:
84
97
  response = await self._client.request("GET", url, params=params)
85
98
  for item in response.json():
@@ -87,8 +100,18 @@ class AsyncQuerySet(Generic[ModelT]):
87
100
  if self._bind is not None:
88
101
  self._bind(instance)
89
102
  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
103
+
104
+ next_url = parse_link_header(response.headers.get("Link")).get("next")
105
+ if next_url is None:
106
+ break
107
+
108
+ split = urlsplit(next_url)
109
+ url = f"{split.scheme}://{split.netloc}{split.path}" if split.scheme else split.path
110
+ next_query = dict(parse_qsl(split.query))
111
+ page_number = next_query.pop("page[number]", None) or next_query.pop("page", None)
112
+ params = {**next_query, **base_params}
113
+ if page_number is not None:
114
+ params["page[number]"] = page_number
92
115
 
93
116
  async def all(self) -> list[ModelT]:
94
117
  return [item async for item in self]
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from ...models.accreditation import Accreditation
4
+ from .base import AsyncResource
5
+
6
+
7
+ class AsyncAccreditationsResource(AsyncResource[Accreditation]):
8
+ path = "/accreditations"
9
+ model = Accreditation
10
+
11
+ def _bind_relations(self, instance: Accreditation) -> None:
12
+ # Referenced in the users apidoc page: GET /accreditations/:id/users.
13
+ instance._bind_relation(
14
+ "users",
15
+ lambda: self._client.users._queryset(f"/accreditations/{instance.id}/users"),
16
+ )
@@ -17,3 +17,7 @@ class AsyncCursusResource(AsyncResource[Cursus]):
17
17
  "events",
18
18
  lambda: self._client.events._queryset(f"/cursus/{instance.id}/events"),
19
19
  )
20
+ instance._bind_relation(
21
+ "projects",
22
+ lambda: self._client.projects._queryset(f"/cursus/{instance.id}/projects"),
23
+ )
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from ...models.exam import Exam
4
+ from .base import AsyncResource
5
+
6
+
7
+ class AsyncExamsResource(AsyncResource[Exam]):
8
+ path = "/exams"
9
+ model = Exam
10
+
11
+ async def graph(
12
+ self, *, field: str | None = None, interval: str | None = None
13
+ ) -> dict[str, int]:
14
+ """Grouped temporal counts (``GET /exams/graph...``)."""
15
+ path = "/exams/graph"
16
+ if interval is not None and field is None:
17
+ field = "created_at"
18
+ if field is not None:
19
+ path += f"/on/{field}"
20
+ if interval is not None:
21
+ path += f"/by/{interval}"
22
+ response = await self._client.request("GET", path)
23
+ return response.json()
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from ...models.project import Project
4
+ from .base import AsyncResource
5
+
6
+
7
+ class AsyncProjectsResource(AsyncResource[Project]):
8
+ path = "/projects"
9
+ model = Project
10
+
11
+ def _bind_relations(self, instance: Project) -> None:
12
+ # Referenced in the projects_users apidoc page:
13
+ # GET /projects/:id/projects_users.
14
+ instance._bind_relation(
15
+ "projects_users",
16
+ lambda: self._client.projects_users._queryset(
17
+ f"/projects/{instance.id}/projects_users"
18
+ ),
19
+ )
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from ...models.project_user import ProjectUser
4
+ from .base import AsyncResource
5
+
6
+
7
+ class AsyncProjectUsersResource(AsyncResource[ProjectUser]):
8
+ path = "/projects_users"
9
+ model = ProjectUser
10
+
11
+ async def graph(
12
+ self, *, field: str | None = None, interval: str | None = None
13
+ ) -> dict[str, int]:
14
+ path = "/projects_users/graph"
15
+ if interval is not None and field is None:
16
+ field = "created_at"
17
+ if field is not None:
18
+ path += f"/on/{field}"
19
+ if interval is not None:
20
+ path += f"/by/{interval}"
21
+ response = await self._client.request("GET", path)
22
+ return response.json()
@@ -21,3 +21,7 @@ class AsyncUsersResource(AsyncResource[User]):
21
21
  "locations",
22
22
  lambda: self._client.locations._queryset(f"/users/{instance.id}/locations"),
23
23
  )
24
+ instance._bind_relation(
25
+ "projects_users",
26
+ lambda: self._client.projects_users._queryset(f"/users/{instance.id}/projects_users"),
27
+ )
@@ -18,11 +18,15 @@ from .._auth import TokenManager
18
18
  from .._config import DEFAULT_BASE_URL, ClientConfig
19
19
  from .._rate_limit import RateLimiter
20
20
  from ..exceptions import NetworkError, raise_for_status
21
+ from .resources.accreditations import AccreditationsResource
21
22
  from .resources.campus_users import CampusUsersResource
22
23
  from .resources.campuses import CampusesResource
23
24
  from .resources.cursus import CursusResource
24
25
  from .resources.events import EventsResource
26
+ from .resources.exams import ExamsResource
25
27
  from .resources.locations import LocationsResource
28
+ from .resources.projects import ProjectsResource
29
+ from .resources.projects_users import ProjectUsersResource
26
30
  from .resources.users import UsersResource
27
31
 
28
32
 
@@ -64,11 +68,15 @@ class Client:
64
68
  )
65
69
 
66
70
  self.users = UsersResource(self)
71
+ self.accreditations = AccreditationsResource(self)
67
72
  self.campuses = CampusesResource(self)
68
73
  self.campus_users = CampusUsersResource(self)
69
74
  self.cursus = CursusResource(self)
70
75
  self.events = EventsResource(self)
76
+ self.exams = ExamsResource(self)
71
77
  self.locations = LocationsResource(self)
78
+ self.projects = ProjectsResource(self)
79
+ self.projects_users = ProjectUsersResource(self)
72
80
 
73
81
  @property
74
82
  def base_url(self) -> str:
@@ -13,6 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  from collections.abc import Callable, Iterator
15
15
  from typing import TYPE_CHECKING, Any, Generic, TypeVar
16
+ from urllib.parse import parse_qsl, urlsplit
16
17
 
17
18
  from .._pagination import parse_link_header
18
19
  from .._query_params import build_query_params
@@ -84,8 +85,20 @@ class QuerySet(Generic[ModelT]):
84
85
  )
85
86
 
86
87
  def __iter__(self) -> Iterator[ModelT]:
88
+ # `base_params` (filter/sort/page_size/range) is re-applied on every
89
+ # page, not just the first. Confirmed against the live API: the
90
+ # `next` link's own query string always uses a *bare* `page=N`
91
+ # cursor — a different, incompatible pagination scheme from the
92
+ # bracketed `page[size]`/`page[number]` this library sends, and the
93
+ # server returns 400 if both appear on the same request. So rather
94
+ # than forwarding the link's query string as-is (which either loses
95
+ # page_size or actively errors once you try to keep it), we extract
96
+ # just the page *number* from the link and reissue it as
97
+ # `page[number]`, combined with our own persisted page_size/filter/
98
+ # sort/range.
99
+ base_params = self._initial_params()
87
100
  url: str | None = self._path
88
- params: dict[str, str] | None = self._initial_params()
101
+ params: dict[str, str] | None = base_params
89
102
  while url is not None:
90
103
  response = self._client.request("GET", url, params=params)
91
104
  for item in response.json():
@@ -93,8 +106,18 @@ class QuerySet(Generic[ModelT]):
93
106
  if self._bind is not None:
94
107
  self._bind(instance)
95
108
  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
109
+
110
+ next_url = parse_link_header(response.headers.get("Link")).get("next")
111
+ if next_url is None:
112
+ break
113
+
114
+ split = urlsplit(next_url)
115
+ url = f"{split.scheme}://{split.netloc}{split.path}" if split.scheme else split.path
116
+ next_query = dict(parse_qsl(split.query))
117
+ page_number = next_query.pop("page[number]", None) or next_query.pop("page", None)
118
+ params = {**next_query, **base_params}
119
+ if page_number is not None:
120
+ params["page[number]"] = page_number
98
121
 
99
122
  def all(self) -> list[ModelT]:
100
123
  return [item for item in self]
@@ -0,0 +1,22 @@
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.accreditation import Accreditation
10
+ from .base import Resource
11
+
12
+
13
+ class AccreditationsResource(Resource[Accreditation]):
14
+ path = "/accreditations"
15
+ model = Accreditation
16
+
17
+ def _bind_relations(self, instance: Accreditation) -> None:
18
+ # Referenced in the users apidoc page: GET /accreditations/:id/users.
19
+ instance._bind_relation(
20
+ "users",
21
+ lambda: self._client.users._queryset(f"/accreditations/{instance.id}/users"),
22
+ )
@@ -23,3 +23,7 @@ class CursusResource(Resource[Cursus]):
23
23
  "events",
24
24
  lambda: self._client.events._queryset(f"/cursus/{instance.id}/events"),
25
25
  )
26
+ instance._bind_relation(
27
+ "projects",
28
+ lambda: self._client.projects._queryset(f"/cursus/{instance.id}/projects"),
29
+ )
@@ -0,0 +1,27 @@
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.exam import Exam
10
+ from .base import Resource
11
+
12
+
13
+ class ExamsResource(Resource[Exam]):
14
+ path = "/exams"
15
+ model = Exam
16
+
17
+ def graph(self, *, field: str | None = None, interval: str | None = None) -> dict[str, int]:
18
+ """Grouped temporal counts (``GET /exams/graph...``)."""
19
+ path = "/exams/graph"
20
+ if interval is not None and field is None:
21
+ field = "created_at"
22
+ if field is not None:
23
+ path += f"/on/{field}"
24
+ if interval is not None:
25
+ path += f"/by/{interval}"
26
+ response = self._client.request("GET", path)
27
+ return response.json()
@@ -0,0 +1,25 @@
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.project import Project
10
+ from .base import Resource
11
+
12
+
13
+ class ProjectsResource(Resource[Project]):
14
+ path = "/projects"
15
+ model = Project
16
+
17
+ def _bind_relations(self, instance: Project) -> None:
18
+ # Referenced in the projects_users apidoc page:
19
+ # GET /projects/:id/projects_users.
20
+ instance._bind_relation(
21
+ "projects_users",
22
+ lambda: self._client.projects_users._queryset(
23
+ f"/projects/{instance.id}/projects_users"
24
+ ),
25
+ )
@@ -0,0 +1,26 @@
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.project_user import ProjectUser
10
+ from .base import Resource
11
+
12
+
13
+ class ProjectUsersResource(Resource[ProjectUser]):
14
+ path = "/projects_users"
15
+ model = ProjectUser
16
+
17
+ def graph(self, *, field: str | None = None, interval: str | None = None) -> dict[str, int]:
18
+ path = "/projects_users/graph"
19
+ if interval is not None and field is None:
20
+ field = "created_at"
21
+ if field is not None:
22
+ path += f"/on/{field}"
23
+ if interval is not None:
24
+ path += f"/by/{interval}"
25
+ response = self._client.request("GET", path)
26
+ return response.json()
@@ -27,3 +27,7 @@ class UsersResource(Resource[User]):
27
27
  "locations",
28
28
  lambda: self._client.locations._queryset(f"/users/{instance.id}/locations"),
29
29
  )
30
+ instance._bind_relation(
31
+ "projects_users",
32
+ lambda: self._client.projects_users._queryset(f"/users/{instance.id}/projects_users"),
33
+ )
@@ -0,0 +1,29 @@
1
+ """The ``Accreditation`` resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import TYPE_CHECKING
7
+
8
+ from .base import FortyTwoModel
9
+
10
+ if TYPE_CHECKING:
11
+ from .._async.query import AsyncQuerySet
12
+ from .._sync.query import QuerySet
13
+ from .user import User
14
+
15
+
16
+ class Accreditation(FortyTwoModel):
17
+ id: int
18
+ name: str | None = None
19
+ user_id: int | None = None
20
+ cursus_id: int | None = None
21
+ difficulty: int | None = None
22
+ validated: bool | None = None
23
+ created_at: datetime | None = None
24
+ updated_at: datetime | None = None
25
+
26
+ @property
27
+ def users(self) -> AsyncQuerySet[User] | QuerySet[User]:
28
+ """Users holding this accreditation (``GET /accreditations/{id}/users``)."""
29
+ return self._relation("users")
@@ -11,6 +11,7 @@ if TYPE_CHECKING:
11
11
  from .._async.query import AsyncQuerySet
12
12
  from .._sync.query import QuerySet
13
13
  from .event import Event
14
+ from .project import Project
14
15
  from .user import User
15
16
 
16
17
 
@@ -33,3 +34,8 @@ class Cursus(FortyTwoModel):
33
34
  def events(self) -> AsyncQuerySet[Event] | QuerySet[Event]:
34
35
  """Events for this cursus (``GET /cursus/{id}/events``)."""
35
36
  return self._relation("events")
37
+
38
+ @property
39
+ def projects(self) -> AsyncQuerySet[Project] | QuerySet[Project]:
40
+ """Projects belonging to this cursus (``GET /cursus/{id}/projects``)."""
41
+ return self._relation("projects")
@@ -0,0 +1,32 @@
1
+ """The ``Exam`` resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+
7
+ from .base import FortyTwoModel
8
+ from .campus import Campus
9
+ from .cursus import Cursus
10
+ from .project import Project
11
+
12
+
13
+ class Exam(FortyTwoModel):
14
+ id: int
15
+ name: str | None = None
16
+ ip_range: str | None = None
17
+ begin_at: datetime | None = None
18
+ end_at: datetime | None = None
19
+ location: str | None = None
20
+ max_people: int | None = None
21
+ nbr_subscribers: int | None = None
22
+ created_at: datetime | None = None
23
+ updated_at: datetime | None = None
24
+ campus_id: int | None = None
25
+ campus: Campus | None = None
26
+ cursus: list[Cursus] | None = None
27
+ projects: list[Project] | None = None
28
+ visible: bool | None = None
29
+ validated: bool | None = None
30
+ validated_at: datetime | None = None
31
+ validator_id: int | None = None
32
+ prohibition_of_cancellation: int | None = None