ladok3 4.13__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/data.py CHANGED
@@ -13,6 +13,19 @@ def filter_rounds(all_rounds, desired_rounds):
13
13
 
14
14
 
15
15
  def extract_data_for_round(ladok, course_round, args):
16
+ """Extract student result data for a specific course round.
17
+
18
+ Processes a course round to extract student results data in CSV format,
19
+ filtering by students and components as specified in args.
20
+
21
+ Args:
22
+ ladok (LadokSession): The LADOK session for data access.
23
+ course_round: The course round object to extract data from.
24
+ args: Command line arguments containing filter options.
25
+
26
+ Returns:
27
+ list: List of result data dictionaries for CSV output.
28
+ """
16
29
  course_start = course_round.start
17
30
  course_length = course_round.end - course_start
18
31
  component = course_round.components()[0]
@@ -60,14 +73,38 @@ def extract_data_for_round(ladok, course_round, args):
60
73
  grade = "-"
61
74
  normalized_date = None
62
75
 
63
- yield student, component, grade, normalized_date
76
+ yield student.ladok_id if args.ladok_id else student, component, grade, (
77
+ normalized_date
78
+ if args.normalize_date
79
+ else result_data["Examinationsdatum"] if result_data else None
80
+ )
64
81
 
65
82
 
66
83
  def filter_student_results(student, results):
84
+ """Filter results for a specific student.
85
+
86
+ Args:
87
+ student: Student object with ladok_id attribute.
88
+ results (list): List of result dictionaries from LADOK.
89
+
90
+ Returns:
91
+ list: Filtered list containing only results for the specified student.
92
+ """
67
93
  return list(filter(lambda x: x["Student"]["Uid"] == student.ladok_id, results))
68
94
 
69
95
 
70
96
  def filter_component_result(component, results):
97
+ """Find the result data for a specific course component.
98
+
99
+ Searches through results to find the entry matching the given component.
100
+
101
+ Args:
102
+ component: Course component object to search for.
103
+ results (list): List of result dictionaries to search through.
104
+
105
+ Returns:
106
+ dict or None: Result data for the component, or None if not found.
107
+ """
71
108
  for component_result in results:
72
109
  if "Arbetsunderlag" in component_result:
73
110
  result_data = component_result["Arbetsunderlag"]
@@ -131,8 +168,16 @@ def has_credit_transfer(results):
131
168
 
132
169
 
133
170
  def add_command_options(parser):
171
+ """Add the 'course' subcommand options to the argument parser.
172
+
173
+ Creates a subparser for the course data extraction command with all
174
+ necessary arguments for filtering and output formatting.
175
+
176
+ Args:
177
+ parser (ArgumentParser): The parent parser to add the subcommand to.
178
+ """
134
179
  data_parser = parser.add_parser(
135
- "data",
180
+ "course",
136
181
  help="Returns course results data in CSV form",
137
182
  description="""
138
183
  Returns the results in CSV form for all first-time registered students.
@@ -151,6 +196,13 @@ def add_command_options(parser):
151
196
  "default is a tab character to be compatible with POSIX commands, "
152
197
  "use `-d,` or `-d ,` to get comma-separated values.",
153
198
  )
199
+
200
+ data_parser.add_argument(
201
+ "-H",
202
+ "--header",
203
+ action="store_true",
204
+ help="Print a header line with the column names.",
205
+ )
154
206
  data_parser.add_argument(
155
207
  "-r",
156
208
  "--rounds",
@@ -158,6 +210,21 @@ def add_command_options(parser):
158
210
  help="The round codes for the rounds to include, "
159
211
  "otherwise all rounds will be included.",
160
212
  )
213
+ data_parser.add_argument(
214
+ "-l",
215
+ "--ladok-id",
216
+ action="store_true",
217
+ help="Use the LADOK ID for the student, "
218
+ "otherwise the student name and personnummer "
219
+ "will be used.",
220
+ )
221
+ data_parser.add_argument(
222
+ "-n",
223
+ "--normalize-date",
224
+ action="store_true",
225
+ help="Normalize the date to the start of the course, "
226
+ "otherwise the date is printed as is.",
227
+ )
161
228
  data_parser.add_argument(
162
229
  "-t",
163
230
  "--time-limit",
@@ -183,12 +250,21 @@ def add_command_options(parser):
183
250
 
184
251
 
185
252
  def command(ladok, args):
253
+ """Execute the course data extraction command.
254
+
255
+ Args:
256
+ ladok (LadokSession): The LADOK session for data access.
257
+ args: Parsed command line arguments containing course and filter options.
258
+ """
186
259
  data_writer = csv.writer(sys.stdout, delimiter=args.delimiter)
187
260
  course_rounds = filter_rounds(
188
261
  ladok.search_course_rounds(code=args.course_code), args.rounds
189
262
  )
190
263
 
191
- data_writer.writerow(["Course", "Round", "Component", "Student", "Grade", "Time"])
264
+ if args.header:
265
+ data_writer.writerow(
266
+ ["Course", "Round", "Component", "Student", "Grade", "Time"]
267
+ )
192
268
  for course_round in course_rounds:
193
269
  data = extract_data_for_round(ladok, course_round, args)
194
270
 
ladok3/ladok.bash CHANGED
@@ -25,22 +25,40 @@ __python_argcomplete_run_inner() {
25
25
 
26
26
  _python_argcomplete() {
27
27
  local IFS=$'\013'
28
- local SUPPRESS_SPACE=0
29
- if compopt +o nospace 2> /dev/null; then
30
- SUPPRESS_SPACE=1
31
- fi
32
- COMPREPLY=( $(IFS="$IFS" \
33
- COMP_LINE="$COMP_LINE" \
34
- COMP_POINT="$COMP_POINT" \
35
- COMP_TYPE="$COMP_TYPE" \
36
- _ARGCOMPLETE_COMP_WORDBREAKS="$COMP_WORDBREAKS" \
37
- _ARGCOMPLETE=1 \
38
- _ARGCOMPLETE_SUPPRESS_SPACE=$SUPPRESS_SPACE \
39
- __python_argcomplete_run "$1") )
40
- if [[ $? != 0 ]]; then
41
- unset COMPREPLY
42
- elif [[ $SUPPRESS_SPACE == 1 ]] && [[ "${COMPREPLY-}" =~ [=/:]$ ]]; then
43
- compopt -o nospace
28
+ local script=""
29
+ if [[ -n "${ZSH_VERSION-}" ]]; then
30
+ local completions
31
+ completions=($(IFS="$IFS" \
32
+ COMP_LINE="$BUFFER" \
33
+ COMP_POINT="$CURSOR" \
34
+ _ARGCOMPLETE=1 \
35
+ _ARGCOMPLETE_SHELL="zsh" \
36
+ _ARGCOMPLETE_SUPPRESS_SPACE=1 \
37
+ __python_argcomplete_run ${script:-${words[1]}}))
38
+ _describe "${words[1]}" completions -o nosort
39
+ else
40
+ local SUPPRESS_SPACE=0
41
+ if compopt +o nospace 2> /dev/null; then
42
+ SUPPRESS_SPACE=1
43
+ fi
44
+ COMPREPLY=($(IFS="$IFS" \
45
+ COMP_LINE="$COMP_LINE" \
46
+ COMP_POINT="$COMP_POINT" \
47
+ COMP_TYPE="$COMP_TYPE" \
48
+ _ARGCOMPLETE_COMP_WORDBREAKS="$COMP_WORDBREAKS" \
49
+ _ARGCOMPLETE=1 \
50
+ _ARGCOMPLETE_SHELL="bash" \
51
+ _ARGCOMPLETE_SUPPRESS_SPACE=$SUPPRESS_SPACE \
52
+ __python_argcomplete_run ${script:-$1}))
53
+ if [[ $? != 0 ]]; then
54
+ unset COMPREPLY
55
+ elif [[ $SUPPRESS_SPACE == 1 ]] && [[ "${COMPREPLY-}" =~ [=/:]$ ]]; then
56
+ compopt -o nospace
57
+ fi
44
58
  fi
45
59
  }
46
- complete -o nospace -o default -o bashdefault -F _python_argcomplete ladok
60
+ if [[ -z "${ZSH_VERSION-}" ]]; then
61
+ complete -o nospace -o default -o bashdefault -F _python_argcomplete ladok
62
+ else
63
+ compdef _python_argcomplete ladok
64
+ fi
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,8 +182,75 @@ 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>>
186
+ @
187
+
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)
172
249
  @
173
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
+
174
254
 
175
255
  \section{The [[LadokSession]] data methods}\label{LadokSession-data-methods}
176
256
 
@@ -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
 
@@ -1668,7 +1901,7 @@ try:
1668
1901
  self.__uid, self.grade.id, self.date.isoformat(), self.__last_modified
1669
1902
  )
1670
1903
  except Exception as err:
1671
- raise Exception(
1904
+ raise LadokError(
1672
1905
  f"couldn't update {self.component.code} to {self.grade} ({self.date})"
1673
1906
  f" to LADOK: {err}"
1674
1907
  )
@@ -1691,7 +1924,7 @@ try:
1691
1924
  self.grade.id, self.date.isoformat()
1692
1925
  )
1693
1926
  except Exception as err:
1694
- raise Exception("Couldn't register "
1927
+ raise LadokError("Couldn't register "
1695
1928
  f"{self.component} {self.grade} {self.date}: {err}")
1696
1929
 
1697
1930
  self.__populate_attributes(**response)