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 +39 -0
- myges/_version.py +1 -0
- myges/auth.py +198 -0
- myges/blocs.py +99 -0
- myges/cli.py +1942 -0
- myges/client.py +682 -0
- myges/errors.py +48 -0
- myges/gcal.py +288 -0
- myges/ics.py +70 -0
- myges/models.py +640 -0
- myges/notify.py +152 -0
- myges/pending.py +81 -0
- myges/py.typed +0 -0
- myges/registration.py +225 -0
- myges/schedule.py +139 -0
- myges/watch.py +211 -0
- myges_api-0.3.0.dist-info/METADATA +125 -0
- myges_api-0.3.0.dist-info/RECORD +21 -0
- myges_api-0.3.0.dist-info/WHEEL +4 -0
- myges_api-0.3.0.dist-info/entry_points.txt +2 -0
- myges_api-0.3.0.dist-info/licenses/LICENSE +21 -0
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()))
|