ladok3 4.10__py3-none-any.whl → 5.4__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.
ladok3/ladok3.nw CHANGED
@@ -16,6 +16,8 @@ import json
16
16
  import operator
17
17
  import re
18
18
  import requests
19
+ from requests.adapters import HTTPAdapter
20
+ from urllib3.util.retry import Retry
19
21
  import urllib.parse
20
22
  import weblogin.ladok
21
23
 
@@ -57,6 +59,17 @@ ladok = ladok3.LadokSession("KTH Royal Institute of Technology",
57
59
  test_environment=True) # for experiments
58
60
  \end{minted}
59
61
 
62
+ Yet another way is to use the [[ladok]] command's own credential store.
63
+ \begin{minted}[linenos]{python}
64
+ import ladok3
65
+ import ladok3.cli
66
+
67
+ ladok = ladok3.LadokSession(*ladok3.cli.load_credentials(),
68
+ test_environment=True) # for experiments
69
+ \end{minted}
70
+ Just note that unpacking [[*]] to unpack the tuple that [[load_credentials]]
71
+ returns.
72
+
60
73
  This chapter covers how the [[LadokSession]] class work.
61
74
  The remaining chapters cover what the [[ladok]] object can be used for.
62
75
  \Cref{StudentClasses} covers how we can work with student data.
@@ -169,9 +182,76 @@ autologin_handlers.insert(0,
169
182
  test_environment=test_environment))
170
183
 
171
184
  self.__session = weblogin.AutologinSession(autologin_handlers)
185
+ <<configure retry policy for the session>>
172
186
  @
173
187
 
174
188
 
189
+ \subsection{Retry policy for network resilience}
190
+
191
+ Network operations can fail transiently due to temporary connectivity issues,
192
+ server overload, or other intermittent problems. To make the LADOK API wrapper
193
+ more robust, we implement an automatic retry mechanism with exponential backoff.
194
+
195
+ The retry policy addresses the "why" behind network failures: they are often
196
+ temporary. Rather than immediately failing when encountering a network error or
197
+ server overload (5xx errors), we wait and try again. The exponential backoff
198
+ prevents overwhelming an already struggling server while still providing
199
+ reasonable response times for transient failures.
200
+
201
+ We use the [[urllib3.util.retry.Retry]] class, which provides sophisticated
202
+ retry logic, combined with [[requests.adapters.HTTPAdapter]] to integrate it
203
+ into our session. This approach is preferable to manual retry loops because it:
204
+ \begin{itemize}
205
+ \item Handles backoff timing automatically with exponential growth
206
+ \item Respects HTTP standards (like [[Retry-After]] headers)
207
+ \item Integrates seamlessly with the [[requests]] session infrastructure
208
+ \item Applies to all HTTP methods uniformly
209
+ \end{itemize}
210
+
211
+ We configure the retry policy as follows:
212
+ \begin{description}
213
+ \item[{Total retries}] We set a maximum of 10 retry attempts. With exponential
214
+ backoff starting at 1 second, this allows for approximately 5 minutes of total
215
+ retry time (1, 2, 4, 8, 16, 32, 64, 128, 256, 300 seconds = \~{}5 minutes when
216
+ capped at 300 seconds).
217
+
218
+ \item[{Backoff factor}] We use a backoff factor of 1, meaning the wait time
219
+ between retries is $\{\text{backoff factor}\} \times 2^{(\text{retry number} -
220
+ 1)}$ seconds. The first retry waits 1 second, the second waits 2 seconds, the
221
+ third waits 4 seconds, and so on.
222
+
223
+ \item[{Maximum backoff}] We cap the backoff at 300 seconds (5 minutes) to
224
+ prevent excessively long waits on later retries while still allowing the full
225
+ retry sequence to span approximately 5 minutes total.
226
+
227
+ \item[{Status forcelist}] We retry on HTTP status codes 500 (Internal Server
228
+ Error), 502 (Bad Gateway), 503 (Service Unavailable), and 504 (Gateway Timeout).
229
+ These indicate temporary server-side issues that often resolve quickly.
230
+
231
+ \item[{Allowed methods}] We allow retries on all HTTP methods including GET,
232
+ POST, PUT, and DELETE. While POST/PUT/DELETE are not idempotent by HTTP
233
+ standards, LADOK's API design makes these operations safe to retry (results are
234
+ either already recorded or will be recorded on retry).
235
+ \end{description}
236
+
237
+ <<configure retry policy for the session>>=
238
+ retry_strategy = Retry(
239
+ total=10,
240
+ backoff_factor=1,
241
+ backoff_max=300,
242
+ status_forcelist=[500, 502, 503, 504],
243
+ allowed_methods=["HEAD", "GET", "PUT", "DELETE", "OPTIONS", "TRACE",
244
+ "POST"]
245
+ )
246
+ adapter = HTTPAdapter(max_retries=retry_strategy)
247
+ self.__session.mount("http://", adapter)
248
+ self.__session.mount("https://", adapter)
249
+ @
250
+
251
+ The [[mount]] method attaches the retry-enabled adapter to both HTTP and HTTPS
252
+ URLs, ensuring all requests through this session benefit from automatic retries.
253
+
254
+
175
255
  \section{The [[LadokSession]] data methods}\label{LadokSession-data-methods}
176
256
 
177
257
  The data methods are essentially factories for various objects mapping to data
@@ -252,7 +332,20 @@ For completeness, we also provide [[LadokDataEncoder]], a subclass of
252
332
  [[JSONEncoder]].
253
333
  <<classes>>=
254
334
  class LadokDataEncoder(json.JSONEncoder):
335
+ """JSON encoder for LadokData objects.
336
+
337
+ Extends JSONEncoder to properly serialize LadokData objects using their
338
+ json property for consistent JSON output.
339
+ """
255
340
  def default(self, object):
341
+ """Convert LadokData objects to JSON-serializable format.
342
+
343
+ Args:
344
+ object: The object to encode.
345
+
346
+ Returns:
347
+ dict: JSON representation for LadokData objects, or default handling.
348
+ """
256
349
  if isinstance(object, LadokData):
257
350
  return object.json
258
351
  return super().default(object)
@@ -844,6 +937,8 @@ def alive(self):
844
937
  except:
845
938
  self.__get_personal_attributes()
846
939
  return self.__alive
940
+
941
+ <<student contact attribute methods>>
847
942
  @
848
943
 
849
944
  Then we can call that method to update all attributes.
@@ -872,6 +967,89 @@ self.__alive = not record['Avliden']
872
967
  @
873
968
 
874
969
 
970
+ \section{Students' contact attributes}
971
+
972
+ The contact attributes include email, phone number, and postal address information.
973
+ We use the [[__get_contact_attributes]] helper method to populate these attributes
974
+ from a separate LADOK API call.
975
+
976
+ <<student contact attribute methods>>=
977
+ @property
978
+ def email(self):
979
+ """Return the student's email address"""
980
+ try:
981
+ return self.__email
982
+ except:
983
+ self.__get_contact_attributes()
984
+ return self.__email
985
+
986
+ @property
987
+ def phone(self):
988
+ """Return the student's phone number"""
989
+ try:
990
+ return self.__phone
991
+ except:
992
+ self.__get_contact_attributes()
993
+ return self.__phone
994
+
995
+ @property
996
+ def address(self):
997
+ """Return the student's postal address as a list of lines"""
998
+ try:
999
+ return self.__address
1000
+ except:
1001
+ self.__get_contact_attributes()
1002
+ return self.__address
1003
+
1004
+ def __get_contact_attributes(self):
1005
+ """Helper method that fetches contact attributes"""
1006
+ <<pull student contact attributes from LADOK>>
1007
+ @
1008
+ <<pull student contact attributes from LADOK>>=
1009
+ try:
1010
+ contact_data = self.ladok.get_student_contact_data_JSON(self.ladok_id)
1011
+
1012
+ # Extract email address
1013
+ self.__email = None
1014
+ if 'Epost' in contact_data and contact_data['Epost']:
1015
+ for email in contact_data['Epost']:
1016
+ if 'Adress' in email:
1017
+ self.__email = email['Adress']
1018
+ break
1019
+
1020
+ # Extract phone number
1021
+ self.__phone = None
1022
+ if 'Telefon' in contact_data and contact_data['Telefon']:
1023
+ for phone in contact_data['Telefon']:
1024
+ if 'Nummer' in phone:
1025
+ self.__phone = phone['Nummer']
1026
+ break
1027
+
1028
+ # Extract postal address
1029
+ self.__address = []
1030
+ if 'Postadress' in contact_data and contact_data['Postadress']:
1031
+ addr = contact_data['Postadress']
1032
+ if isinstance(addr, list) and len(addr) > 0:
1033
+ addr = addr[0]
1034
+ if isinstance(addr, dict):
1035
+ for field in ['Adressrad1', 'Adressrad2', 'Adressrad3']:
1036
+ if field in addr and addr[field]:
1037
+ self.__address.append(addr[field])
1038
+ if 'Postnummer' in addr and addr['Postnummer'] and 'Postort' in addr and addr['Postort']:
1039
+ self.__address.append(f"{addr['Postnummer']} {addr['Postort']}")
1040
+
1041
+ except Exception:
1042
+ # If contact data can't be retrieved, set default empty values
1043
+ self.__email = None
1044
+ self.__phone = None
1045
+ self.__address = []
1046
+ @
1047
+
1048
+ <<student attribute methods>>=
1049
+ <<student contact attribute methods>>
1050
+ @
1051
+
1052
+
875
1053
  \section{Students' study-related attributes}
876
1054
 
877
1055
  The study related attributes are courses and course results.
@@ -923,9 +1101,62 @@ for course in self.ladok.registrations_JSON(self.ladok_id):
923
1101
 
924
1102
  We also want to know if a student is suspended or not.
925
1103
  We can get a list of suspensions for a student.
926
- However, since suspended students are rare, we haven't found a specimen to
927
- study; so we simply don't know what the list contains.
928
- But we can return it nonetheless.
1104
+ This list looks like this:
1105
+ \begin{minted}{json}
1106
+ [
1107
+ {"Anteckning": "VJ-2024-...",
1108
+ "Giltighetsperiod": {"Slutdatum": "2024-12-03",
1109
+ "Startdatum": "2024-09-25", "link": []},
1110
+ "LarosateID": 29,
1111
+ "OrsakID": 101450,
1112
+ "SenastAndradAv": "someone@kth.se",
1113
+ "SenastSparad": "2024-09-24T16:45:56.941",
1114
+ "StudentUID": "...",
1115
+ "Uid": "...",
1116
+ "link": [{"method": "GET",
1117
+ "uri": "https://api.ladok.se:443/studentinformation/internal/avstangning/...",
1118
+ "mediaType": "application/vnd.ladok+xml",
1119
+ "rel": "self"}]
1120
+ }
1121
+ ]
1122
+ \end{minted}
1123
+ Let's make a class for this.
1124
+ <<classes>>=
1125
+ class Suspension(LadokData):
1126
+ """A suspension"""
1127
+ def __init__(self, /, **kwargs):
1128
+ super().__init__(**kwargs)
1129
+ self.__note = kwargs.pop("Anteckning")
1130
+ self.__validity = (
1131
+ datetime.date.fromisoformat(kwargs["Giltighetsperiod"]["Startdatum"]),
1132
+ datetime.date.fromisoformat(kwargs["Giltighetsperiod"]["Slutdatum"])
1133
+ )
1134
+
1135
+ @property
1136
+ def note(self):
1137
+ """
1138
+ The note of the suspension. This is usually a case number.
1139
+ """
1140
+ return self.__note
1141
+
1142
+ @property
1143
+ def validity(self):
1144
+ """
1145
+ A tuple (start, end) of the validity period of the suspension.
1146
+ """
1147
+ return self.__validity
1148
+
1149
+ @property
1150
+ def is_current(self):
1151
+ """
1152
+ Is True if the suspension is currently valid.
1153
+ """
1154
+ return self.validity[0] <= datetime.date.today() <= self.validity[1]
1155
+
1156
+ def __str__(self):
1157
+ return f"{self.validity[0]}--{self.validity[1]}"
1158
+ @
1159
+
929
1160
  We never cache this result since we always want the most up-to-date version.
930
1161
  <<student attribute methods>>=
931
1162
  @property
@@ -933,7 +1164,9 @@ def suspensions(self):
933
1164
  """
934
1165
  The list of the students' suspensions.
935
1166
  """
936
- return self.ladok.get_student_suspensions_JSON(self.ladok_id)["Avstangning"]
1167
+ suspensions = self.ladok.get_student_suspensions_JSON(
1168
+ self.ladok_id)["Avstangning"]
1169
+ return [Suspension(**suspension) for suspension in suspensions]
937
1170
  @
938
1171
 
939
1172
 
@@ -971,9 +1204,9 @@ ladok = ladok3.LadokSession(
971
1204
  test_environment=True)
972
1205
 
973
1206
  print(r"\begin{minted}{JSON}")
974
- prgi = ladok.search_course_rounds_JSON(code="DD1317")[0]
975
- ladok3.clean_data(prgi)
976
- print(json.dumps(prgi, indent=2))
1207
+ course_round = ladok.search_course_rounds_JSON(code="DD1317")[0]
1208
+ ladok3.clean_data(course_round)
1209
+ print(json.dumps(course_round, indent=2))
977
1210
  print(r"\end{minted}")
978
1211
  \end{pycode}
979
1212
 
@@ -1122,12 +1355,8 @@ By trial-and-error, it seems like the faux courses has none of the attributes
1122
1355
  that the real courses have.
1123
1356
  However, we try our best.
1124
1357
  <<assign common CourseInstance data to private attributes>>=
1125
- if "IngaendeMoment" in data:
1126
- self.__components = [CourseComponent(
1127
- ladok=self.ladok, course=self,
1128
- **component) for component in data["IngaendeMoment"]]
1129
- else:
1130
- self.__components = []
1358
+ self.__components = []
1359
+ <<add course components to [[self.__components]]>>
1131
1360
  <<assign CourseInstance data to private attributes>>=
1132
1361
  <<assign common CourseInstance data to private attributes>>
1133
1362
 
@@ -1203,6 +1432,33 @@ def components(self, /, **kwargs):
1203
1432
  @
1204
1433
 
1205
1434
 
1435
+ \subsection{Course components}
1436
+
1437
+ We need to add the courses components to the course instance.
1438
+ That is things like LAB1 or EXA1 that make up the course in LADOK.
1439
+ <<add course components to [[self.__components]]>>=
1440
+ if "IngaendeMoment" in data:
1441
+ self.__components += [CourseComponent(
1442
+ ladok=self.ladok, course=self,
1443
+ **component) for component in data["IngaendeMoment"]]
1444
+ @
1445
+
1446
+ We also add the course itself as a component.
1447
+ This is to handle the grade on the course itself.
1448
+ Once all the components have a grade, the course itself will get a grade.
1449
+ This component represents that grade.
1450
+ It will get the course code as its code.
1451
+ <<add course components to [[self.__components]]>>=
1452
+ try:
1453
+ course_component_data = data.copy()
1454
+ course_component_data["ladok"] = self.ladok
1455
+ self.__components.append(CourseComponent(course=self,
1456
+ **course_component_data))
1457
+ except KeyError:
1458
+ pass
1459
+ @
1460
+
1461
+
1206
1462
  \section{Course components}
1207
1463
 
1208
1464
  The [[CourseComponent]] class will make the attributes available.
@@ -1415,13 +1671,15 @@ We can pull these from LADOK as well.
1415
1671
  We construct [[CourseResult]] objects using a [[CourseRegistration]] object.
1416
1672
  From the response we get from LADOK, we construct [[CourseResult]] objects that
1417
1673
  deal with the details.
1674
+ (It's a bit unclear why [[Kursversioner]] is a list, so far it has always
1675
+ worked to take the first.)
1418
1676
  <<pull existing CourseResult objects from LADOK>>=
1419
1677
  response = self.ladok.student_results_JSON(
1420
1678
  self.__student.ladok_id, self.education_id
1421
1679
  )["Kursversioner"][0]
1422
1680
 
1423
- #self.__results_id = response["Uid"]
1424
1681
  self.__results = []
1682
+
1425
1683
  for result in response["VersionensModuler"]:
1426
1684
  try:
1427
1685
  self.__results.append(CourseResult(ladok=self.ladok,
@@ -1432,6 +1690,20 @@ for result in response["VersionensModuler"]:
1432
1690
  pass
1433
1691
  @
1434
1692
 
1693
+ As with the components, the course grade itself is a special case.
1694
+ That data is not in [[VersionensModuler]], but in [[VersionensKurs]].
1695
+ But we can do the same with it, just as we could with the components.
1696
+ (And this works since it's a component in [[self.components()]].)
1697
+ <<pull existing CourseResult objects from LADOK>>=
1698
+ try:
1699
+ self.__results.append(CourseResult(ladok=self.ladok,
1700
+ components=self.components(),
1701
+ student=self.__student,
1702
+ **response["VersionensKurs"]))
1703
+ except TypeError:
1704
+ pass
1705
+ @
1706
+
1435
1707
  Now we can see which components from [[self.components]] are missing from
1436
1708
  [[self.__results]] and just add empty results for those.
1437
1709
  <<add new CourseResult objects for missing components>>=
@@ -1629,7 +1901,7 @@ try:
1629
1901
  self.__uid, self.grade.id, self.date.isoformat(), self.__last_modified
1630
1902
  )
1631
1903
  except Exception as err:
1632
- raise Exception(
1904
+ raise LadokError(
1633
1905
  f"couldn't update {self.component.code} to {self.grade} ({self.date})"
1634
1906
  f" to LADOK: {err}"
1635
1907
  )
@@ -1652,7 +1924,7 @@ try:
1652
1924
  self.grade.id, self.date.isoformat()
1653
1925
  )
1654
1926
  except Exception as err:
1655
- raise Exception("Couldn't register "
1927
+ raise LadokError("Couldn't register "
1656
1928
  f"{self.component} {self.grade} {self.date}: {err}")
1657
1929
 
1658
1930
  self.__populate_attributes(**response)