librus-python-api 1.2.1__py3-none-any.whl → 1.4.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.
- librus_python_api/__init__.py +19 -1
- librus_python_api/config.py +49 -0
- librus_python_api/formative_grades.py +173 -0
- librus_python_api/grade_records.py +29 -1
- librus_python_api/markup.py +14 -3
- librus_python_api/models.py +118 -0
- librus_python_api/school_year_archive.py +432 -0
- librus_python_api/service.py +31 -0
- {librus_python_api-1.2.1.dist-info → librus_python_api-1.4.0.dist-info}/METADATA +17 -9
- {librus_python_api-1.2.1.dist-info → librus_python_api-1.4.0.dist-info}/RECORD +12 -10
- {librus_python_api-1.2.1.dist-info → librus_python_api-1.4.0.dist-info}/WHEEL +0 -0
- {librus_python_api-1.2.1.dist-info → librus_python_api-1.4.0.dist-info}/licenses/LICENSE +0 -0
librus_python_api/__init__.py
CHANGED
|
@@ -16,6 +16,13 @@ from librus_python_api.models import (
|
|
|
16
16
|
AgendaEvent,
|
|
17
17
|
Announcement,
|
|
18
18
|
Announcements,
|
|
19
|
+
ArchiveAbsences,
|
|
20
|
+
ArchiveAchievement,
|
|
21
|
+
ArchiveCounts,
|
|
22
|
+
ArchiveDescriptive,
|
|
23
|
+
ArchiveMarks,
|
|
24
|
+
ArchiveSubject,
|
|
25
|
+
ArchiveYear,
|
|
19
26
|
AttachmentHeaders,
|
|
20
27
|
AttachmentMetadata,
|
|
21
28
|
Attendance,
|
|
@@ -37,6 +44,7 @@ from librus_python_api.models import (
|
|
|
37
44
|
DetailField,
|
|
38
45
|
DetailFieldKey,
|
|
39
46
|
FinalGrades,
|
|
47
|
+
FormativeGrade,
|
|
40
48
|
FrequencyMeasure,
|
|
41
49
|
GatewayAttendance,
|
|
42
50
|
GatewayAttendanceRecord,
|
|
@@ -110,6 +118,7 @@ from librus_python_api.models import (
|
|
|
110
118
|
SchoolAverage,
|
|
111
119
|
SchoolDetail,
|
|
112
120
|
SchoolReference,
|
|
121
|
+
SchoolYearArchive,
|
|
113
122
|
SendResult,
|
|
114
123
|
SendStatus,
|
|
115
124
|
SendSubmission,
|
|
@@ -127,9 +136,17 @@ from librus_python_api.models import (
|
|
|
127
136
|
from librus_python_api.sending import SendAttempt
|
|
128
137
|
from librus_python_api.service import AccountClient, LibrusService
|
|
129
138
|
|
|
130
|
-
__version__ = "1.
|
|
139
|
+
__version__ = "1.4.0"
|
|
131
140
|
|
|
132
141
|
__all__ = [
|
|
142
|
+
"ArchiveAbsences",
|
|
143
|
+
"ArchiveAchievement",
|
|
144
|
+
"ArchiveCounts",
|
|
145
|
+
"ArchiveDescriptive",
|
|
146
|
+
"ArchiveMarks",
|
|
147
|
+
"ArchiveSubject",
|
|
148
|
+
"ArchiveYear",
|
|
149
|
+
"SchoolYearArchive",
|
|
133
150
|
"AccountContext",
|
|
134
151
|
"MessagingBackend",
|
|
135
152
|
"ModernAccountData",
|
|
@@ -235,6 +252,7 @@ __all__ = [
|
|
|
235
252
|
"DescriptiveGrade",
|
|
236
253
|
"DescriptiveGradeSummary",
|
|
237
254
|
"FinalGrades",
|
|
255
|
+
"FormativeGrade",
|
|
238
256
|
"GradeKind",
|
|
239
257
|
"GradeRecords",
|
|
240
258
|
"Grades",
|
librus_python_api/config.py
CHANGED
|
@@ -505,6 +505,14 @@ ENDPOINTS: Mapping[str, Endpoint] = MappingProxyType(
|
|
|
505
505
|
True,
|
|
506
506
|
Evidence.INDEPENDENTLY_OBSERVED,
|
|
507
507
|
),
|
|
508
|
+
Endpoint(
|
|
509
|
+
"school_year_archive",
|
|
510
|
+
"GET",
|
|
511
|
+
"/archiwum",
|
|
512
|
+
SideEffect.NONE,
|
|
513
|
+
True,
|
|
514
|
+
Evidence.INDEPENDENTLY_OBSERVED,
|
|
515
|
+
),
|
|
508
516
|
Endpoint(
|
|
509
517
|
"agenda",
|
|
510
518
|
"POST",
|
|
@@ -713,6 +721,18 @@ ATTENDANCE_MAX_LESSONS = 4096
|
|
|
713
721
|
ATTENDANCE_MAX_SUBJECTS = 1024
|
|
714
722
|
# Collection recognition and detail retrieval share this central path family.
|
|
715
723
|
ATTENDANCE_DETAIL_PATH_PREFIX = "/przegladaj_nb/szczegoly/"
|
|
724
|
+
# Recognition only: formative detail links are classified, never requested.
|
|
725
|
+
FORMATIVE_DETAIL_PATH_PREFIX = "/przegladaj_oceny/szczegoly/ksztaltujace/"
|
|
726
|
+
FORMATIVE_HEADERS = (
|
|
727
|
+
"przedmiot",
|
|
728
|
+
"ocena kształtująca",
|
|
729
|
+
"kategoria",
|
|
730
|
+
"okres",
|
|
731
|
+
"data",
|
|
732
|
+
"typ",
|
|
733
|
+
)
|
|
734
|
+
FORMATIVE_TEMPLATE_ID = "000000"
|
|
735
|
+
FORMATIVE_MAX_TEXT_LENGTH = 65536
|
|
716
736
|
ATTENDANCE_DETAIL_MAX_FIELDS = 32
|
|
717
737
|
ATTENDANCE_METADATA_CACHE_SIZE = 256
|
|
718
738
|
ATTENDANCE_RESULT_CACHE_SIZE = 64
|
|
@@ -722,6 +742,35 @@ ANNOUNCEMENT_MAX_ITEMS = 256
|
|
|
722
742
|
ANNOUNCEMENT_MAX_FIELD_LENGTH = 1024
|
|
723
743
|
ANNOUNCEMENT_MAX_CONTENT_LENGTH = 65536
|
|
724
744
|
ANNOUNCEMENT_MAX_TOTAL_TEXT_LENGTH = 262144
|
|
745
|
+
|
|
746
|
+
# School-year archive (/archiwum): labels as rendered upstream, including the
|
|
747
|
+
# absence spellings. Matching is case-insensitive and exact otherwise.
|
|
748
|
+
ARCHIVE_TABLE_CLASSES = frozenset({"decorated"})
|
|
749
|
+
ARCHIVE_ACHIEVEMENT_TABLE_CLASSES = frozenset({"decorated", "big", "center"})
|
|
750
|
+
ARCHIVE_YEAR_PATTERN = (
|
|
751
|
+
r"Klasa: (?P<class_name>\S(?:.*\S)?) Rok: (?P<first>[0-9]{4})/(?P<second>[0-9]{4})"
|
|
752
|
+
)
|
|
753
|
+
ARCHIVE_PERIOD_LABELS = ("okres 1", "okres 2", "koniec roku")
|
|
754
|
+
ARCHIVE_BEHAVIOUR_HEADING = "zachowanie"
|
|
755
|
+
ARCHIVE_ABSENCES_HEADING = "nieobecności"
|
|
756
|
+
ARCHIVE_ABSENCE_LABELS = MappingProxyType(
|
|
757
|
+
{
|
|
758
|
+
"nieusprawiedlione": "unexcused",
|
|
759
|
+
"usprawiedlione": "excused",
|
|
760
|
+
"spóźnienia": "late",
|
|
761
|
+
}
|
|
762
|
+
)
|
|
763
|
+
ARCHIVE_ACHIEVEMENT_HEADERS = ("data", "klasa", "kategoria", "osiągnięcie")
|
|
764
|
+
ARCHIVE_YEAR_HEADER_TAGS = frozenset({"span", "b"})
|
|
765
|
+
ARCHIVE_MAX_YEARS = 16
|
|
766
|
+
ARCHIVE_MAX_SUBJECTS = 128
|
|
767
|
+
ARCHIVE_MAX_DESCRIPTIVE = 32
|
|
768
|
+
ARCHIVE_MAX_ACHIEVEMENTS = 256
|
|
769
|
+
ARCHIVE_MAX_TEXT_LENGTH = 65536
|
|
770
|
+
ARCHIVE_MAX_TOTAL_TEXT_LENGTH = 262144
|
|
771
|
+
ARCHIVE_MAX_YEAR_HEADER_LENGTH = 1024 + len("Klasa: Rok: 0000/0000")
|
|
772
|
+
ARCHIVE_EMPTY_TITLE = "brak danych"
|
|
773
|
+
ARCHIVE_EMPTY_BOX_CLASSES = frozenset({"warning-box", "information", "medium"})
|
|
725
774
|
ANNOUNCEMENT_TABLE_CLASSES = frozenset(
|
|
726
775
|
{"decorated", "big", "center", "printable", "margin-top"}
|
|
727
776
|
)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""The grades page's formative-assessment table ("Oceny kształtujące").
|
|
2
|
+
|
|
3
|
+
Authored from an owner-authorized structural observation; fixtures are
|
|
4
|
+
synthetic. The table sits on the page `grades()` already reads, so no request
|
|
5
|
+
is added. Its subject may be the pseudo-subject "KARTA SPOSTRZEŻEŃ". Unknown
|
|
6
|
+
markup is unsupported; contradictions inside the known layout are parse errors.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Literal, NoReturn, cast
|
|
12
|
+
|
|
13
|
+
from lxml import html
|
|
14
|
+
|
|
15
|
+
from librus_python_api import markup
|
|
16
|
+
from librus_python_api.config import (
|
|
17
|
+
FORMATIVE_DETAIL_PATH_PREFIX,
|
|
18
|
+
FORMATIVE_HEADERS,
|
|
19
|
+
FORMATIVE_MAX_TEXT_LENGTH,
|
|
20
|
+
FORMATIVE_TEMPLATE_ID,
|
|
21
|
+
GRADE_MAX_RECORDS,
|
|
22
|
+
)
|
|
23
|
+
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
24
|
+
from librus_python_api.models import FormativeGrade
|
|
25
|
+
|
|
26
|
+
_SPAN = re.compile(r"[1-9][0-9]{0,5}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _fail(kind: ErrorKind) -> NoReturn:
|
|
30
|
+
raise LibrusError(kind)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_formative_table(table: html.HtmlElement) -> bool:
|
|
34
|
+
# Cheap structural gates first: other tables' header text is never read.
|
|
35
|
+
if not {"stretch", "decorated"} <= set(table.get("class", "").split()):
|
|
36
|
+
return False
|
|
37
|
+
heads = [row for row in markup.rows(table) if markup.in_header(row)]
|
|
38
|
+
if len(heads) != 1:
|
|
39
|
+
return False
|
|
40
|
+
cells = markup.cells(heads[0])
|
|
41
|
+
return len(cells) == len(FORMATIVE_HEADERS) and [
|
|
42
|
+
markup.text(cell).casefold() for cell in cells
|
|
43
|
+
] == list(FORMATIVE_HEADERS)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _single(cell: html.HtmlElement) -> None:
|
|
47
|
+
if cell.get("colspan", "1") != "1" or cell.get("rowspan", "1") != "1":
|
|
48
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _plain(cell: html.HtmlElement) -> str:
|
|
52
|
+
_single(cell)
|
|
53
|
+
if any(isinstance(node.tag, str) for node in cell.iterdescendants()):
|
|
54
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
55
|
+
return markup.text(cell)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _link(cell: html.HtmlElement) -> tuple[str, str]:
|
|
59
|
+
"""The detail ID and full text of the assessment cell.
|
|
60
|
+
|
|
61
|
+
Observed: one link whose optional first child is an empty coloured
|
|
62
|
+
`span.grade-box` marker, followed by the text. Anything else is new markup.
|
|
63
|
+
"""
|
|
64
|
+
_single(cell)
|
|
65
|
+
anchors = [node for node in cell if isinstance(node.tag, str)]
|
|
66
|
+
if len(anchors) != 1 or anchors[0].tag != "a":
|
|
67
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
68
|
+
anchor = anchors[0]
|
|
69
|
+
children = [node for node in anchor.iterdescendants() if isinstance(node.tag, str)]
|
|
70
|
+
if children:
|
|
71
|
+
marker = children[0]
|
|
72
|
+
if (
|
|
73
|
+
len(children) != 1
|
|
74
|
+
or marker.getparent() is not anchor
|
|
75
|
+
or anchor.index(marker) != 0
|
|
76
|
+
or marker.tag != "span"
|
|
77
|
+
or "grade-box" not in marker.get("class", "").split()
|
|
78
|
+
or markup.text(marker)
|
|
79
|
+
):
|
|
80
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
81
|
+
identifier = markup.detail_id(anchor.get("href"), FORMATIVE_DETAIL_PATH_PREFIX)
|
|
82
|
+
if identifier is None:
|
|
83
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
84
|
+
return identifier, markup.text(anchor, FORMATIVE_MAX_TEXT_LENGTH)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _template(row: html.HtmlElement) -> bool:
|
|
88
|
+
"""A hidden, empty template entry (ID 000000) rendered for scripts.
|
|
89
|
+
|
|
90
|
+
Not observed inside this table (the page's observed template is an ordinary
|
|
91
|
+
grade link in a separate hidden table); kept narrow on purpose.
|
|
92
|
+
"""
|
|
93
|
+
hidden = "display:none" in row.get("style", "").replace(" ", "").casefold()
|
|
94
|
+
links = [
|
|
95
|
+
markup.detail_id(anchor.get("href"), FORMATIVE_DETAIL_PATH_PREFIX)
|
|
96
|
+
for anchor in row.iter("a")
|
|
97
|
+
]
|
|
98
|
+
if not hidden or links != [FORMATIVE_TEMPLATE_ID]:
|
|
99
|
+
return False
|
|
100
|
+
if any(markup.text(anchor) for anchor in row.iter("a")):
|
|
101
|
+
_fail(ErrorKind.PARSE)
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _item(subject: str, cells: list[html.HtmlElement]) -> FormativeGrade:
|
|
106
|
+
if len(cells) != 5:
|
|
107
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
108
|
+
identifier, text = _link(cells[0])
|
|
109
|
+
category = _plain(cells[1])
|
|
110
|
+
period = _plain(cells[2])
|
|
111
|
+
day = markup.civil_date(_plain(cells[3]))
|
|
112
|
+
kind = _plain(cells[4])
|
|
113
|
+
if not text or period not in ("1", "2") or identifier == FORMATIVE_TEMPLATE_ID:
|
|
114
|
+
_fail(ErrorKind.PARSE)
|
|
115
|
+
return FormativeGrade(
|
|
116
|
+
subject,
|
|
117
|
+
text,
|
|
118
|
+
category,
|
|
119
|
+
cast(Literal[1, 2], int(period)),
|
|
120
|
+
day,
|
|
121
|
+
kind,
|
|
122
|
+
identifier,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def read_formative_table(
|
|
127
|
+
table: html.HtmlElement, check_size: Callable[[int], None]
|
|
128
|
+
) -> tuple[FormativeGrade, ...]:
|
|
129
|
+
"""Rows in page order; a subject TH may span the rows of its group."""
|
|
130
|
+
if any(nested is not table for nested in table.iter("table")):
|
|
131
|
+
_fail(ErrorKind.PARSE)
|
|
132
|
+
items: list[FormativeGrade] = []
|
|
133
|
+
seen: set[str] = set()
|
|
134
|
+
subject, remaining = "", 0
|
|
135
|
+
for row in markup.rows(table):
|
|
136
|
+
if markup.in_header(row):
|
|
137
|
+
continue
|
|
138
|
+
cells = markup.cells(row)
|
|
139
|
+
if next(row.iterancestors("tfoot"), None) is not None:
|
|
140
|
+
if len(cells) != 1 or markup.text(cells[0]):
|
|
141
|
+
_fail(ErrorKind.PARSE)
|
|
142
|
+
continue
|
|
143
|
+
if _template(row):
|
|
144
|
+
continue
|
|
145
|
+
if remaining == 0:
|
|
146
|
+
if not cells or cells[0].tag != "th":
|
|
147
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
148
|
+
head = cells[0]
|
|
149
|
+
span = head.get("rowspan", "1")
|
|
150
|
+
if head.get("colspan", "1") != "1" or not _SPAN.fullmatch(span):
|
|
151
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
152
|
+
remaining = int(span)
|
|
153
|
+
if remaining > GRADE_MAX_RECORDS:
|
|
154
|
+
_fail(ErrorKind.LIMIT)
|
|
155
|
+
if any(isinstance(node.tag, str) for node in head.iterdescendants()):
|
|
156
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
157
|
+
subject = markup.text(head)
|
|
158
|
+
if not subject:
|
|
159
|
+
_fail(ErrorKind.PARSE)
|
|
160
|
+
cells = cells[1:]
|
|
161
|
+
elif cells and cells[0].tag == "th":
|
|
162
|
+
# A new subject before the spanning group ended.
|
|
163
|
+
_fail(ErrorKind.PARSE)
|
|
164
|
+
remaining -= 1
|
|
165
|
+
item = _item(subject, cells)
|
|
166
|
+
if item.detail_id in seen:
|
|
167
|
+
_fail(ErrorKind.PARSE)
|
|
168
|
+
seen.add(item.detail_id)
|
|
169
|
+
items.append(item)
|
|
170
|
+
check_size(len(items))
|
|
171
|
+
if remaining:
|
|
172
|
+
_fail(ErrorKind.PARSE)
|
|
173
|
+
return tuple(items)
|
|
@@ -8,6 +8,7 @@ from lxml import html
|
|
|
8
8
|
|
|
9
9
|
from librus_python_api import markup
|
|
10
10
|
from librus_python_api.config import (
|
|
11
|
+
FORMATIVE_DETAIL_PATH_PREFIX,
|
|
11
12
|
GRADE_AVERAGE_HEADERS,
|
|
12
13
|
GRADE_BODY_PREFIX_COLUMNS,
|
|
13
14
|
GRADE_CURRENT_HEADER,
|
|
@@ -23,11 +24,13 @@ from librus_python_api.config import (
|
|
|
23
24
|
GRADE_PUBLICATION_TEACHER_LABEL,
|
|
24
25
|
)
|
|
25
26
|
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
27
|
+
from librus_python_api.formative_grades import is_formative_table, read_formative_table
|
|
26
28
|
from librus_python_api.grade_parsers import _locate, _subject_rows, _summary
|
|
27
29
|
from librus_python_api.models import (
|
|
28
30
|
Availability,
|
|
29
31
|
DescriptiveGrade,
|
|
30
32
|
DescriptiveGradeSummary,
|
|
33
|
+
FormativeGrade,
|
|
31
34
|
GradeKind,
|
|
32
35
|
GradeRecords,
|
|
33
36
|
GradeSummaryValue,
|
|
@@ -138,14 +141,22 @@ class _Collection:
|
|
|
138
141
|
subjects: set[tuple[bool, str]] = field(default_factory=set, repr=False)
|
|
139
142
|
consumed: set[html.HtmlElement] = field(default_factory=set, repr=False)
|
|
140
143
|
publication_rows: set[html.HtmlElement] = field(default_factory=set, repr=False)
|
|
144
|
+
formative: int = 0
|
|
141
145
|
|
|
142
146
|
def check_size(self) -> None:
|
|
143
147
|
if (
|
|
144
|
-
len(self.numeric)
|
|
148
|
+
len(self.numeric)
|
|
149
|
+
+ len(self.descriptive)
|
|
150
|
+
+ len(self.descriptive_summaries)
|
|
151
|
+
+ self.formative
|
|
145
152
|
> GRADE_MAX_RECORDS
|
|
146
153
|
):
|
|
147
154
|
raise LibrusError(ErrorKind.LIMIT)
|
|
148
155
|
|
|
156
|
+
def count_formative(self, count: int) -> None:
|
|
157
|
+
self.formative = count
|
|
158
|
+
self.check_size()
|
|
159
|
+
|
|
149
160
|
def subject(self, value: str, *, descriptive: bool = False) -> None:
|
|
150
161
|
key = (descriptive, value)
|
|
151
162
|
if not value or key in self.subjects:
|
|
@@ -400,7 +411,14 @@ def parse_grade_records(body: bytes) -> GradeRecords:
|
|
|
400
411
|
numeric_tables.append(table)
|
|
401
412
|
if len(numeric_tables) > 1:
|
|
402
413
|
raise LibrusError(ErrorKind.PARSE)
|
|
414
|
+
formative_tables = [table for table in tables if is_formative_table(table)]
|
|
415
|
+
if len(formative_tables) > 1:
|
|
416
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
417
|
+
formative: tuple[FormativeGrade, ...] = ()
|
|
403
418
|
for table in tables:
|
|
419
|
+
if table in formative_tables:
|
|
420
|
+
formative = read_formative_table(table, collection.count_formative)
|
|
421
|
+
continue
|
|
404
422
|
_read_publications(table, collection)
|
|
405
423
|
if table in numeric_tables:
|
|
406
424
|
_read_numeric_table(table, collection)
|
|
@@ -434,6 +452,15 @@ def parse_grade_records(body: bytes) -> GradeRecords:
|
|
|
434
452
|
):
|
|
435
453
|
continue
|
|
436
454
|
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
455
|
+
for anchor in document.iter("a"):
|
|
456
|
+
# A formative link outside both known tables is a new layout.
|
|
457
|
+
if markup.detail_id(
|
|
458
|
+
anchor.get("href"), FORMATIVE_DETAIL_PATH_PREFIX
|
|
459
|
+
) and not any(
|
|
460
|
+
parent in numeric_tables or parent in formative_tables
|
|
461
|
+
for parent in anchor.iterancestors("table")
|
|
462
|
+
):
|
|
463
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
437
464
|
if not collection.subjects and not collection.descriptive:
|
|
438
465
|
raise LibrusError(ErrorKind.PARSE)
|
|
439
466
|
for descriptive, subject in sorted(collection.subjects):
|
|
@@ -451,4 +478,5 @@ def parse_grade_records(body: bytes) -> GradeRecords:
|
|
|
451
478
|
tuple(collection.descriptive),
|
|
452
479
|
tuple(collection.averages),
|
|
453
480
|
tuple(collection.descriptive_summaries),
|
|
481
|
+
formative,
|
|
454
482
|
)
|
librus_python_api/markup.py
CHANGED
|
@@ -131,8 +131,19 @@ def attendance_detail_id(element: html.HtmlElement) -> str | None:
|
|
|
131
131
|
)
|
|
132
132
|
if match is None:
|
|
133
133
|
return None
|
|
134
|
+
return detail_id(match[2], ATTENDANCE_DETAIL_PATH_PREFIX)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def detail_id(link: str | None, prefix: str) -> str | None:
|
|
138
|
+
"""The numeric ID of a relative or same-origin Synergia detail link.
|
|
139
|
+
|
|
140
|
+
Recognition only: the link is never followed. Queries, fragments, other
|
|
141
|
+
origins, credentials and non-digit IDs are not detail links.
|
|
142
|
+
"""
|
|
143
|
+
if link is None or len(link) > GRADE_MAX_METADATA_LENGTH:
|
|
144
|
+
return None
|
|
134
145
|
try:
|
|
135
|
-
target = urlsplit(
|
|
146
|
+
target = urlsplit(link)
|
|
136
147
|
except ValueError:
|
|
137
148
|
return None
|
|
138
149
|
if target.query or target.fragment or target.username or target.password:
|
|
@@ -141,7 +152,7 @@ def attendance_detail_id(element: html.HtmlElement) -> str | None:
|
|
|
141
152
|
expected = urlsplit(UPSTREAM_ORIGINS["synergia"])
|
|
142
153
|
if (target.scheme, target.netloc) != (expected.scheme, expected.netloc):
|
|
143
154
|
return None
|
|
144
|
-
if not target.path.startswith(
|
|
155
|
+
if not target.path.startswith(prefix):
|
|
145
156
|
return None
|
|
146
|
-
identifier = target.path[len(
|
|
157
|
+
identifier = target.path[len(prefix) :]
|
|
147
158
|
return identifier if re.fullmatch(r"[0-9]{1,64}", identifier) else None
|
librus_python_api/models.py
CHANGED
|
@@ -38,6 +38,7 @@ type OperationName = Literal[
|
|
|
38
38
|
"attachment_download",
|
|
39
39
|
"recipient_groups",
|
|
40
40
|
"recipients",
|
|
41
|
+
"school_year_archive",
|
|
41
42
|
"notification_counts",
|
|
42
43
|
"consume_schedule_events",
|
|
43
44
|
"decode_schedule_events",
|
|
@@ -778,6 +779,79 @@ class Announcements:
|
|
|
778
779
|
observation: Observation
|
|
779
780
|
|
|
780
781
|
|
|
782
|
+
@dataclass(frozen=True, slots=True)
|
|
783
|
+
class ArchiveCounts:
|
|
784
|
+
"""Counts per period as rendered; the year-end value is not checked as a sum."""
|
|
785
|
+
|
|
786
|
+
first_semester: int
|
|
787
|
+
second_semester: int
|
|
788
|
+
year_end: int
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
@dataclass(frozen=True, slots=True)
|
|
792
|
+
class ArchiveAbsences:
|
|
793
|
+
unexcused: ArchiveCounts
|
|
794
|
+
excused: ArchiveCounts
|
|
795
|
+
late: ArchiveCounts
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
@dataclass(frozen=True, slots=True)
|
|
799
|
+
class ArchiveMarks:
|
|
800
|
+
"""Rendered marks; "-" and empty text are preserved, never invented.
|
|
801
|
+
|
|
802
|
+
`year_end` is the archive's year-end column, not asserted to equal the
|
|
803
|
+
published annual grade of `SubjectGradeSummary`.
|
|
804
|
+
"""
|
|
805
|
+
|
|
806
|
+
first_semester: str = field(repr=False)
|
|
807
|
+
second_semester: str = field(repr=False)
|
|
808
|
+
year_end: str = field(repr=False)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
@dataclass(frozen=True, slots=True)
|
|
812
|
+
class ArchiveSubject:
|
|
813
|
+
subject: str = field(repr=False)
|
|
814
|
+
marks: ArchiveMarks = field(repr=False)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
@dataclass(frozen=True, slots=True)
|
|
818
|
+
class ArchiveDescriptive:
|
|
819
|
+
label: str = field(repr=False)
|
|
820
|
+
text: str = field(repr=False)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
@dataclass(frozen=True, slots=True)
|
|
824
|
+
class ArchiveYear:
|
|
825
|
+
"""One earlier school year. Behaviour cells are raw positional text: the
|
|
826
|
+
first-semester column and the cell spanning second semester and year end;
|
|
827
|
+
their populated meaning is not established."""
|
|
828
|
+
|
|
829
|
+
class_name: str = field(repr=False)
|
|
830
|
+
school_year: str = field(repr=False)
|
|
831
|
+
first_year: int = field(repr=False)
|
|
832
|
+
subjects: tuple[ArchiveSubject, ...] = field(repr=False)
|
|
833
|
+
descriptive: tuple[ArchiveDescriptive, ...] = field(repr=False)
|
|
834
|
+
behaviour_first_semester: str = field(repr=False)
|
|
835
|
+
behaviour_second_semester_and_year_end: str = field(repr=False)
|
|
836
|
+
absences: ArchiveAbsences = field(repr=False)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
@dataclass(frozen=True, slots=True)
|
|
840
|
+
class ArchiveAchievement:
|
|
841
|
+
day: date = field(repr=False)
|
|
842
|
+
class_name: str = field(repr=False)
|
|
843
|
+
category: str = field(repr=False)
|
|
844
|
+
text: str = field(repr=False)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
@dataclass(frozen=True, slots=True)
|
|
848
|
+
class SchoolYearArchive:
|
|
849
|
+
identity: Identity
|
|
850
|
+
years: tuple[ArchiveYear, ...] = field(repr=False)
|
|
851
|
+
achievements: tuple[ArchiveAchievement, ...] = field(repr=False)
|
|
852
|
+
observation: Observation
|
|
853
|
+
|
|
854
|
+
|
|
781
855
|
@dataclass(frozen=True, slots=True)
|
|
782
856
|
class AgendaEvent:
|
|
783
857
|
day: date = field(repr=False)
|
|
@@ -945,6 +1019,19 @@ class NumericGrade:
|
|
|
945
1019
|
metadata: tuple[tuple[str, str], ...] = field(repr=False)
|
|
946
1020
|
kind: GradeKind = GradeKind.CURRENT
|
|
947
1021
|
|
|
1022
|
+
@property
|
|
1023
|
+
def formative_id(self) -> str | None:
|
|
1024
|
+
"""The formative-assessment ID when `href` is a formative detail link.
|
|
1025
|
+
|
|
1026
|
+
Computed, not a field: canonical notification IDs hash the record's
|
|
1027
|
+
fields, so stored identities stay unchanged.
|
|
1028
|
+
"""
|
|
1029
|
+
# Lazy import: config imports this module.
|
|
1030
|
+
from librus_python_api.config import FORMATIVE_DETAIL_PATH_PREFIX
|
|
1031
|
+
from librus_python_api.markup import detail_id
|
|
1032
|
+
|
|
1033
|
+
return detail_id(self.href, FORMATIVE_DETAIL_PATH_PREFIX)
|
|
1034
|
+
|
|
948
1035
|
|
|
949
1036
|
@dataclass(frozen=True, slots=True)
|
|
950
1037
|
class DescriptiveGrade:
|
|
@@ -958,6 +1045,19 @@ class DescriptiveGrade:
|
|
|
958
1045
|
kind: GradeKind = GradeKind.CURRENT
|
|
959
1046
|
href: str | None = field(default=None, repr=False)
|
|
960
1047
|
|
|
1048
|
+
@property
|
|
1049
|
+
def formative_id(self) -> str | None:
|
|
1050
|
+
"""The formative-assessment ID when `href` is a formative detail link.
|
|
1051
|
+
|
|
1052
|
+
Computed, not a field: canonical notification IDs hash the record's
|
|
1053
|
+
fields, so stored identities stay unchanged.
|
|
1054
|
+
"""
|
|
1055
|
+
# Lazy import: config imports this module.
|
|
1056
|
+
from librus_python_api.config import FORMATIVE_DETAIL_PATH_PREFIX
|
|
1057
|
+
from librus_python_api.markup import detail_id
|
|
1058
|
+
|
|
1059
|
+
return detail_id(self.href, FORMATIVE_DETAIL_PATH_PREFIX)
|
|
1060
|
+
|
|
961
1061
|
|
|
962
1062
|
@dataclass(frozen=True, slots=True)
|
|
963
1063
|
class SchoolAverage:
|
|
@@ -977,6 +1077,22 @@ class DescriptiveGradeSummary:
|
|
|
977
1077
|
raw: str = field(repr=False)
|
|
978
1078
|
|
|
979
1079
|
|
|
1080
|
+
@dataclass(frozen=True, slots=True)
|
|
1081
|
+
class FormativeGrade:
|
|
1082
|
+
"""One row of the grades page's formative-assessment ("Oceny kształtujące")
|
|
1083
|
+
table. "KARTA SPOSTRZEŻEŃ" (observation card) appears as a subject value.
|
|
1084
|
+
The same item may also appear in the grid with a matching `formative_id`.
|
|
1085
|
+
"""
|
|
1086
|
+
|
|
1087
|
+
subject: str = field(repr=False)
|
|
1088
|
+
text: str = field(repr=False)
|
|
1089
|
+
category: str = field(repr=False)
|
|
1090
|
+
semester: Literal[1, 2]
|
|
1091
|
+
day: date = field(repr=False)
|
|
1092
|
+
assessment_type: str = field(repr=False)
|
|
1093
|
+
detail_id: str = field(repr=False)
|
|
1094
|
+
|
|
1095
|
+
|
|
980
1096
|
@dataclass(frozen=True, slots=True)
|
|
981
1097
|
class GradeRecords:
|
|
982
1098
|
numeric: tuple[NumericGrade, ...] = field(repr=False)
|
|
@@ -985,6 +1101,7 @@ class GradeRecords:
|
|
|
985
1101
|
descriptive_summaries: tuple[DescriptiveGradeSummary, ...] = field(
|
|
986
1102
|
default=(), repr=False
|
|
987
1103
|
)
|
|
1104
|
+
formative: tuple[FormativeGrade, ...] = field(default=(), repr=False)
|
|
988
1105
|
|
|
989
1106
|
|
|
990
1107
|
@dataclass(frozen=True, slots=True)
|
|
@@ -1004,6 +1121,7 @@ class GradeWindow:
|
|
|
1004
1121
|
descriptive: tuple[DescriptiveGrade, ...] = field(repr=False)
|
|
1005
1122
|
observation: Observation
|
|
1006
1123
|
view: GradeView = GradeView.ALL
|
|
1124
|
+
formative: tuple[FormativeGrade, ...] = field(default=(), repr=False)
|
|
1007
1125
|
|
|
1008
1126
|
|
|
1009
1127
|
@dataclass(frozen=True, slots=True)
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""The school-year archive page (`/archiwum`), authored from an owner-authorized
|
|
2
|
+
structural observation; every fixture is synthetic.
|
|
3
|
+
|
|
4
|
+
One table holds every earlier school year: a year header row, a period row,
|
|
5
|
+
grade rows (subject marks or full-year descriptive text), then exactly one
|
|
6
|
+
behaviour section, one absences section and an empty footer, in that order.
|
|
7
|
+
A separate table lists achievements. Unknown layouts are unsupported;
|
|
8
|
+
contradictions inside the known layout are parse errors. The student's name in
|
|
9
|
+
the page header and the chart scripts are never read.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from typing import NoReturn
|
|
14
|
+
|
|
15
|
+
from lxml import html
|
|
16
|
+
|
|
17
|
+
from librus_python_api import markup
|
|
18
|
+
from librus_python_api.config import (
|
|
19
|
+
ARCHIVE_ABSENCE_LABELS,
|
|
20
|
+
ARCHIVE_ABSENCES_HEADING,
|
|
21
|
+
ARCHIVE_ACHIEVEMENT_HEADERS,
|
|
22
|
+
ARCHIVE_ACHIEVEMENT_TABLE_CLASSES,
|
|
23
|
+
ARCHIVE_BEHAVIOUR_HEADING,
|
|
24
|
+
ARCHIVE_EMPTY_BOX_CLASSES,
|
|
25
|
+
ARCHIVE_EMPTY_TITLE,
|
|
26
|
+
ARCHIVE_MAX_ACHIEVEMENTS,
|
|
27
|
+
ARCHIVE_MAX_DESCRIPTIVE,
|
|
28
|
+
ARCHIVE_MAX_SUBJECTS,
|
|
29
|
+
ARCHIVE_MAX_TEXT_LENGTH,
|
|
30
|
+
ARCHIVE_MAX_TOTAL_TEXT_LENGTH,
|
|
31
|
+
ARCHIVE_MAX_YEAR_HEADER_LENGTH,
|
|
32
|
+
ARCHIVE_MAX_YEARS,
|
|
33
|
+
ARCHIVE_PERIOD_LABELS,
|
|
34
|
+
ARCHIVE_TABLE_CLASSES,
|
|
35
|
+
ARCHIVE_YEAR_HEADER_TAGS,
|
|
36
|
+
ARCHIVE_YEAR_PATTERN,
|
|
37
|
+
)
|
|
38
|
+
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
39
|
+
from librus_python_api.models import (
|
|
40
|
+
ArchiveAbsences,
|
|
41
|
+
ArchiveAchievement,
|
|
42
|
+
ArchiveCounts,
|
|
43
|
+
ArchiveDescriptive,
|
|
44
|
+
ArchiveMarks,
|
|
45
|
+
ArchiveSubject,
|
|
46
|
+
ArchiveYear,
|
|
47
|
+
)
|
|
48
|
+
from librus_python_api.parsers import parse_page
|
|
49
|
+
|
|
50
|
+
_YEAR = re.compile(ARCHIVE_YEAR_PATTERN)
|
|
51
|
+
_COUNT = re.compile(r"[0-9]{1,6}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _fail(kind: ErrorKind) -> NoReturn:
|
|
55
|
+
raise LibrusError(kind)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _Text:
|
|
59
|
+
"""Rendered cell text under one total budget for the whole page."""
|
|
60
|
+
|
|
61
|
+
def __init__(self) -> None:
|
|
62
|
+
self.used = 0
|
|
63
|
+
|
|
64
|
+
def __call__(
|
|
65
|
+
self,
|
|
66
|
+
cell: html.HtmlElement,
|
|
67
|
+
*,
|
|
68
|
+
multiline: bool = False,
|
|
69
|
+
limit: int = markup.FIELD_LENGTH,
|
|
70
|
+
) -> str:
|
|
71
|
+
value = markup.text(cell, limit, multiline=multiline)
|
|
72
|
+
self.used += len(value)
|
|
73
|
+
if self.used > ARCHIVE_MAX_TOTAL_TEXT_LENGTH:
|
|
74
|
+
_fail(ErrorKind.LIMIT)
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _plain(cell: html.HtmlElement, allowed: frozenset[str] = frozenset()) -> None:
|
|
79
|
+
"""Only the observed child vocabulary; other markup is a new layout."""
|
|
80
|
+
for node in cell.iterdescendants():
|
|
81
|
+
if isinstance(node.tag, str) and node.tag not in allowed:
|
|
82
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
83
|
+
for node in cell.iter():
|
|
84
|
+
if any(name == "title" or name.startswith("on") for name in node.attrib):
|
|
85
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _td(cells: list[html.HtmlElement]) -> None:
|
|
89
|
+
if any(cell.tag != "td" for cell in cells):
|
|
90
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _classes(table: html.HtmlElement) -> set[str]:
|
|
94
|
+
return set(table.get("class", "").split())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _first_cells(table: html.HtmlElement) -> list[html.HtmlElement]:
|
|
98
|
+
first = next(markup.rows(table), None)
|
|
99
|
+
return [] if first is None else markup.cells(first)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _is_archive(table: html.HtmlElement) -> bool:
|
|
103
|
+
cells = _first_cells(table)
|
|
104
|
+
return (
|
|
105
|
+
len(cells) >= 2
|
|
106
|
+
and markup.text(cells[0]) == ""
|
|
107
|
+
and all(
|
|
108
|
+
_YEAR.fullmatch(markup.text(cell, ARCHIVE_MAX_YEAR_HEADER_LENGTH))
|
|
109
|
+
for cell in cells[1:]
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _is_achievement_list(table: html.HtmlElement) -> bool:
|
|
115
|
+
cells = _first_cells(table)
|
|
116
|
+
return [
|
|
117
|
+
markup.text(cell, ARCHIVE_MAX_YEAR_HEADER_LENGTH).casefold() for cell in cells
|
|
118
|
+
] == list(ARCHIVE_ACHIEVEMENT_HEADERS)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _in_footer(row: html.HtmlElement) -> bool:
|
|
122
|
+
return next(row.iterancestors("tfoot"), None) is not None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _years(header: html.HtmlElement, text: _Text) -> list[tuple[str, str, int]]:
|
|
126
|
+
cells = markup.cells(header)
|
|
127
|
+
_td(cells)
|
|
128
|
+
_plain(cells[0])
|
|
129
|
+
if len(cells) - 1 > ARCHIVE_MAX_YEARS:
|
|
130
|
+
_fail(ErrorKind.LIMIT)
|
|
131
|
+
if markup.colspan(cells[0]) != 1:
|
|
132
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
133
|
+
years: list[tuple[str, str, int]] = []
|
|
134
|
+
for cell in cells[1:]:
|
|
135
|
+
if markup.colspan(cell) != 3:
|
|
136
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
137
|
+
_plain(cell, ARCHIVE_YEAR_HEADER_TAGS)
|
|
138
|
+
match = _YEAR.fullmatch(text(cell, limit=ARCHIVE_MAX_YEAR_HEADER_LENGTH))
|
|
139
|
+
if match is None:
|
|
140
|
+
_fail(ErrorKind.PARSE)
|
|
141
|
+
first, second = int(match["first"]), int(match["second"])
|
|
142
|
+
if len(match["class_name"]) > markup.FIELD_LENGTH:
|
|
143
|
+
_fail(ErrorKind.LIMIT)
|
|
144
|
+
if second != first + 1 or any(first == seen for _, _, seen in years):
|
|
145
|
+
_fail(ErrorKind.PARSE)
|
|
146
|
+
years.append((match["class_name"], f"{first}/{second}", first))
|
|
147
|
+
return years
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _check_periods(row: html.HtmlElement, count: int, text: _Text) -> None:
|
|
151
|
+
cells = markup.cells(row)
|
|
152
|
+
_td(cells)
|
|
153
|
+
for cell in cells:
|
|
154
|
+
_plain(cell)
|
|
155
|
+
if (
|
|
156
|
+
len(cells) != 1 + 3 * count
|
|
157
|
+
or any(markup.colspan(cell) != 1 for cell in cells)
|
|
158
|
+
or text(cells[0])
|
|
159
|
+
or [text(cell).casefold() for cell in cells[1:]]
|
|
160
|
+
!= list(ARCHIVE_PERIOD_LABELS) * count
|
|
161
|
+
):
|
|
162
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class _Body:
|
|
166
|
+
"""The section order: grades, behaviour, absences, footer."""
|
|
167
|
+
|
|
168
|
+
def __init__(self, count: int, text: _Text) -> None:
|
|
169
|
+
self.count = count
|
|
170
|
+
self.text = text
|
|
171
|
+
self.state = "grades"
|
|
172
|
+
self.labels: set[str] = set()
|
|
173
|
+
self.subjects: list[tuple[str, list[ArchiveMarks]]] = []
|
|
174
|
+
self.descriptive: list[tuple[str, list[str]]] = []
|
|
175
|
+
self.behaviour: list[tuple[str, str]] = []
|
|
176
|
+
self.absences: dict[str, list[ArchiveCounts]] = {}
|
|
177
|
+
|
|
178
|
+
def read(self, row: html.HtmlElement) -> None:
|
|
179
|
+
cells = markup.cells(row)
|
|
180
|
+
spans = [markup.colspan(cell) for cell in cells]
|
|
181
|
+
full = [1 + 3 * self.count]
|
|
182
|
+
heading = spans == full and "bolded" in row.get("class", "").split()
|
|
183
|
+
if self.state == "grades":
|
|
184
|
+
if heading:
|
|
185
|
+
self._heading(cells, ARCHIVE_BEHAVIOUR_HEADING, "behaviour")
|
|
186
|
+
else:
|
|
187
|
+
self._grade(cells, spans)
|
|
188
|
+
elif self.state == "behaviour":
|
|
189
|
+
self._behaviour(cells, spans, heading)
|
|
190
|
+
elif self.state == "absences_heading":
|
|
191
|
+
if not heading:
|
|
192
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
193
|
+
self._heading(cells, ARCHIVE_ABSENCES_HEADING, "absences")
|
|
194
|
+
elif self.state == "absences":
|
|
195
|
+
if spans == full and not heading:
|
|
196
|
+
self._footer(cells)
|
|
197
|
+
else:
|
|
198
|
+
self._absence(cells, spans)
|
|
199
|
+
else:
|
|
200
|
+
_fail(ErrorKind.PARSE)
|
|
201
|
+
|
|
202
|
+
def _heading(self, cells: list[html.HtmlElement], label: str, state: str) -> None:
|
|
203
|
+
_td(cells)
|
|
204
|
+
_plain(cells[0])
|
|
205
|
+
if self.text(cells[0]).casefold() != label:
|
|
206
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
207
|
+
self.state = state
|
|
208
|
+
|
|
209
|
+
def _label(self, cells: list[html.HtmlElement]) -> str:
|
|
210
|
+
if not cells or cells[0].tag != "th":
|
|
211
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
212
|
+
_plain(cells[0])
|
|
213
|
+
return self.text(cells[0])
|
|
214
|
+
|
|
215
|
+
def _values(
|
|
216
|
+
self,
|
|
217
|
+
cells: list[html.HtmlElement],
|
|
218
|
+
*,
|
|
219
|
+
multiline: bool = False,
|
|
220
|
+
limit: int = markup.FIELD_LENGTH,
|
|
221
|
+
) -> list[str]:
|
|
222
|
+
_td(cells)
|
|
223
|
+
values = []
|
|
224
|
+
for cell in cells:
|
|
225
|
+
_plain(cell, frozenset({"br"}) if multiline else frozenset())
|
|
226
|
+
values.append(self.text(cell, multiline=multiline, limit=limit))
|
|
227
|
+
return values
|
|
228
|
+
|
|
229
|
+
def _grade(self, cells: list[html.HtmlElement], spans: list[int]) -> None:
|
|
230
|
+
label = self._label(cells)
|
|
231
|
+
if not label:
|
|
232
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
233
|
+
if spans == [1] * (1 + 3 * self.count):
|
|
234
|
+
values = self._values(cells[1:])
|
|
235
|
+
marks = [
|
|
236
|
+
ArchiveMarks(*values[3 * i : 3 * i + 3]) for i in range(self.count)
|
|
237
|
+
]
|
|
238
|
+
self.subjects.append((label, marks))
|
|
239
|
+
limit, size = ARCHIVE_MAX_SUBJECTS, len(self.subjects)
|
|
240
|
+
elif spans == [1] + [3] * self.count:
|
|
241
|
+
texts = self._values(
|
|
242
|
+
cells[1:], multiline=True, limit=ARCHIVE_MAX_TEXT_LENGTH
|
|
243
|
+
)
|
|
244
|
+
self.descriptive.append((label, texts))
|
|
245
|
+
limit, size = ARCHIVE_MAX_DESCRIPTIVE, len(self.descriptive)
|
|
246
|
+
else:
|
|
247
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
248
|
+
if label in self.labels:
|
|
249
|
+
_fail(ErrorKind.PARSE)
|
|
250
|
+
self.labels.add(label)
|
|
251
|
+
if size > limit:
|
|
252
|
+
_fail(ErrorKind.LIMIT)
|
|
253
|
+
|
|
254
|
+
def _behaviour(
|
|
255
|
+
self, cells: list[html.HtmlElement], spans: list[int], heading: bool
|
|
256
|
+
) -> None:
|
|
257
|
+
if heading or spans != [1] + [1, 2] * self.count or self._label(cells):
|
|
258
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
259
|
+
values = self._values(cells[1:])
|
|
260
|
+
self.behaviour = [(values[2 * i], values[2 * i + 1]) for i in range(self.count)]
|
|
261
|
+
self.state = "absences_heading"
|
|
262
|
+
|
|
263
|
+
def _absence(self, cells: list[html.HtmlElement], spans: list[int]) -> None:
|
|
264
|
+
field = ARCHIVE_ABSENCE_LABELS.get(self._label(cells).casefold())
|
|
265
|
+
if field is None or spans != [1] * (1 + 3 * self.count):
|
|
266
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
267
|
+
if field in self.absences:
|
|
268
|
+
_fail(ErrorKind.PARSE)
|
|
269
|
+
values = self._values(cells[1:])
|
|
270
|
+
if not all(_COUNT.fullmatch(value) for value in values):
|
|
271
|
+
_fail(ErrorKind.PARSE)
|
|
272
|
+
numbers = [int(value) for value in values]
|
|
273
|
+
self.absences[field] = [
|
|
274
|
+
ArchiveCounts(*numbers[3 * i : 3 * i + 3]) for i in range(self.count)
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
def _footer(self, cells: list[html.HtmlElement]) -> None:
|
|
278
|
+
_td(cells)
|
|
279
|
+
_plain(cells[0])
|
|
280
|
+
if self.text(cells[0]) or len(self.absences) != len(ARCHIVE_ABSENCE_LABELS):
|
|
281
|
+
_fail(ErrorKind.PARSE)
|
|
282
|
+
self.state = "end"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _archive(table: html.HtmlElement, text: _Text) -> tuple[ArchiveYear, ...]:
|
|
286
|
+
rows = list(markup.rows(table))
|
|
287
|
+
if len(rows) < 2:
|
|
288
|
+
_fail(ErrorKind.PARSE)
|
|
289
|
+
years = _years(rows[0], text)
|
|
290
|
+
_check_periods(rows[1], len(years), text)
|
|
291
|
+
body = _Body(len(years), text)
|
|
292
|
+
for row in rows[2:]:
|
|
293
|
+
body.read(row)
|
|
294
|
+
if body.state != "end":
|
|
295
|
+
_fail(ErrorKind.PARSE)
|
|
296
|
+
return tuple(
|
|
297
|
+
ArchiveYear(
|
|
298
|
+
class_name=class_name,
|
|
299
|
+
school_year=school_year,
|
|
300
|
+
first_year=first_year,
|
|
301
|
+
subjects=tuple(
|
|
302
|
+
ArchiveSubject(label, marks[i]) for label, marks in body.subjects
|
|
303
|
+
),
|
|
304
|
+
descriptive=tuple(
|
|
305
|
+
ArchiveDescriptive(label, texts[i]) for label, texts in body.descriptive
|
|
306
|
+
),
|
|
307
|
+
behaviour_first_semester=body.behaviour[i][0],
|
|
308
|
+
behaviour_second_semester_and_year_end=body.behaviour[i][1],
|
|
309
|
+
absences=ArchiveAbsences(
|
|
310
|
+
unexcused=body.absences["unexcused"][i],
|
|
311
|
+
excused=body.absences["excused"][i],
|
|
312
|
+
late=body.absences["late"][i],
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
for i, (class_name, school_year, first_year) in enumerate(years)
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _achievements(
|
|
320
|
+
table: html.HtmlElement, text: _Text
|
|
321
|
+
) -> tuple[ArchiveAchievement, ...]:
|
|
322
|
+
items: list[ArchiveAchievement] = []
|
|
323
|
+
rows = list(markup.rows(table))
|
|
324
|
+
headers = markup.cells(rows[0])
|
|
325
|
+
_td(headers)
|
|
326
|
+
for cell in headers:
|
|
327
|
+
_plain(cell)
|
|
328
|
+
if markup.colspan(cell) != 1:
|
|
329
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
330
|
+
text(cell)
|
|
331
|
+
finished = False
|
|
332
|
+
for row in rows[1:]:
|
|
333
|
+
if finished:
|
|
334
|
+
_fail(ErrorKind.PARSE)
|
|
335
|
+
cells = markup.cells(row)
|
|
336
|
+
_td(cells)
|
|
337
|
+
if _in_footer(row):
|
|
338
|
+
if len(cells) != 1 or text(cells[0]):
|
|
339
|
+
_fail(ErrorKind.PARSE)
|
|
340
|
+
_plain(cells[0])
|
|
341
|
+
if markup.colspan(cells[0]) != 4:
|
|
342
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
343
|
+
finished = True
|
|
344
|
+
continue
|
|
345
|
+
if len(cells) != 4 or any(markup.colspan(cell) != 1 for cell in cells):
|
|
346
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
347
|
+
for i, cell in enumerate(cells):
|
|
348
|
+
_plain(cell, frozenset({"br"}) if i == 3 else frozenset())
|
|
349
|
+
items.append(
|
|
350
|
+
ArchiveAchievement(
|
|
351
|
+
day=markup.civil_date(text(cells[0])),
|
|
352
|
+
class_name=text(cells[1]),
|
|
353
|
+
category=text(cells[2]),
|
|
354
|
+
text=text(cells[3], multiline=True, limit=ARCHIVE_MAX_TEXT_LENGTH),
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
if len(items) > ARCHIVE_MAX_ACHIEVEMENTS:
|
|
358
|
+
_fail(ErrorKind.LIMIT)
|
|
359
|
+
return tuple(items)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _explicit_empty(document: html.HtmlElement) -> bool:
|
|
363
|
+
notices = []
|
|
364
|
+
for box in document.iter("div"):
|
|
365
|
+
if not ARCHIVE_EMPTY_BOX_CLASSES <= _classes(box):
|
|
366
|
+
continue
|
|
367
|
+
parent = box.getparent()
|
|
368
|
+
if parent is None or "container-background" not in _classes(parent):
|
|
369
|
+
continue
|
|
370
|
+
for head in box:
|
|
371
|
+
if head.tag != "div" or "warning-head" not in _classes(head):
|
|
372
|
+
continue
|
|
373
|
+
for title in head:
|
|
374
|
+
if title.tag == "span" and "warning-title" in _classes(title):
|
|
375
|
+
_plain(title)
|
|
376
|
+
if markup.text(title).casefold() != ARCHIVE_EMPTY_TITLE:
|
|
377
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
378
|
+
notices.append(title)
|
|
379
|
+
if len(notices) > 1:
|
|
380
|
+
_fail(ErrorKind.PARSE)
|
|
381
|
+
if notices and any("decorated" in _classes(t) for t in document.iter("table")):
|
|
382
|
+
_fail(ErrorKind.PARSE)
|
|
383
|
+
return bool(notices)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _check_nesting(table: html.HtmlElement, *, chart_trailer: bool = False) -> None:
|
|
387
|
+
if next(table.iterancestors("table"), None) is not None:
|
|
388
|
+
_fail(ErrorKind.PARSE)
|
|
389
|
+
nested = [node for node in table.iter("table") if node is not table]
|
|
390
|
+
if not nested:
|
|
391
|
+
return
|
|
392
|
+
# Live HTML nests the post-footer charts in a stray opening table. Permit
|
|
393
|
+
# that single rowless trailing container, never nested tabular data.
|
|
394
|
+
if not chart_trailer or len(nested) != 1:
|
|
395
|
+
_fail(ErrorKind.PARSE)
|
|
396
|
+
trailer = nested[0]
|
|
397
|
+
previous = trailer.getprevious()
|
|
398
|
+
if (
|
|
399
|
+
trailer.getparent() is not table
|
|
400
|
+
or trailer.getnext() is not None
|
|
401
|
+
or previous is None
|
|
402
|
+
or previous.tag != "tfoot"
|
|
403
|
+
or trailer.attrib
|
|
404
|
+
or any(
|
|
405
|
+
node.tag in {"tr", "td", "th", "thead", "tbody", "tfoot"}
|
|
406
|
+
for node in trailer.iterdescendants()
|
|
407
|
+
)
|
|
408
|
+
):
|
|
409
|
+
_fail(ErrorKind.PARSE)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def parse_school_year_archive(
|
|
413
|
+
body: bytes,
|
|
414
|
+
) -> tuple[tuple[ArchiveYear, ...], tuple[ArchiveAchievement, ...]]:
|
|
415
|
+
document = parse_page(body)
|
|
416
|
+
archives = [t for t in document.iter("table") if _is_archive(t)]
|
|
417
|
+
lists = [t for t in document.iter("table") if _is_achievement_list(t)]
|
|
418
|
+
if _explicit_empty(document):
|
|
419
|
+
if archives or lists:
|
|
420
|
+
_fail(ErrorKind.PARSE)
|
|
421
|
+
return (), ()
|
|
422
|
+
if len(archives) != 1 or len(lists) != 1:
|
|
423
|
+
_fail(ErrorKind.PARSE)
|
|
424
|
+
archive, achievements = archives[0], lists[0]
|
|
425
|
+
_check_nesting(archive)
|
|
426
|
+
_check_nesting(achievements, chart_trailer=True)
|
|
427
|
+
if not ARCHIVE_TABLE_CLASSES <= _classes(archive) or not (
|
|
428
|
+
ARCHIVE_ACHIEVEMENT_TABLE_CLASSES <= _classes(achievements)
|
|
429
|
+
):
|
|
430
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
431
|
+
text = _Text()
|
|
432
|
+
return _archive(archive, text), _achievements(achievements, text)
|
librus_python_api/service.py
CHANGED
|
@@ -138,6 +138,7 @@ from librus_python_api.models import (
|
|
|
138
138
|
SchedulerSnapshot,
|
|
139
139
|
SchoolDetail,
|
|
140
140
|
SchoolReference,
|
|
141
|
+
SchoolYearArchive,
|
|
141
142
|
SendResult,
|
|
142
143
|
SendSubmission,
|
|
143
144
|
StudentInformation,
|
|
@@ -196,6 +197,7 @@ from librus_python_api.school_reads import (
|
|
|
196
197
|
parse_homework,
|
|
197
198
|
parse_school_detail,
|
|
198
199
|
)
|
|
200
|
+
from librus_python_api.school_year_archive import parse_school_year_archive
|
|
199
201
|
from librus_python_api.sending import SendAttempt, parse_send_acknowledgement
|
|
200
202
|
from librus_python_api.timetable import parse_timetable
|
|
201
203
|
from librus_python_api.transport import (
|
|
@@ -1305,6 +1307,11 @@ class AccountClient:
|
|
|
1305
1307
|
),
|
|
1306
1308
|
result.observation,
|
|
1307
1309
|
result.view,
|
|
1310
|
+
tuple(
|
|
1311
|
+
g
|
|
1312
|
+
for g in result.records.formative
|
|
1313
|
+
if (start is None or start <= g.day) and (end is None or g.day <= end)
|
|
1314
|
+
),
|
|
1308
1315
|
)
|
|
1309
1316
|
|
|
1310
1317
|
async def attendance(
|
|
@@ -1486,6 +1493,30 @@ class AccountClient:
|
|
|
1486
1493
|
|
|
1487
1494
|
return await self._read(("announcements",), fetch, budget, max_age_seconds)
|
|
1488
1495
|
|
|
1496
|
+
async def school_year_archive(
|
|
1497
|
+
self, *, budget: RequestBudget | None = None, max_age_seconds: float = 0.0
|
|
1498
|
+
) -> SchoolYearArchive:
|
|
1499
|
+
"""Read every earlier school year's summary and the achievements list.
|
|
1500
|
+
|
|
1501
|
+
One GET with no side effect; the page's student name and charts are
|
|
1502
|
+
never read. A layout other than the observed one is unsupported.
|
|
1503
|
+
"""
|
|
1504
|
+
|
|
1505
|
+
async def fetch(budget: RequestBudget, _: bool) -> SchoolYearArchive:
|
|
1506
|
+
years, achievements = await self._page(
|
|
1507
|
+
"school_year_archive", budget, parse_school_year_archive
|
|
1508
|
+
)
|
|
1509
|
+
return SchoolYearArchive(
|
|
1510
|
+
self._session_identity(),
|
|
1511
|
+
years,
|
|
1512
|
+
achievements,
|
|
1513
|
+
self._observation("school_year_archive"),
|
|
1514
|
+
)
|
|
1515
|
+
|
|
1516
|
+
return await self._read(
|
|
1517
|
+
("school_year_archive",), fetch, budget, max_age_seconds
|
|
1518
|
+
)
|
|
1519
|
+
|
|
1489
1520
|
async def agenda(
|
|
1490
1521
|
self,
|
|
1491
1522
|
year: int,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: librus-python-api
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: Independent bounded async Librus Synergia client
|
|
5
5
|
Project-URL: Repository, https://github.com/krzysztofbury/librus-python-api
|
|
6
6
|
Project-URL: Issues, https://github.com/krzysztofbury/librus-python-api/issues
|
|
@@ -26,6 +26,8 @@ Description-Content-Type: text/markdown
|
|
|
26
26
|
|
|
27
27
|
# librus-python-api
|
|
28
28
|
|
|
29
|
+
[](https://github.com/krzysztofbury/librus-python-api/actions/workflows/live-check.yml)
|
|
30
|
+
|
|
29
31
|
Read grades, attendance, homework, timetables and messages from **Librus Synergia**
|
|
30
32
|
in Python. This independent, asynchronous client handles login, session recovery,
|
|
31
33
|
pagination and request limits, returning typed Python objects rather than HTML.
|
|
@@ -40,9 +42,10 @@ private ACLs. Windows installations include `pywin32` and `tzdata` automatically
|
|
|
40
42
|
Network paths, reparse-point paths and unsafe storage permissions fail closed.
|
|
41
43
|
See [platform requirements](contracts/persistence.md#windows-disk-boundary-25).
|
|
42
44
|
|
|
43
|
-
**Status:** `1.0
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
**Status:** `1.4.0` adds formative assessments, including the observation card,
|
|
46
|
+
to grade results. The 1.3.0 archive read passed installed live checks for
|
|
47
|
+
populated and empty archives on two parent logins; see [verification](VERIFICATION.md).
|
|
48
|
+
The stable 1.x API does not guarantee every school layout. School features depend
|
|
46
49
|
on what each account can access. See the [compatibility policy](API.md#compatibility-policy).
|
|
47
50
|
See [limitations](#supported-features-and-limitations) below.
|
|
48
51
|
|
|
@@ -51,7 +54,7 @@ See [limitations](#supported-features-and-limitations) below.
|
|
|
51
54
|
Install the exact release from PyPI after its gated publication completes:
|
|
52
55
|
|
|
53
56
|
```sh
|
|
54
|
-
python -m pip install librus-python-api==1.
|
|
57
|
+
python -m pip install librus-python-api==1.4.0
|
|
55
58
|
```
|
|
56
59
|
|
|
57
60
|
Pin the exact version qualified by your application. Before publication, use a
|
|
@@ -67,7 +70,7 @@ python -m pip install .
|
|
|
67
70
|
To install a locally built wheel instead:
|
|
68
71
|
|
|
69
72
|
```sh
|
|
70
|
-
python -m pip install ./dist/librus_python_api-1.
|
|
73
|
+
python -m pip install ./dist/librus_python_api-1.4.0-py3-none-any.whl
|
|
71
74
|
```
|
|
72
75
|
|
|
73
76
|
No CLI or background process is installed: import the library in your own program.
|
|
@@ -148,6 +151,11 @@ attendance = await client.attendance_window(today.replace(day=1), today)
|
|
|
148
151
|
|
|
149
152
|
# Permit reuse of this account's cached result for up to 60 seconds.
|
|
150
153
|
announcements = await client.announcements(max_age_seconds=60)
|
|
154
|
+
|
|
155
|
+
# Earlier school years, separate from the message archive (1.3.0).
|
|
156
|
+
archive = await client.school_year_archive()
|
|
157
|
+
for year in archive.years:
|
|
158
|
+
print(year.school_year, year.subjects)
|
|
151
159
|
```
|
|
152
160
|
|
|
153
161
|
Collections expose named record tuples, for example `homework.items`, rather than
|
|
@@ -233,12 +241,12 @@ removed in 0.7.
|
|
|
233
241
|
|
|
234
242
|
| Area | Available |
|
|
235
243
|
| --- | --- |
|
|
236
|
-
| School data | Profile, grades, attendance, timetable, announcements, agenda, homework and completed lessons |
|
|
244
|
+
| School data | Profile, grades, school-year archive, attendance, timetable, announcements, agenda, homework and completed lessons |
|
|
237
245
|
| Communication | Legacy/modern message lists and content, recipient discovery, bounded attachment streams and explicit sending |
|
|
238
246
|
| Application workflows | Shared multi-account limits, caching, notification checkpoints, optional durable send/notification stores |
|
|
239
247
|
|
|
240
|
-
Completed lessons may be disabled by the school. Behaviour notes
|
|
241
|
-
|
|
248
|
+
Completed lessons may be disabled by the school. Behaviour notes are not
|
|
249
|
+
implemented. Some recipient, archive and receipt layouts remain
|
|
242
250
|
unqualified; backend acceptance is not proof of delivery. Tests cover supported
|
|
243
251
|
contracts, not every school or role. Detailed coverage is in the
|
|
244
252
|
[verification log][verification] and [roadmap][roadmap].
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
librus_python_api/__init__.py,sha256=
|
|
1
|
+
librus_python_api/__init__.py,sha256=Ka6_hAV-fnh3QrjAPyqhWPvaG2eh5w8VT8a5aB_RBNU,6435
|
|
2
2
|
librus_python_api/_notification_bootstrap.py,sha256=18nzH0doXBNaZXCz-_ckIvRrlHTWV5Mm1t5m2-5eYGI,4669
|
|
3
3
|
librus_python_api/_notification_codec.py,sha256=hU6G3zGJ5fR8wfPC6fV2OqBKs-7XpT9eRnKO3FGsTzY,13451
|
|
4
4
|
librus_python_api/_storage.py,sha256=M_6ZAzN4zs7laOdKJBRH1I2rvy3pgqYgC_MZtsd2xtM,15893
|
|
@@ -11,19 +11,20 @@ librus_python_api/attendance_frequency.py,sha256=SBhED5zuyRIHWdw4urFyTJYykb6EYB7
|
|
|
11
11
|
librus_python_api/budget.py,sha256=8fvqT7eIxyVX2P2OBG27XsYHk_HbtSS4lzuecYpHtgs,2878
|
|
12
12
|
librus_python_api/checkpoint.py,sha256=QBmG9GtpyaGRaFq-FXuGA3yR1BjuMFN9V3RTvvi_FNQ,1930
|
|
13
13
|
librus_python_api/completed_lessons.py,sha256=sR2oLSGb2q1580l-2upCBv9sQfIcHi7CQfWr1K0EFXI,7635
|
|
14
|
-
librus_python_api/config.py,sha256=
|
|
14
|
+
librus_python_api/config.py,sha256=zhE2-v3FehMvvsbxMtK1J6iliOp8jYwb4g-e-rFCMEQ,54968
|
|
15
15
|
librus_python_api/detail_fields.py,sha256=K10Stx_ehDTttkUI7-G7a2Ko9exYEu5NJpR-3sBnQ60,1789
|
|
16
16
|
librus_python_api/diagnostics.py,sha256=2l7wq6RA3it-Gwj-JRLcw5qbI37fl3xII6GZA2mZj0o,856
|
|
17
17
|
librus_python_api/exceptions.py,sha256=yWWoo0xyrQAk1d13HOJZnQBsq4ulh_P0hRSI6ZiNQOE,4622
|
|
18
18
|
librus_python_api/files.py,sha256=pQBsIJyfTOZDz83ZB-5fcd-nPMcxR1HJ155h4zTHmsA,9223
|
|
19
|
+
librus_python_api/formative_grades.py,sha256=-F-d1rnFVAIGwg1mN0cryCIBR76OW3v2L4mCWUZ3M7Q,6227
|
|
19
20
|
librus_python_api/grade_parsers.py,sha256=91Kr1-TArzqO-b94ipeIR0a98OV7tRCZQdBWsd9QLZ0,6141
|
|
20
|
-
librus_python_api/grade_records.py,sha256=
|
|
21
|
+
librus_python_api/grade_records.py,sha256=K0ZzmU1KsA8Jo1MXh30mStApgrigaOKk5ldoBnwRrH8,17901
|
|
21
22
|
librus_python_api/homework_range.py,sha256=erYfvS6PhHU5XhnR6LkiLNy677hGVflY3UAuHOs-YUw,1663
|
|
22
23
|
librus_python_api/lifecycle.py,sha256=XzA5CEdZFo7iL5IMUrawnRvf2m9BFe0OseP-fpQFaAE,856
|
|
23
|
-
librus_python_api/markup.py,sha256=
|
|
24
|
+
librus_python_api/markup.py,sha256=mjCZf6KJTWUz2vjfXbJ4zM17hnev7a5AFIOW3ip3gsE,5677
|
|
24
25
|
librus_python_api/message_content.py,sha256=sUPuqpjuavqDyn6QGM5aPH4mpoQV62bDLepSSQ2AsUE,8686
|
|
25
26
|
librus_python_api/messages.py,sha256=K9F8XVs1SojfFpdxxLV8gx9E0g81YxEHjigDzLHmDCg,10230
|
|
26
|
-
librus_python_api/models.py,sha256=
|
|
27
|
+
librus_python_api/models.py,sha256=wWMw2TXmeRWP3yJLSDmdVDYsbXRIztm3mEhCYPH1gco,40310
|
|
27
28
|
librus_python_api/modern_body.py,sha256=3L4P_cIrHdIxZqiinFSQrDMhG6iklT1GoA-VEWMJUiI,2682
|
|
28
29
|
librus_python_api/modern_mailbox.py,sha256=2jF9UnJZ5AF6dip-GrMcv_z2kSLpp87dIpVeXk9jMzA,20600
|
|
29
30
|
librus_python_api/modern_messages.py,sha256=qt2CXtrxCZQ-jTt0aQCD6ZaPAJQZXf0BxmvacPo9B0w,10653
|
|
@@ -38,11 +39,12 @@ librus_python_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
38
39
|
librus_python_api/recipients.py,sha256=6GQCKo-Zjg3VFSg1J8YXcDsy_U3r6-MUS4VCvz0xdaQ,10033
|
|
39
40
|
librus_python_api/scheduler.py,sha256=3JplYxqvQdFz3aKXcoQUJHE9s3FPw_xRhQ2CPNLuQmA,10729
|
|
40
41
|
librus_python_api/school_reads.py,sha256=PCnZ4lv44j-68FgJpQiMteaPSyfhbX8_3CUyE6zhFcQ,14760
|
|
42
|
+
librus_python_api/school_year_archive.py,sha256=D0A1ywMKgoYgvi-d70__SFreMGvMb_72870Akkbb_m8,15492
|
|
41
43
|
librus_python_api/sending.py,sha256=Nvvk2r-6D6Us53a2fH5GW9PcA6bWEfuOvy6c3M5QVcE,4549
|
|
42
|
-
librus_python_api/service.py,sha256=
|
|
44
|
+
librus_python_api/service.py,sha256=yjdKc4-psMDlIHVLud-WA2YDXafKPuTT-hLdVmdWsIM,94464
|
|
43
45
|
librus_python_api/timetable.py,sha256=tX1QK9fQABxzfLaHHqTe8e6rAgdOkCoTOfAHdPwNmLE,9836
|
|
44
46
|
librus_python_api/transport.py,sha256=QnYXnrv1lW4qToYoTv6HyZw-Lt4rDc-_SMTJpMp6_bc,35297
|
|
45
|
-
librus_python_api-1.
|
|
46
|
-
librus_python_api-1.
|
|
47
|
-
librus_python_api-1.
|
|
48
|
-
librus_python_api-1.
|
|
47
|
+
librus_python_api-1.4.0.dist-info/METADATA,sha256=VKqlisucgyKpgIO7aGudJPX8-gKpm5mB3ZEm_59jJzE,12031
|
|
48
|
+
librus_python_api-1.4.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
49
|
+
librus_python_api-1.4.0.dist-info/licenses/LICENSE,sha256=IuerLb3cnZCsaB23znydjjvKqP-LmSJd3syKa9NGUts,1071
|
|
50
|
+
librus_python_api-1.4.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|