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, List, Tuple, Union
 34
 35from bs4 import BeautifulSoup, Tag
 36
 37from librus_apix.client import Client
 38from librus_apix.exceptions import ArgumentError, ParseError
 39from librus_apix.helpers import no_access_check
 40
 41
 42@dataclass
 43class Gpa:
 44    """
 45    Represents the Semestral Grade for a specific semester and subject.
 46
 47    Attributes:
 48        semester (int): The semester number (e.g., 1 for first semester, 2 for second semester).
 49        gpa (float | str): The GPA value, which can be a float or a "-" string meaning it's empty.
 50        subject (str): The subject for which the GPA is calculated.
 51    """
 52
 53    semester: int
 54    gpa: float | str
 55    subject: str
 56
 57
 58@dataclass
 59class Grade:
 60    """
 61    Represents a single grade entry with detailed information.
 62
 63    Attributes:
 64        title (str): The title of the grade.
 65        grade (str): The grade string value (e.g., '2', '4+', etc.).
 66        value (float): Property function. Returns calculated float of grade. (e.g., '4.5 for 4+', '2.75 for 3-')
 67        counts (bool): Indicates whether the grade counts towards the GPA.
 68        date (str): The date when the grade was given.
 69        href (str): A URL suffix associated with the grade.
 70        desc (str): A detailed description of the grade.
 71        semester (int): The semester number (e.g., 1 for first semester, 2 for second semester).
 72        category (str): The category of the grade (e.g., 'Homework', 'Exam').
 73        teacher (str): The name of the teacher who gave the grade.
 74        weight (int): The weight of the grade in calculating the final score.
 75    """
 76
 77    title: str
 78    grade: str
 79    counts: bool
 80    date: str
 81    href: str
 82    desc: str
 83    semester: int
 84    category: str
 85    teacher: str
 86    weight: int
 87
 88    @property
 89    def value(self) -> Union[float, str]:
 90        """
 91        Calculates and returns the numeric value of the grade based on its string representation.
 92
 93        Returns:
 94            Union[float, str]: The numeric value of the grade or a string indicating it doesn't count.
 95        Raises:
 96            ValueError: if grade's format is invalid ex. A+, B+ instead of 5+, 4+
 97        """
 98        if self.counts is False:
 99            return "Does not count"
100        try:
101            if len(self.grade) > 1:
102                grade_value = float(self.grade[0]) + float(
103                    self.grade[1].replace("+", ".5").replace("-", "-0.25")
104                )
105            else:
106                grade_value = float(self.grade)
107            return grade_value
108        except ValueError:
109            raise ValueError("Invalid grade format in .value property func")
110
111
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
121
122
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    tr = no_access_check(
153        BeautifulSoup(
154            client.post(client.GRADES_URL, data={SORT[sort_by]: "1"}).text,
155            "lxml",
156        )
157    ).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    return sem_grades, avg_grades, sem_grades_desc
164
165
166def _handle_subject(semester_grades) -> str:
167    return semester_grades[0].text.replace("\n", "").strip()
168
169
170def _get_desc_and_counts(a: Tag, grade: str, subject: str) -> Tuple[str, bool]:
171    desc = f"Ocena: {grade}\nPrzedmiot: {subject}\n"
172    desc += re.sub(
173        r"<br*>",
174        "\n",
175        a.attrs.get("title", "").replace("<br/>", "").replace("<br />", "\n"),
176    )
177    gpacount = re.search("Licz do średniej: [a-zA-Z]{3}", desc)
178    counts = False
179    if gpacount is not None:
180        pair = gpacount[0].split(": ")
181        if len(pair) >= 2 and pair[1] == "tak":
182            counts = True
183    return desc, counts
184
185
186def _extract_grade_info(
187    a: Tag, subject: str
188) -> Tuple[str, str, str, str, bool, str, str, int]:
189    date = re.search("Data:.{11}", a.attrs.get("title", ""))
190    if date is None:
191        raise ParseError("Error in getting grade's date.")
192
193    attr_dict = {}
194    for attr in a.attrs["title"].replace("<br/>", "<br>").split("<br>"):
195        if len(attr.strip()) >= 2:
196            key, value = attr.split(": ", 1)
197            attr_dict[key] = value
198    category: str = attr_dict.get("Kategoria", "")
199    teacher: str = attr_dict.get("Nauczyciel", "")
200    weight: int = int(attr_dict.get("Waga", 0))
201
202    grade = a.text.replace("\xa0", "").replace("\n", "")
203    desc, counts = _get_desc_and_counts(a, grade, subject)
204    date = date.group().split(" ")
205    date = date[1] if len(date) >= 2 else " ".join(date)
206    return (
207        grade,
208        date,
209        a.attrs.get("href", ""),
210        desc,
211        counts,
212        category,
213        teacher,
214        weight,
215    )
216
217
218def _extract_grades_numeric(
219    table_rows: List[Tag],
220) -> Tuple[List[DefaultDict[str, List[Grade]]], DefaultDict[str, List[Gpa]]]:
221    # list containing two dicts (for each semester)
222    # key of each semester dict is subject, in each subject there is list of grades
223    sem_grades: List[DefaultDict[str, List[Grade]]] = [
224        defaultdict(list) for _ in range(2)
225    ]  # 2 semesters
226    avg_grades: DefaultDict[str, List[Gpa]] = defaultdict(list)
227
228    for box in table_rows:
229        if box.select_one("td[class='center micro screen-only']") is None:
230            # row without grade data - skip
231            continue
232        semester_grades = box.select('td[class!="center micro screen-only"]')
233        if len(semester_grades) < 9:
234            continue
235        average_grades = list(map(lambda x: x.text, box.select("td.right")))
236        semesters = [semester_grades[1:4], semester_grades[4:7]]
237        subject = _handle_subject(semester_grades)
238        for semester_number, semester in enumerate(semesters):
239            if subject not in sem_grades[semester_number]:
240                sem_grades[semester_number][subject] = []
241            for sg in semester:
242                grade_a_improved = sg.select(
243                    "td[class!='center'] > span > span.grade-box > a"
244                )
245                grade_a = (
246                    sg.select("td[class!='center'] > span.grade-box > a")
247                    + grade_a_improved
248                )
249                for a in grade_a:
250                    (
251                        _grade,
252                        date,
253                        _href,
254                        desc,
255                        counts,
256                        category,
257                        teacher,
258                        weight,
259                    ) = _extract_grade_info(a, subject)
260                    g = Grade(
261                        subject,
262                        _grade,
263                        counts,
264                        date,
265                        a.attrs.get("href", ""),
266                        desc,
267                        semester_number + 1,
268                        category,
269                        teacher,
270                        weight,
271                    )
272                    sem_grades[semester_number][subject].append(g)
273            avg_gr = (
274                average_grades[semester_number]
275                if len(average_grades) >= semester_number
276                else 0.0
277            )  # might happen that the list is empty
278            gpa = Gpa(semester_number + 1, avg_gr, subject)
279            avg_grades[subject].append(gpa)
280        avg_gr = (
281            average_grades[-1] if len(average_grades) > 0 else 0.0
282        )  # might happen that the list is empty
283        avg_grades[subject].append(Gpa(0, avg_gr, subject))
284
285    return sem_grades, avg_grades
286
287
288def _extract_grades_descriptive(
289    table_rows: List[Tag],
290) -> List[DefaultDict[str, List[GradeDescriptive]]]:
291    # list containing two dicts (for each semester)
292    # key of each semester dict is subject, in each subject there is list of grades
293    sem_grades_desc: List[DefaultDict[str, List[GradeDescriptive]]] = [
294        defaultdict(list) for _ in range(2)
295    ]  # 2 semesters
296
297    for box in table_rows:
298        if box.select_one("td[class='micro center screen-only']") is None:
299            # row without descriptive grade data - skip
300            continue
301        semester_grades = box.select('td[class!="micro center screen-only"]')
302        if len(semester_grades) < 3:
303            continue
304        semesters = [semester_grades[1], semester_grades[2]]
305        subject = semester_grades[0].text.replace("\n", "").strip()
306        for sem_index, sg in enumerate(semesters):
307            grade_a = sg.select("td[class!='center'] > span.grade-box > a")
308            for a in grade_a:
309                (
310                    _grade,
311                    date,
312                    href,
313                    desc,
314                    _,
315                    _category,
316                    teacher,
317                    _weight,
318                ) = _extract_grade_info(a, subject)
319                if "javascript" in href:
320                    # javascript content is not standard href - clear it
321                    href = ""
322                g = GradeDescriptive(
323                    subject, _grade, date, href, desc, sem_index + 1, teacher
324                )
325                sem_grades_desc[sem_index][subject].append(g)
326
327    # get semester descriptive grade
328    found_grade = False
329    summary_title = ""
330    summary_desc = ""
331    summary_date = ""
332    summary_teacher = ""
333
334    parse_next_row = False
335    for box in table_rows:
336        if parse_next_row:
337            parse_next_row = False
338            paragraphs = box.find_all("p")
339            text_list = [par.text.strip() for par in paragraphs]
340            summary_desc = "\n".join(text_list).strip()
341            found_grade = True
342            # description found - break
343            # There is no more grades for now (for first semester). Maybe there will be grade for
344            # second semester, but the format (structure) of web page is unknown for the moment.
345            # #TODO: implement the case for second semester (in future)
346            break
347
348        header = box.select_one("th")
349        if header and header.select_one("strong") is not None:
350            # header row found - next row will contain the description
351            parse_next_row = True
352            title_tag = header.select_one("strong")
353            if title_tag is None:
354                continue
355            info = title_tag.next_sibling
356            if info is None:
357                continue
358            summary_title = title_tag.text.strip()
359            summary_date = re.findall(r"opublikowano: (.+?) ", info.text)[
360                0
361            ]  # get date only
362            summary_teacher = re.findall(r"nauczyciel: (.+?)\)", info.text)[0]
363
364    if found_grade:
365        sem_index = 0
366        semester_summary = GradeDescriptive(
367            summary_title,
368            "",
369            summary_date,
370            "",
371            summary_desc,
372            sem_index + 1,
373            summary_teacher,
374        )
375        if summary_title not in sem_grades_desc[sem_index]:
376            sem_grades_desc[sem_index][summary_title] = []
377        sem_grades_desc[sem_index][summary_title].append(semester_summary)
378
379    return sem_grades_desc
@dataclass
class Gpa:
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

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:
 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): Property function. Returns calculated float of grade. (e.g., '4.5 for 4+', '2.75 for 3-')
 68        counts (bool): Indicates whether the grade counts towards the GPA.
 69        date (str): The date when the grade was given.
 70        href (str): A URL suffix associated with the grade.
 71        desc (str): A detailed description of the grade.
 72        semester (int): The semester number (e.g., 1 for first semester, 2 for second semester).
 73        category (str): The category of the grade (e.g., 'Homework', 'Exam').
 74        teacher (str): The name of the teacher who gave the grade.
 75        weight (int): The weight of the grade in calculating the final score.
 76    """
 77
 78    title: str
 79    grade: str
 80    counts: bool
 81    date: str
 82    href: str
 83    desc: str
 84    semester: int
 85    category: str
 86    teacher: str
 87    weight: int
 88
 89    @property
 90    def value(self) -> Union[float, str]:
 91        """
 92        Calculates and returns the numeric value of the grade based on its string representation.
 93
 94        Returns:
 95            Union[float, str]: The numeric value of the grade or a string indicating it doesn't count.
 96        Raises:
 97            ValueError: if grade's format is invalid ex. A+, B+ instead of 5+, 4+
 98        """
 99        if self.counts is False:
100            return "Does not count"
101        try:
102            if len(self.grade) > 1:
103                grade_value = float(self.grade[0]) + float(
104                    self.grade[1].replace("+", ".5").replace("-", "-0.25")
105                )
106            else:
107                grade_value = float(self.grade)
108            return grade_value
109        except ValueError:
110            raise ValueError("Invalid grade format in .value property func")

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): Property function. Returns calculated float of grade. (e.g., '4.5 for 4+', '2.75 for 3-') 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]
 89    @property
 90    def value(self) -> Union[float, str]:
 91        """
 92        Calculates and returns the numeric value of the grade based on its string representation.
 93
 94        Returns:
 95            Union[float, str]: The numeric value of the grade or a string indicating it doesn't count.
 96        Raises:
 97            ValueError: if grade's format is invalid ex. A+, B+ instead of 5+, 4+
 98        """
 99        if self.counts is False:
100            return "Does not count"
101        try:
102            if len(self.grade) > 1:
103                grade_value = float(self.grade[0]) + float(
104                    self.grade[1].replace("+", ".5").replace("-", "-0.25")
105                )
106            else:
107                grade_value = float(self.grade)
108            return grade_value
109        except ValueError:
110            raise ValueError("Invalid grade format in .value property func")

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

Returns: Union[float, str]: The numeric value of the grade or a string indicating it doesn't count. Raises: ValueError: if grade's format is invalid ex. A+, B+ instead of 5+, 4+

@dataclass
class GradeDescriptive:
113@dataclass
114class GradeDescriptive:
115    title: str
116    grade: str
117    date: str
118    href: str
119    desc: str
120    semester: int
121    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]]]]:
124def get_grades(client: Client, sort_by: str = "all") -> Tuple[
125    List[DefaultDict[str, List[Grade]]],
126    DefaultDict[str, List[Gpa]],
127    List[DefaultDict[str, List[GradeDescriptive]]],
128]:
129    """
130    Fetches and returns the grades, semestral averages and descriptive grades from librus.
131
132    Args:
133        client (Client): The client object used to interact with the server.
134        sort_by (str): The criteria to sort grades. Can be 'all', 'week', or 'last_login'.
135
136    Returns:
137        Tuple: A tuple containing lists of numeric and descriptive grades, and GPA information.
138
139    Raises:
140        ArgumentError: If an invalid sort_by value is provided.
141        ParseError: If there is an error in parsing the grades.
142    """
143    SORT = {
144        "all": "zmiany_logowanie_wszystkie",
145        "week": "zmiany_logowanie_tydzien",
146        "last_login": "zmiany_logowanie",
147    }
148    if sort_by not in SORT.keys():
149        raise ArgumentError(
150            "Wrong value for sort_by it can be either all, week or last_login"
151        )
152
153    tr = no_access_check(
154        BeautifulSoup(
155            client.post(client.GRADES_URL, data={SORT[sort_by]: "1"}).text,
156            "lxml",
157        )
158    ).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    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.