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,34 @@
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.location import Location
10
+ from .base import Resource
11
+
12
+
13
+ class LocationsResource(Resource[Location]):
14
+ path = "/locations"
15
+ model = Location
16
+
17
+ def graph(self, *, field: str | None = None, interval: str | None = None) -> dict[str, int]:
18
+ """Grouped temporal counts (``GET /locations/graph...``).
19
+
20
+ Not a paginated list of ``Location`` objects — returns a raw
21
+ ``{date_string: count}`` mapping, counting occurrences of `field`
22
+ (default ``begin_at``) bucketed by `interval` (default
23
+ ``month_of_year``) from the first occurrence to now.
24
+ """
25
+
26
+ path = "/locations/graph"
27
+ if interval is not None and field is None:
28
+ field = "begin_at"
29
+ if field is not None:
30
+ path += f"/on/{field}"
31
+ if interval is not None:
32
+ path += f"/by/{interval}"
33
+ response = self._client.request("GET", path)
34
+ return response.json()
@@ -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.user import User
10
+ from .base import Resource
11
+
12
+
13
+ class UsersResource(Resource[User]):
14
+ path = "/users"
15
+ model = User
16
+
17
+ def _bind_relations(self, instance: User) -> None:
18
+ instance._bind_relation(
19
+ "events",
20
+ lambda: self._client.events._queryset(f"/users/{instance.id}/events"),
21
+ )
22
+ instance._bind_relation(
23
+ "campus_users",
24
+ lambda: self._client.campus_users._queryset(f"/users/{instance.id}/campus_users"),
25
+ )
26
+ instance._bind_relation(
27
+ "locations",
28
+ lambda: self._client.locations._queryset(f"/users/{instance.id}/locations"),
29
+ )
intra42/exceptions.py ADDED
@@ -0,0 +1,88 @@
1
+ """Exception hierarchy for the intra42 client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ import httpx
9
+
10
+
11
+ class FortyTwoAPIError(Exception):
12
+ """Base class for all errors raised by this library."""
13
+
14
+ def __init__(
15
+ self,
16
+ message: str,
17
+ *,
18
+ status_code: int | None = None,
19
+ response: httpx.Response | None = None,
20
+ ) -> None:
21
+ super().__init__(message)
22
+ self.message = message
23
+ self.status_code = status_code
24
+ self.response = response
25
+
26
+ def __repr__(self) -> str:
27
+ return f"{type(self).__name__}(status_code={self.status_code!r}, message={self.message!r})"
28
+
29
+
30
+ class AuthenticationError(FortyTwoAPIError):
31
+ """Raised on any 401"""
32
+
33
+
34
+ class PermissionDeniedError(FortyTwoAPIError):
35
+ """Raised on any 403"""
36
+
37
+
38
+ class NotFoundError(FortyTwoAPIError):
39
+ """Raised on any 404."""
40
+
41
+
42
+ class ValidationError(FortyTwoAPIError):
43
+ """Raised on any 422"""
44
+
45
+
46
+ class RateLimitError(FortyTwoAPIError):
47
+ """Raised on any 429 after the built-in retry/backoff budget is exhausted."""
48
+
49
+
50
+ class ServerError(FortyTwoAPIError):
51
+ """Raised on any 5xx"""
52
+
53
+
54
+ class NetworkError(FortyTwoAPIError):
55
+ """Wraps a transport-level failure (DNS, connect, timeout) from httpx."""
56
+
57
+
58
+ _STATUS_MAP: dict[int, type[FortyTwoAPIError]] = {
59
+ 401: AuthenticationError,
60
+ 403: PermissionDeniedError,
61
+ 404: NotFoundError,
62
+ 422: ValidationError,
63
+ 429: RateLimitError,
64
+ }
65
+
66
+
67
+ def raise_for_status(response: httpx.Response) -> None:
68
+ """Raise the mapped :class:`FortyTwoAPIError` subclass for a non-2xx response.
69
+
70
+ Does nothing for successful responses.
71
+ """
72
+ if response.is_success:
73
+ return
74
+
75
+ status = response.status_code
76
+ try:
77
+ payload = response.json()
78
+ detail = payload.get("error_description") or payload.get("message") or payload.get("error")
79
+ except Exception:
80
+ detail = None
81
+ message = detail or f"Request failed with status {status}"
82
+
83
+ if status >= 500:
84
+ error_cls: type[FortyTwoAPIError] = ServerError
85
+ else:
86
+ error_cls = _STATUS_MAP.get(status, FortyTwoAPIError)
87
+
88
+ raise error_cls(message, status_code=status, response=response)
File without changes
intra42/models/base.py ADDED
@@ -0,0 +1,47 @@
1
+ """Shared base model for all 42 API resource models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, ConfigDict, PrivateAttr
9
+
10
+
11
+ class FortyTwoModel(BaseModel):
12
+ """Base class for every typed resource returned by the API.
13
+
14
+ ``extra="allow"`` is deliberate: 42's API has historically added fields
15
+ over time, so unknown keys are kept (inspectable via ``.model_extra``)
16
+ rather than raising a ``ValidationError`` or silently discarding them.
17
+ """
18
+
19
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
20
+
21
+ # Nested-resource accessors (e.g. User.events) are not
22
+ # hardcoded, they're bound per-instance by whichever resource
23
+ # fetched this object, as a lazily invoked factory.
24
+ _relations: dict[str, Callable[[], Any]] = PrivateAttr(default_factory=dict)
25
+
26
+ def _bind_relation(self, name: str, factory: Callable[[], Any]) -> None:
27
+ """Attach a lazily-invoked nested-resource accessor to this instance.
28
+
29
+ Called internally by Client/AsyncClient resources right after
30
+ parsing a response; not meant to be called directly.
31
+ """
32
+ self._relations[name] = factory
33
+
34
+ def _relation(self, name: str) -> Any:
35
+ """Resolve a nested-resource accessor bound via ``_bind_relation``.
36
+
37
+ Raises ``RuntimeError`` with a clear message if this instance wasn't
38
+ fetched through a Client/AsyncClient
39
+ """
40
+ try:
41
+ factory = self._relations[name]
42
+ except KeyError:
43
+ raise RuntimeError(
44
+ f"'{name}' is only available on a {type(self).__name__} instance "
45
+ "fetched via Client/AsyncClient, not one constructed manually."
46
+ ) from None
47
+ return factory()
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from .base import FortyTwoModel
6
+
7
+ if TYPE_CHECKING:
8
+ from .._async.query import AsyncQuerySet
9
+ from .._sync.query import QuerySet
10
+ from .event import Event
11
+ from .location import Location
12
+ from .user import User
13
+
14
+
15
+ class CampusLanguage(FortyTwoModel):
16
+ id: int
17
+ name: str | None = None
18
+ identifier: str | None = None
19
+
20
+
21
+ class CampusEndpoint(FortyTwoModel):
22
+ id: int
23
+ url: str | None = None
24
+ description: str | None = None
25
+ created_at: str | None = None
26
+ updated_at: str | None = None
27
+
28
+
29
+ class Campus(FortyTwoModel):
30
+ id: int
31
+ name: str
32
+ time_zone: str | None = None
33
+ language: CampusLanguage | None = None
34
+ users_count: int | None = None
35
+ vogsphere_id: int | None = None
36
+ country: str | None = None
37
+ address: str | None = None
38
+ zip: str | None = None
39
+ city: str | None = None
40
+ website: str | None = None
41
+ facebook: str | None = None
42
+ twitter: str | None = None
43
+ active: bool | None = None
44
+ public: bool | None = None
45
+ email_extension: str | None = None
46
+ default_hidden_phone: bool | None = None
47
+ endpoint: CampusEndpoint | None = None
48
+
49
+ @property
50
+ def users(self) -> AsyncQuerySet[User] | QuerySet[User]:
51
+ """Users of this campus (``GET /campus/{id}/users``).
52
+
53
+ Lazily-paginating. Only available on instances fetched via
54
+ ``Client``/``AsyncClient`` — see ``FortyTwoModel._relation``.
55
+ """
56
+ return self._relation("users")
57
+
58
+ @property
59
+ def events(self) -> AsyncQuerySet[Event] | QuerySet[Event]:
60
+ """Events at this campus (``GET /campus/{id}/events``)."""
61
+ return self._relation("events")
62
+
63
+ @property
64
+ def locations(self) -> AsyncQuerySet[Location] | QuerySet[Location]:
65
+ """Login sessions at this campus (``GET /campus/{id}/locations``)."""
66
+ return self._relation("locations")
@@ -0,0 +1,16 @@
1
+ """The ``CampusUser`` resource: the join between a user and a campus."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+
7
+ from .base import FortyTwoModel
8
+
9
+
10
+ class CampusUser(FortyTwoModel):
11
+ id: int
12
+ user_id: int | None = None
13
+ campus_id: int | None = None
14
+ is_primary: bool | None = None
15
+ created_at: datetime | None = None
16
+ updated_at: datetime | None = None
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from .base import FortyTwoModel
6
+
7
+
8
+ class EventTheme(FortyTwoModel):
9
+ id: int | None = None
10
+ name: str | None = None
11
+ created_at: datetime | None = None
12
+ updated_at: datetime | None = None
13
+
14
+
15
+ class EventWaitlist(FortyTwoModel):
16
+ id: int | None = None
17
+ waitlistable_id: int | None = None
18
+ waitlistable_type: str | None = None
19
+ created_at: datetime | None = None
20
+ updated_at: datetime | None = None
21
+
22
+
23
+ class Event(FortyTwoModel):
24
+ id: int
25
+ name: str | None = None
26
+ description: str | None = None
27
+ location: str | None = None
28
+ kind: str | None = None
29
+ max_people: int | None = None
30
+ nbr_subscribers: int | None = None
31
+ begin_at: datetime | None = None
32
+ end_at: datetime | None = None
33
+ campus_ids: list[int] | None = None
34
+ cursus_ids: list[int] | None = None
35
+ themes: list[EventTheme] | None = None
36
+ waitlist: EventWaitlist | None = None
37
+ prohibition_of_cancellation: int | None = None
38
+ difficulty: int | None = None
39
+ remote: bool | None = None
40
+ created_at: datetime | None = None
41
+ updated_at: datetime | None = None
@@ -0,0 +1,26 @@
1
+ """The ``Location`` resource: a login session at a workstation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+
7
+ from .base import FortyTwoModel
8
+
9
+
10
+ class LocationUser(FortyTwoModel):
11
+ id: int | None = None
12
+ login: str | None = None
13
+ url: str | None = None
14
+
15
+
16
+ class Location(FortyTwoModel):
17
+ id: int
18
+ begin_at: datetime | None = None
19
+ end_at: datetime | None = None
20
+ primary: bool | None = None
21
+ floor: str | None = None
22
+ row: str | None = None
23
+ post: str | None = None
24
+ host: str | None = None
25
+ campus_id: int | None = None
26
+ user: LocationUser | None = None
intra42/models/user.py ADDED
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import TYPE_CHECKING
5
+
6
+ from pydantic import Field
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 .campus_user import CampusUser
14
+ from .event import Event
15
+ from .location import Location
16
+
17
+
18
+ class ImageVersions(FortyTwoModel):
19
+ large: str | None = None
20
+ medium: str | None = None
21
+ small: str | None = None
22
+ micro: str | None = None
23
+
24
+
25
+ class Image(FortyTwoModel):
26
+ link: str | None = None
27
+ versions: ImageVersions | None = None
28
+
29
+
30
+ class User(FortyTwoModel):
31
+ id: int
32
+ email: str | None = None
33
+ login: str | None = None
34
+ first_name: str | None = None
35
+ last_name: str | None = None
36
+ usual_full_name: str | None = None
37
+ usual_first_name: str | None = None
38
+ url: str | None = None
39
+ phone: str | None = None
40
+ displayname: str | None = None
41
+ kind: str | None = None
42
+ image: Image | None = None
43
+ staff: bool | None = Field(default=None, alias="staff?")
44
+ correction_point: int | None = None
45
+ pool_month: str | None = None
46
+ pool_year: str | None = None
47
+ location: str | None = None
48
+ wallet: int | None = None
49
+ anonymize_date: datetime | None = None
50
+ data_erasure_date: datetime | None = None
51
+ created_at: datetime | None = None
52
+ updated_at: datetime | None = None
53
+ alumnized_at: datetime | None = None
54
+ alumni: bool | None = Field(default=None, alias="alumni?")
55
+ active: bool | None = Field(default=None, alias="active?")
56
+
57
+ @property
58
+ def events(self) -> AsyncQuerySet[Event] | QuerySet[Event]:
59
+ """Events scoped to this user (``GET /users/{id}/events``)"""
60
+ return self._relation("events")
61
+
62
+ @property
63
+ def campus_users(self) -> AsyncQuerySet[CampusUser] | QuerySet[CampusUser]:
64
+ """This user's campus memberships (``GET /users/{id}/campus_users``)."""
65
+ return self._relation("campus_users")
66
+
67
+ @property
68
+ def locations(self) -> AsyncQuerySet[Location] | QuerySet[Location]:
69
+ """This user's login sessions (``GET /users/{id}/locations``).
70
+
71
+ Not to be confused with the ``location`` string field.
72
+ """
73
+ return self._relation("locations")
intra42/py.typed ADDED
File without changes