librus_apix.grades

This module defines functions and data classes for retrieving and managing grade-related data from the Librus API.

Classes: - Gpa: Represents the semestral grade for a specific semester and subject. - Grade: Represents a single grade entry with detailed information. - GradeDescriptive: Represents a descriptive grade entry with detailed information.

Functions: - get_grades: Fetches and returns the grades, semestral averages, and descriptive grades from Librus.

Usage:

from librus_apix.grades import get_grades

try:
    # Fetch grades data
    numeric_grades, average_grades, descriptive_grades = get_grades(client, sort_by="all")
    # Process the grades data as required
    ...
except ArgumentError as e:
    # Handle invalid argument error
    ...
except ParseError as e:
    # Handle parse error
    ...
  1"""
  2This module defines functions and data classes for retrieving and managing grade-related data from the Librus API.
  3
  4Classes:
  5    - Gpa: Represents the semestral grade for a specific semester and subject.
  6    - Grade: Represents a single grade entry with detailed information.
  7    - GradeDescriptive: Represents a descriptive grade entry with detailed information.
  8
  9Functions:
 10    - get_grades: Fetches and returns the grades, semestral averages, and descriptive grades from Librus.
 11
 12Usage:
 13```python
 14from librus_apix.grades import get_grades
 15
 16try:
 17    # Fetch grades data
 18    numeric_grades, average_grades, descriptive_grades = get_grades(client, sort_by="all")
 19    # Process the grades data as required
 20    ...
 21except ArgumentError as e:
 22    # Handle invalid argument error
 23    ...
 24except ParseError as e:
 25    # Handle parse error
 26    ...
 27```
 28"""
 29
 30import re
 31from collections import defaultdict
 32from dataclasses import dataclass
 33from typing import DefaultDict, Dict, List, Tuple, Union
 34
 35from bs4 import BeautifulSoup, Tag
 36
 37import librus_apix.urls as urls
 38from librus_apix.client import Client
 39from librus_apix.exceptions import ArgumentError, ParseError
 40from librus_apix.helpers import no_access_check
 41
 42
 43@dataclass
 44class Gpa:
 45    """
 46    Represents the Semestral Grade for a specific semester and subject.
 47
 48    Attributes:
 49        semester (int): The semester number (e.g., 1 for first semester, 2 for second semester).
 50        gpa (float | str): The GPA value, which can be a float or a "-" string meaning it's empty.
 51        subject (str): The subject for which the GPA is calculated.
 52    """
 53
 54    semester: int
 55    gpa: float | str
 56    subject: str
 57
 58
 59@dataclass
 60class Grade:
 61    """
 62    Represents a single grade entry with detailed information.
 63
 64    Attributes:
 65        title (str): The title of the grade.
 66        grade (str): The grade string value (e.g., '2', '4+', etc.).
 67        value (float | str): Property function. Returns calculated float of grade. (e.g., '4.5 for 4+', '2.75 for 3-'),
 68            or the raw grade string if it isn't numeric (e.g., '85%', '85/100').
 69        counts (bool): Indicates whether the grade counts towards the GPA.
 70        date (str): The date when the grade was given.
 71        href (str): A URL suffix associated with the grade.
 72        desc (str): A detailed description of the grade.
 73        semester (int): The semester number (e.g., 1 for first semester, 2 for second semester).
 74        category (str): The category of the grade (e.g., 'Homework', 'Exam').
 75        teacher (str): The name of the teacher who gave the grade.
 76        weight (int): The weight of the grade in calculating the final score.
 77    """
 78
 79    title: str
 80    grade: str
 81    counts: bool
 82    date: str
 83    href: str
 84    desc: str
 85    semester: int
 86    category: str
 87    teacher: str
 88    weight: int
 89
 90    @property
 91    def value(self) -> Union[float, str]:
 92        """
 93        Calculates and returns the numeric value of the grade based on its string representation.
 94
 95        Returns:
 96            Union[float, str]: The numeric value of the grade, a string indicating it doesn't count,
 97                or the raw grade string if it can't be converted (ex. '85%', '85/100', 'A+').
 98        """
 99        if self.counts is False:
100            return "Does not count"
101        modified = re.fullmatch(r"(\d)([+-])", self.grade)
102        if modified is not None:
103            base, modifier = modified.groups()
104            return float(base) + (0.5 if modifier == "+" else -0.25)
105        try:
106            return float(self.grade.replace(",", "."))
107        except ValueError:
108            return self.grade
109
110
111@dataclass
112class GradeDescriptive:
113    title: str
114    grade: str
115    date: str
116    href: str
117    desc: str
118    semester: int
119    teacher: str
120
121
122def get_grades(client: Client, sort_by: str = "all") -> Tuple[
123    List[DefaultDict[str, List[Grade]]],
124    DefaultDict[str, List[Gpa]],
125    List[DefaultDict[str, List[GradeDescriptive]]],
126]:
127    """
128    Fetches and returns the grades, semestral averages and descriptive grades from librus.
129
130    Args:
131        client (Client): The client object used to interact with the server.
132        sort_by (str): The criteria to sort grades. Can be 'all', 'week', or 'last_login'.
133
134    Returns:
135        Tuple: A tuple containing lists of numeric and descriptive grades, and GPA information.
136
137    Raises:
138        ArgumentError: If an invalid sort_by value is provided.
139        ParseError: If there is an error in parsing the grades.
140    """
141    SORT = {
142        "all": "zmiany_logowanie_wszystkie",
143        "week": "zmiany_logowanie_tydzien",
144        "last_login": "zmiany_logowanie",
145    }
146    if sort_by not in SORT.keys():
147        raise ArgumentError(
148            "Wrong value for sort_by it can be either all, week or last_login"
149        )
150
151    soup = no_access_check(
152        BeautifulSoup(
153            client.post(client.GRADES_URL, data={SORT[sort_by]: "1"}).text,
154            "lxml",
155        )
156    )
157    tr = soup.find_all("tr", attrs={"class": ["line0", "line1"], "id": None})
158    if len(tr) < 1:
159        raise ParseError("Error in parsing grades")
160
161    sem_grades, avg_grades = _extract_grades_numeric(tr)
162    sem_grades_desc = _extract_grades_descriptive(tr)
163
164    # New descriptive grading schema: the page contains only an empty table,
165    # grades are loaded by JavaScript from the gateway API.
166    if soup.select_one("table.newSchemaGradesByStudentTable") is not None:
167        try:
168            api_grades_desc = _extract_grades_descriptive_from_api(
169                client, soup.select("table.newSchemaGradesByStudentTable tr.studentRow")
170            )
171        except Exception:
172            # optional data - never break get_grades because of the API
173            api_grades_desc = []
174        for sem_index, semester in enumerate(api_grades_desc):
175            for subject, grades in semester.items():
176                if not sem_grades_desc[sem_index][subject]:
177                    sem_grades_desc[sem_index][subject] = grades
178
179    return sem_grades, avg_grades, sem_grades_desc
180
181
182def _handle_subject(semester_grades) -> str:
183    return semester_grades[0].text.replace("\n", "").strip()
184
185
186def _get_desc_and_counts(a: Tag, grade: str, subject: str) -> Tuple[str, bool]:
187    desc = f"Ocena: {grade}\nPrzedmiot: {subject}\n"
188    desc += re.sub(
189        r"<br*>",
190        "\n",
191        a.attrs.get("title", "").replace("<br/>", "").replace("<br />", "\n"),
192    )
193    gpacount = re.search("Licz do średniej: [a-zA-Z]{3}", desc)
194    counts = False
195    if gpacount is not None:
196        pair = gpacount[0].split(": ")
197        if len(pair) >= 2 and pair[1] == "tak":
198            counts = True
199    return desc, counts
200
201
202def _extract_grade_info(
203    a: Tag, subject: str
204) -> Tuple[str, str, str, str, bool, str, str, int]:
205    date = re.search("Data:.{11}", a.attrs.get("title", ""))
206    if date is None:
207        raise ParseError("Error in getting grade's date.")
208
209    attr_dict = {}
210    for attr in a.attrs["title"].replace("<br/>", "<br>").split("<br>"):
211        if len(attr.strip()) >= 2:
212            key, value = attr.split(": ", 1)
213            attr_dict[key] = value
214    category: str = attr_dict.get("Kategoria", "")
215    teacher: str = attr_dict.get("Nauczyciel", "")
216    weight: int = int(attr_dict.get("Waga", 0))
217
218    grade = a.text.replace("\xa0", "").replace("\n", "")
219    desc, counts = _get_desc_and_counts(a, grade, subject)
220    date = date.group().split(" ")
221    date = date[1] if len(date) >= 2 else " ".join(date)
222    return (
223        grade,
224        date,
225        a.attrs.get("href", ""),
226        desc,
227        counts,
228        category,
229        teacher,
230        weight,
231    )
232
233
234def _extract_grades_numeric(
235    table_rows: List[Tag],
236) -> Tuple[List[DefaultDict[str, List[Grade]]], DefaultDict[str, List[Gpa]]]:
237    # list containing two dicts (for each semester)
238    # key of each semester dict is subject, in each subject there is list of grades
239    sem_grades: List[DefaultDict[str, List[Grade]]] = [
240        defaultdict(list) for _ in range(2)
241    ]  # 2 semesters
242    avg_grades: DefaultDict[str, List[Gpa]] = defaultdict(list)
243
244    for box in table_rows:
245        if box.select_one("td[class='center micro screen-only']") is None:
246            # row without grade data - skip
247            continue
248        semester_grades = box.select('td:not([class="center micro screen-only"])')
249        if len(semester_grades) < 9:
250            continue
251        average_grades = list(map(lambda x: x.text, box.select("td.right")))
252        semesters = [semester_grades[1:4], semester_grades[4:7]]
253        subject = _handle_subject(semester_grades)
254        for semester_number, semester in enumerate(semesters):
255            if subject not in sem_grades[semester_number]:
256                sem_grades[semester_number][subject] = []
257            for sg in semester:
258                grade_a_improved = sg.select(
259                    "td:not([class='center']) > span > span.grade-box > a"
260                )
261                grade_a = (
262                    sg.select("td:not([class='center']) > span.grade-box > a")
263                    + grade_a_improved
264                )
265                for a in grade_a:
266                    (
267                        _grade,
268                        date,
269                        _href,
270                        desc,
271                        counts,
272                        category,
273                        teacher,
274                        weight,
275                    ) = _extract_grade_info(a, subject)
276                    g = Grade(
277                        subject,
278                        _grade,
279                        counts,
280                        date,
281                        a.attrs.get("href", ""),
282                        desc,
283                        semester_number + 1,
284                        category,
285                        teacher,
286                        weight,
287                    )
288                    sem_grades[semester_number][subject].append(g)
289            avg_gr = (
290                average_grades[semester_number]
291                if len(average_grades) > semester_number
292                else 0.0
293            )  # might happen that the list is empty
294            gpa = Gpa(semester_number + 1, avg_gr, subject)
295            avg_grades[subject].append(gpa)
296        avg_gr = (
297            average_grades[-1] if len(average_grades) > 0 else 0.0
298        )  # might happen that the list is empty
299        avg_grades[subject].append(Gpa(0, avg_gr, subject))
300
301    return sem_grades, avg_grades
302
303
304def _extract_grades_descriptive(
305    table_rows: List[Tag],
306) -> List[DefaultDict[str, List[GradeDescriptive]]]:
307    # list containing two dicts (for each semester)
308    # key of each semester dict is subject, in each subject there is list of grades
309    sem_grades_desc: List[DefaultDict[str, List[GradeDescriptive]]] = [
310        defaultdict(list) for _ in range(2)
311    ]  # 2 semesters
312
313    for box in table_rows:
314        if box.select_one("td[class='micro center screen-only']") is None:
315            # row without descriptive grade data - skip
316            continue
317        semester_grades = box.select('td:not([class="micro center screen-only"])')
318        if len(semester_grades) < 3:
319            continue
320        semesters = [semester_grades[1], semester_grades[2]]
321        subject = semester_grades[0].text.replace("\n", "").strip()
322        for sem_index, sg in enumerate(semesters):
323            grade_elements = sg.select(
324                "td:not([class='center']) > span.grade-box > a, "
325                "span.grade-box > span.ocena"
326            )
327            for element in grade_elements:
328                grade_tag = (
329                    element.parent
330                    if element.name == "span" and "ocena" in element.get("class", [])
331                    else element
332                )
333                (
334                    _grade,
335                    date,
336                    href,
337                    desc,
338                    _,
339                    _category,
340                    teacher,
341                    _weight,
342                ) = _extract_grade_info(grade_tag, subject)
343                if "javascript" in href:
344                    # javascript content is not standard href - clear it
345                    href = ""
346                g = GradeDescriptive(
347                    subject, _grade, date, href, desc, sem_index + 1, teacher
348                )
349                sem_grades_desc[sem_index][subject].append(g)
350
351    # get semester descriptive grade
352    found_grade = False
353    summary_title = ""
354    summary_desc = ""
355    summary_date = ""
356    summary_teacher = ""
357
358    parse_next_row = False
359    for box in table_rows:
360        if parse_next_row:
361            parse_next_row = False
362            paragraphs = box.find_all("p")
363            text_list = [par.text.strip() for par in paragraphs]
364            summary_desc = "\n".join(text_list).strip()
365            found_grade = True
366            # description found - break
367            # There is no more grades for now (for first semester). Maybe there will be grade for
368            # second semester, but the format (structure) of web page is unknown for the moment.
369            # #TODO: implement the case for second semester (in future)
370            break
371
372        header = box.select_one("th")
373        if header and header.select_one("strong") is not None:
374            # header row found - next row will contain the description
375            parse_next_row = True
376            title_tag = header.select_one("strong")
377            if title_tag is None:
378                continue
379            info = title_tag.next_sibling
380            if info is None:
381                continue
382            summary_title = title_tag.text.strip()
383            summary_date = re.findall(r"opublikowano: (.+?) ", info.text)[
384                0
385            ]  # get date only
386            summary_teacher = re.findall(r"nauczyciel: (.+?)\)", info.text)[0]
387
388    if found_grade:
389        sem_index = 0
390        semester_summary = GradeDescriptive(
391            summary_title,
392            "",
393            summary_date,
394            "",
395            summary_desc,
396            sem_index + 1,
397            summary_teacher,
398        )
399        if summary_title not in sem_grades_desc[sem_index]:
400            sem_grades_desc[sem_index][summary_title] = []
401        sem_grades_desc[sem_index][summary_title].append(semester_summary)
402
403    return sem_grades_desc
404
405
406def _extract_grades_descriptive_from_api(
407    client: Client, subject_rows: List[Tag]
408) -> List[DefaultDict[str, List[GradeDescriptive]]]:
409    """
410    Fetches descriptive grades of the new schema the same way Librus' JavaScript
411    (gradesByStudent.js) does: resolve the student identifier, then
412    POST /gateway/api/2.0/Auth/DescriptiveGradingSystem/PartialGrades/Student/{id}.
413    """
414    sem_grades_desc: List[DefaultDict[str, List[GradeDescriptive]]] = [
415        defaultdict(list) for _ in range(2)
416    ]
417    client.refresh_oauth()  # sets the oauth_token cookie required by the gateway API
418
419    user_id = client.get(urls.GATEWAY_API_TOKEN_INFO).json()["UserIdentifier"]
420    user_info = client.get(urls.GATEWAY_API_USER_INFO + user_id).json()
421    student_id = user_info.get("IdentifierOfStudentAssignedWithUser") or user_id
422
423    response = client.post_json(urls.GATEWAY_API_DESCRIPTIVE_GRADES + student_id)
424    response.raise_for_status()
425    items = response.json().get("data") or []
426    if not items:
427        return sem_grades_desc
428
429    # grade -> subject: identifier -> numericIdentifier (data-subject_id on the page)
430    subjects: Dict[str, Dict] = {
431        s["identifier"]: s for s in client.get(urls.GATEWAY_API_SUBJECTS).json()["data"]
432    }
433    teachers: Dict[str, str] = {
434        u["AccountId"]: f"{u['LastName']} {u['FirstName'] or ''}".strip()
435        for u in client.get(urls.GATEWAY_API_USERS).json()["Users"]
436    }
437    subject_names = {
438        row["data-subject_id"]: row.find_all("td")[1].text.strip() for row in subject_rows
439    }
440
441    for item in items:
442        semester = int(item["semester"])
443        if semester not in (1, 2):
444            continue
445        subject = subjects.get(item["subjectId"], {})
446        title = (
447            subject_names.get(str(subject.get("numericIdentifier")))
448            or subject.get("name")
449            or item["subjectId"]
450        )
451        grade = (item.get("scaleValue") or {}).get("value") or "OO"
452        teacher = teachers.get(item.get("teacherId"), "")
453        date = item.get("date", "")[:10]
454        desc = (
455            f"Ocena: {grade}\n"
456            f"Przedmiot: {title}\n"
457            f"Obszar: {(item.get('area') or {}).get('name', '')}\n"
458            f"Data: {date}\n"
459            f"Nauczyciel: {teacher}\n"
460            f"Treść oceny: {item.get('content') or ''}\n"
461            f"Dodał: {teachers.get(item.get('addedBy'), '')}\n"
462            f"Komentarz: {item.get('comments') or ''}\n"
463        )
464        sem_grades_desc[semester - 1][title].append(
465            GradeDescriptive(title, grade, date, "", desc, semester, teacher)
466        )
467    return sem_grades_desc
@dataclass
class Gpa:
44@dataclass
45class Gpa:
46    """
47    Represents the Semestral Grade for a specific semester and subject.
48
49    Attributes:
50        semester (int): The semester number (e.g., 1 for first semester, 2 for second semester).
51        gpa (float | str): The GPA value, which can be a float or a "-" string meaning it's empty.
52        subject (str): The subject for which the GPA is calculated.
53    """
54
55    semester: int
56    gpa: float | str
57    subject: str

Represents the Semestral Grade for a specific semester and subject.

Attributes: semester (int): The semester number (e.g., 1 for first semester, 2 for second semester). gpa (float | str): The GPA value, which can be a float or a "-" string meaning it's empty. subject (str): The subject for which the GPA is calculated.

Gpa(semester: int, gpa: float | str, subject: str)
semester: int
gpa: float | str
subject: str
@dataclass
class Grade:
 60@dataclass
 61class Grade:
 62    """
 63    Represents a single grade entry with detailed information.
 64
 65    Attributes:
 66        title (str): The title of the grade.
 67        grade (str): The grade string value (e.g., '2', '4+', etc.).
 68        value (float | str): Property function. Returns calculated float of grade. (e.g., '4.5 for 4+', '2.75 for 3-'),
 69            or the raw grade string if it isn't numeric (e.g., '85%', '85/100').
 70        counts (bool): Indicates whether the grade counts towards the GPA.
 71        date (str): The date when the grade was given.
 72        href (str): A URL suffix associated with the grade.
 73        desc (str): A detailed description of the grade.
 74        semester (int): The semester number (e.g., 1 for first semester, 2 for second semester).
 75        category (str): The category of the grade (e.g., 'Homework', 'Exam').
 76        teacher (str): The name of the teacher who gave the grade.
 77        weight (int): The weight of the grade in calculating the final score.
 78    """
 79
 80    title: str
 81    grade: str
 82    counts: bool
 83    date: str
 84    href: str
 85    desc: str
 86    semester: int
 87    category: str
 88    teacher: str
 89    weight: int
 90
 91    @property
 92    def value(self) -> Union[float, str]:
 93        """
 94        Calculates and returns the numeric value of the grade based on its string representation.
 95
 96        Returns:
 97            Union[float, str]: The numeric value of the grade, a string indicating it doesn't count,
 98                or the raw grade string if it can't be converted (ex. '85%', '85/100', 'A+').
 99        """
100        if self.counts is False:
101            return "Does not count"
102        modified = re.fullmatch(r"(\d)([+-])", self.grade)
103        if modified is not None:
104            base, modifier = modified.groups()
105            return float(base) + (0.5 if modifier == "+" else -0.25)
106        try:
107            return float(self.grade.replace(",", "."))
108        except ValueError:
109            return self.grade

Represents a single grade entry with detailed information.

Attributes: title (str): The title of the grade. grade (str): The grade string value (e.g., '2', '4+', etc.). value (float | str): Property function. Returns calculated float of grade. (e.g., '4.5 for 4+', '2.75 for 3-'), or the raw grade string if it isn't numeric (e.g., '85%', '85/100'). counts (bool): Indicates whether the grade counts towards the GPA. date (str): The date when the grade was given. href (str): A URL suffix associated with the grade. desc (str): A detailed description of the grade. semester (int): The semester number (e.g., 1 for first semester, 2 for second semester). category (str): The category of the grade (e.g., 'Homework', 'Exam'). teacher (str): The name of the teacher who gave the grade. weight (int): The weight of the grade in calculating the final score.

Grade( title: str, grade: str, counts: bool, date: str, href: str, desc: str, semester: int, category: str, teacher: str, weight: int)
title: str
grade: str
counts: bool
date: str
href: str
desc: str
semester: int
category: str
teacher: str
weight: int
value: Union[float, str]
 91    @property
 92    def value(self) -> Union[float, str]:
 93        """
 94        Calculates and returns the numeric value of the grade based on its string representation.
 95
 96        Returns:
 97            Union[float, str]: The numeric value of the grade, a string indicating it doesn't count,
 98                or the raw grade string if it can't be converted (ex. '85%', '85/100', 'A+').
 99        """
100        if self.counts is False:
101            return "Does not count"
102        modified = re.fullmatch(r"(\d)([+-])", self.grade)
103        if modified is not None:
104            base, modifier = modified.groups()
105            return float(base) + (0.5 if modifier == "+" else -0.25)
106        try:
107            return float(self.grade.replace(",", "."))
108        except ValueError:
109            return self.grade

Calculates and returns the numeric value of the grade based on its string representation.

Returns: Union[float, str]: The numeric value of the grade, a string indicating it doesn't count, or the raw grade string if it can't be converted (ex. '85%', '85/100', 'A+').

@dataclass
class GradeDescriptive:
112@dataclass
113class GradeDescriptive:
114    title: str
115    grade: str
116    date: str
117    href: str
118    desc: str
119    semester: int
120    teacher: str
GradeDescriptive( title: str, grade: str, date: str, href: str, desc: str, semester: int, teacher: str)
title: str
grade: str
date: str
href: str
desc: str
semester: int
teacher: str
def get_grades( client: librus_apix.client.Client, sort_by: str = 'all') -> Tuple[List[DefaultDict[str, List[Grade]]], DefaultDict[str, List[Gpa]], List[DefaultDict[str, List[GradeDescriptive]]]]:
123def get_grades(client: Client, sort_by: str = "all") -> Tuple[
124    List[DefaultDict[str, List[Grade]]],
125    DefaultDict[str, List[Gpa]],
126    List[DefaultDict[str, List[GradeDescriptive]]],
127]:
128    """
129    Fetches and returns the grades, semestral averages and descriptive grades from librus.
130
131    Args:
132        client (Client): The client object used to interact with the server.
133        sort_by (str): The criteria to sort grades. Can be 'all', 'week', or 'last_login'.
134
135    Returns:
136        Tuple: A tuple containing lists of numeric and descriptive grades, and GPA information.
137
138    Raises:
139        ArgumentError: If an invalid sort_by value is provided.
140        ParseError: If there is an error in parsing the grades.
141    """
142    SORT = {
143        "all": "zmiany_logowanie_wszystkie",
144        "week": "zmiany_logowanie_tydzien",
145        "last_login": "zmiany_logowanie",
146    }
147    if sort_by not in SORT.keys():
148        raise ArgumentError(
149            "Wrong value for sort_by it can be either all, week or last_login"
150        )
151
152    soup = no_access_check(
153        BeautifulSoup(
154            client.post(client.GRADES_URL, data={SORT[sort_by]: "1"}).text,
155            "lxml",
156        )
157    )
158    tr = soup.find_all("tr", attrs={"class": ["line0", "line1"], "id": None})
159    if len(tr) < 1:
160        raise ParseError("Error in parsing grades")
161
162    sem_grades, avg_grades = _extract_grades_numeric(tr)
163    sem_grades_desc = _extract_grades_descriptive(tr)
164
165    # New descriptive grading schema: the page contains only an empty table,
166    # grades are loaded by JavaScript from the gateway API.
167    if soup.select_one("table.newSchemaGradesByStudentTable") is not None:
168        try:
169            api_grades_desc = _extract_grades_descriptive_from_api(
170                client, soup.select("table.newSchemaGradesByStudentTable tr.studentRow")
171            )
172        except Exception:
173            # optional data - never break get_grades because of the API
174            api_grades_desc = []
175        for sem_index, semester in enumerate(api_grades_desc):
176            for subject, grades in semester.items():
177                if not sem_grades_desc[sem_index][subject]:
178                    sem_grades_desc[sem_index][subject] = grades
179
180    return sem_grades, avg_grades, sem_grades_desc

Fetches and returns the grades, semestral averages and descriptive grades from librus.

Args: client (Client): The client object used to interact with the server. sort_by (str): The criteria to sort grades. Can be 'all', 'week', or 'last_login'.

Returns: Tuple: A tuple containing lists of numeric and descriptive grades, and GPA information.

Raises: ArgumentError: If an invalid sort_by value is provided. ParseError: If there is an error in parsing the grades.