myges-api 0.3.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.
myges/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """Client Python pour l'API myGES / Kordis (non officielle)."""
2
+
3
+ from . import models
4
+ from ._version import __version__
5
+ from .auth import FileTokenStore, MemoryTokenStore, Token, TokenStore
6
+ from .client import AsyncMyGES, MyGES
7
+ from .errors import (
8
+ APIError,
9
+ AuthenticationError,
10
+ BadRequestError,
11
+ BlockedRequestError,
12
+ InvalidCredentialsError,
13
+ MyGESError,
14
+ NotFoundError,
15
+ TokenExpiredError,
16
+ WriteNotAllowedError,
17
+ )
18
+ from .ics import to_ics
19
+
20
+ __all__ = [
21
+ "APIError",
22
+ "AsyncMyGES",
23
+ "AuthenticationError",
24
+ "BadRequestError",
25
+ "BlockedRequestError",
26
+ "FileTokenStore",
27
+ "InvalidCredentialsError",
28
+ "MemoryTokenStore",
29
+ "MyGES",
30
+ "MyGESError",
31
+ "NotFoundError",
32
+ "Token",
33
+ "TokenExpiredError",
34
+ "TokenStore",
35
+ "WriteNotAllowedError",
36
+ "__version__",
37
+ "models",
38
+ "to_ics",
39
+ ]
myges/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
myges/auth.py ADDED
@@ -0,0 +1,198 @@
1
+ """Authentification Kordis et stockage du token.
2
+
3
+ Kordis détourne le flow OAuth 2.0 *implicit grant* : on envoie les identifiants en
4
+ Basic Auth à l'endpoint d'autorisation, qui répond par une redirection ``302`` vers
5
+ ``comreseaugesskolae:/oauth2redirect#access_token=...``. Le token est lu dans le
6
+ fragment de l'en-tête ``Location``, sans suivre la redirection.
7
+
8
+ Le token vit ~6,5 jours et il n'existe pas de refresh token : à l'expiration il faut
9
+ se ré-authentifier, d'où le stockage des identifiants côté appelant (variables
10
+ d'environnement) et le cache du token (:class:`FileTokenStore`) pour ne pas se
11
+ ré-authentifier à chaque exécution.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import json
18
+ import os
19
+ import tempfile
20
+ from dataclasses import asdict, dataclass, field
21
+ from datetime import datetime, timedelta, timezone
22
+ from pathlib import Path
23
+ from typing import Protocol
24
+ from urllib.parse import parse_qsl, urlsplit
25
+
26
+ import httpx
27
+
28
+ from .errors import AuthenticationError, BlockedRequestError, InvalidCredentialsError
29
+
30
+ AUTH_URL = "https://authentication.kordis.fr/oauth/authorize"
31
+ AUTH_PARAMS = {"response_type": "token", "client_id": "skolae-app"}
32
+
33
+ # Marge avant expiration en dessous de laquelle on considère le token comme périmé.
34
+ EXPIRY_MARGIN = timedelta(hours=1)
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Token:
39
+ access_token: str
40
+ expires_in: int
41
+ obtained_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
42
+ token_type: str = "bearer"
43
+ scope: str = "account"
44
+
45
+ @property
46
+ def expires_at(self) -> datetime:
47
+ return self.obtained_at + timedelta(seconds=self.expires_in)
48
+
49
+ def is_expired(self, margin: timedelta = EXPIRY_MARGIN) -> bool:
50
+ return datetime.now(timezone.utc) >= self.expires_at - margin
51
+
52
+ def to_dict(self) -> dict[str, object]:
53
+ data = asdict(self)
54
+ data["obtained_at"] = self.obtained_at.isoformat()
55
+ return data
56
+
57
+ @classmethod
58
+ def from_dict(cls, data: dict[str, object]) -> Token:
59
+ return cls(
60
+ access_token=str(data["access_token"]),
61
+ expires_in=int(str(data["expires_in"])),
62
+ obtained_at=datetime.fromisoformat(str(data["obtained_at"])),
63
+ token_type=str(data.get("token_type", "bearer")),
64
+ scope=str(data.get("scope", "account")),
65
+ )
66
+
67
+ def __repr__(self) -> str: # ne jamais afficher le token dans un log
68
+ return f"Token(expires_at={self.expires_at.isoformat()!r})"
69
+
70
+
71
+ # ---------------------------------------------------------------- Requête d'auth
72
+
73
+
74
+ def build_auth_request(username: str, password: str, user_agent: str) -> httpx.Request:
75
+ credentials = base64.b64encode(f"{username}:{password}".encode()).decode()
76
+ return httpx.Request(
77
+ "GET",
78
+ AUTH_URL,
79
+ params=AUTH_PARAMS,
80
+ headers={"Authorization": f"Basic {credentials}", "User-Agent": user_agent},
81
+ )
82
+
83
+
84
+ def parse_auth_response(response: httpx.Response) -> Token:
85
+ """Extrait le token de la redirection, ou lève l'erreur adaptée."""
86
+ if response.status_code == 401:
87
+ raise InvalidCredentialsError("Identifiant ou mot de passe refusé par Kordis.")
88
+ if response.status_code == 403:
89
+ raise BlockedRequestError(
90
+ "Authentification bloquée par le pare-feu Kordis (403). Vérifier le User-Agent envoyé."
91
+ )
92
+ location = response.headers.get("Location")
93
+ if response.status_code != 302 or not location:
94
+ raise AuthenticationError(
95
+ f"Réponse inattendue de l'endpoint d'authentification ({response.status_code})."
96
+ )
97
+ params = dict(parse_qsl(urlsplit(location).fragment))
98
+ if "access_token" not in params:
99
+ raise AuthenticationError("Redirection reçue sans access_token dans le fragment.")
100
+ return Token(
101
+ access_token=params["access_token"],
102
+ expires_in=int(params.get("expires_in", 0)),
103
+ token_type=params.get("token_type", "bearer"),
104
+ scope=params.get("scope", "account"),
105
+ )
106
+
107
+
108
+ def authenticate(http: httpx.Client, username: str, password: str, user_agent: str) -> Token:
109
+ request = build_auth_request(username, password, user_agent)
110
+ return parse_auth_response(http.send(request, follow_redirects=False))
111
+
112
+
113
+ async def authenticate_async(
114
+ http: httpx.AsyncClient, username: str, password: str, user_agent: str
115
+ ) -> Token:
116
+ request = build_auth_request(username, password, user_agent)
117
+ return parse_auth_response(await http.send(request, follow_redirects=False))
118
+
119
+
120
+ # ---------------------------------------------------------------- Stockage
121
+
122
+
123
+ class TokenStore(Protocol):
124
+ def load(self, username: str) -> Token | None: ...
125
+
126
+ def save(self, username: str, token: Token) -> None: ...
127
+
128
+ def clear(self, username: str) -> None: ...
129
+
130
+
131
+ class MemoryTokenStore:
132
+ """Cache en mémoire, perdu à la fin du process."""
133
+
134
+ def __init__(self) -> None:
135
+ self._tokens: dict[str, Token] = {}
136
+
137
+ def load(self, username: str) -> Token | None:
138
+ return self._tokens.get(username)
139
+
140
+ def save(self, username: str, token: Token) -> None:
141
+ self._tokens[username] = token
142
+
143
+ def clear(self, username: str) -> None:
144
+ self._tokens.pop(username, None)
145
+
146
+
147
+ def default_token_path() -> Path:
148
+ base = os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache"
149
+ return Path(base) / "myges" / "tokens.json"
150
+
151
+
152
+ class FileTokenStore:
153
+ """Tokens persistés dans un fichier JSON en ``0600``, un par identifiant.
154
+
155
+ Écriture atomique (fichier temporaire puis ``rename``) pour qu'un process
156
+ interrompu ne laisse jamais un cache tronqué.
157
+ """
158
+
159
+ def __init__(self, path: Path | str | None = None) -> None:
160
+ self.path = Path(path) if path else default_token_path()
161
+
162
+ def _read(self) -> dict[str, dict[str, object]]:
163
+ try:
164
+ data = json.loads(self.path.read_text(encoding="utf-8"))
165
+ except (FileNotFoundError, json.JSONDecodeError):
166
+ return {}
167
+ return data if isinstance(data, dict) else {}
168
+
169
+ def _write(self, data: dict[str, dict[str, object]]) -> None:
170
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
171
+ fd, tmp = tempfile.mkstemp(dir=self.path.parent, prefix=".tokens-")
172
+ try:
173
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
174
+ json.dump(data, fh)
175
+ os.chmod(tmp, 0o600)
176
+ os.replace(tmp, self.path)
177
+ except BaseException:
178
+ Path(tmp).unlink(missing_ok=True)
179
+ raise
180
+
181
+ def load(self, username: str) -> Token | None:
182
+ entry = self._read().get(username)
183
+ if entry is None:
184
+ return None
185
+ try:
186
+ return Token.from_dict(entry)
187
+ except (KeyError, ValueError):
188
+ return None
189
+
190
+ def save(self, username: str, token: Token) -> None:
191
+ data = self._read()
192
+ data[username] = token.to_dict()
193
+ self._write(data)
194
+
195
+ def clear(self, username: str) -> None:
196
+ data = self._read()
197
+ if data.pop(username, None) is not None:
198
+ self._write(data)
myges/blocs.py ADDED
@@ -0,0 +1,99 @@
1
+ """Moyennes par bloc de compétences, avec simulation de notes.
2
+
3
+ myGES n'expose pas les blocs : l'étudiant les définit lui-même (``myges blocs add``), en
4
+ regroupant des matières. Ce module calcule, à partir des notes, la moyenne pondérée de
5
+ chaque bloc et la moyenne générale, en tenant compte de notes hypothétiques.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping, Sequence
11
+ from dataclasses import dataclass, field
12
+
13
+ from .models import Course, Grade
14
+
15
+ PASSING = 10.0
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class CourseLine:
20
+ rc_id: int
21
+ name: str
22
+ weight: float
23
+ average: float | None
24
+ source: str # officielle | provisoire | simulée | aucune
25
+ official_weight: bool # False : coefficient non publié, pondération 1
26
+
27
+
28
+ @dataclass
29
+ class BlocResult:
30
+ name: str
31
+ lines: list[CourseLine] = field(default_factory=list)
32
+
33
+ @property
34
+ def average(self) -> float | None:
35
+ return weighted_average(self.lines)
36
+
37
+ @property
38
+ def validated(self) -> bool | None:
39
+ avg = self.average
40
+ return None if avg is None else avg >= PASSING
41
+
42
+
43
+ def weighted_average(lines: Sequence[CourseLine]) -> float | None:
44
+ counted = [line for line in lines if line.average is not None]
45
+ total = sum(line.weight for line in counted)
46
+ if not counted or total <= 0:
47
+ return None
48
+ return sum(line.weight * (line.average or 0.0) for line in counted) / total
49
+
50
+
51
+ def course_line(course: Course, grade: Grade | None, simulated: float | None) -> CourseLine:
52
+ weight = course.coef or (grade.coef_value if grade else None) or 0.0
53
+ official_weight = weight > 0
54
+ average: float | None
55
+ if simulated is not None:
56
+ average, source = simulated, "simulée"
57
+ elif grade is not None and grade.average is not None:
58
+ average, source = grade.average, "officielle"
59
+ elif grade is not None and grade.grades:
60
+ average, source = grade.ccaverage, "provisoire"
61
+ else:
62
+ average, source = None, "aucune"
63
+ return CourseLine(
64
+ rc_id=course.rc_id,
65
+ name=course.name,
66
+ weight=weight if official_weight else 1.0,
67
+ average=average,
68
+ source=source,
69
+ official_weight=official_weight,
70
+ )
71
+
72
+
73
+ def compute(
74
+ courses: Sequence[Course],
75
+ grades: Sequence[Grade],
76
+ blocs: Mapping[str, Sequence[int]],
77
+ simulated: Mapping[int, float] | None = None,
78
+ ) -> tuple[list[BlocResult], BlocResult]:
79
+ """(un résultat par bloc dans l'ordre de définition, matières hors bloc)."""
80
+ simulated = simulated or {}
81
+ by_id = {c.rc_id: c for c in courses}
82
+ grade_by_id = {g.rc_id: g for g in grades}
83
+ lines = {rc: course_line(c, grade_by_id.get(rc), simulated.get(rc)) for rc, c in by_id.items()}
84
+ results = [
85
+ BlocResult(name, [lines[rc] for rc in members if rc in lines])
86
+ for name, members in blocs.items()
87
+ ]
88
+ assigned = {rc for members in blocs.values() for rc in members}
89
+ others = BlocResult("Hors bloc", [line for rc, line in lines.items() if rc not in assigned])
90
+ return results, others
91
+
92
+
93
+ def overall(results: Sequence[BlocResult], others: BlocResult) -> float | None:
94
+ """Moyenne générale pondérée de toutes les matières (chacune comptée une fois)."""
95
+ seen: dict[int, CourseLine] = {}
96
+ for result in [*results, others]:
97
+ for line in result.lines:
98
+ seen.setdefault(line.rc_id, line)
99
+ return weighted_average(list(seen.values()))