librus-python-api 1.8.2__py3-none-any.whl → 1.8.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- librus_python_api/__init__.py +1 -1
- librus_python_api/attendance.py +13 -1
- librus_python_api/config.py +27 -4
- librus_python_api/gateway_grades.py +45 -12
- librus_python_api/grade_records.py +77 -2
- librus_python_api/inspect_page.py +5 -2
- librus_python_api/markup.py +56 -11
- librus_python_api/notification_workflow.py +2 -1
- librus_python_api/service.py +29 -28
- {librus_python_api-1.8.2.dist-info → librus_python_api-1.8.4.dist-info}/METADATA +9 -4
- {librus_python_api-1.8.2.dist-info → librus_python_api-1.8.4.dist-info}/RECORD +13 -13
- {librus_python_api-1.8.2.dist-info → librus_python_api-1.8.4.dist-info}/WHEEL +1 -1
- {librus_python_api-1.8.2.dist-info → librus_python_api-1.8.4.dist-info}/licenses/LICENSE +0 -0
librus_python_api/__init__.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Independent bounded async client. General live compatibility is unqualified."""
|
|
2
2
|
|
|
3
3
|
# Before the imports: config reads it for the User-Agent.
|
|
4
|
-
__version__ = "1.8.
|
|
4
|
+
__version__ = "1.8.4"
|
|
5
5
|
|
|
6
6
|
from librus_python_api.attachments import AttachmentStream, ModernAttachmentStream
|
|
7
7
|
from librus_python_api.budget import RequestBudget
|
librus_python_api/attendance.py
CHANGED
|
@@ -11,6 +11,7 @@ from librus_python_api.config import (
|
|
|
11
11
|
ATTENDANCE_EMPTY_MARKERS,
|
|
12
12
|
ATTENDANCE_MAX_RECORDS,
|
|
13
13
|
ATTENDANCE_SEMESTER_LABELS,
|
|
14
|
+
ATTENDANCE_TOOLTIP_NOTICES,
|
|
14
15
|
GRADE_MAX_METADATA_LENGTH,
|
|
15
16
|
GRADE_MAX_VALUE_LENGTH,
|
|
16
17
|
)
|
|
@@ -29,6 +30,17 @@ def _entry(element: html.HtmlElement, semester: Literal[1, 2]) -> AttendanceReco
|
|
|
29
30
|
title = element.get("title", "")
|
|
30
31
|
if len(title) > GRADE_MAX_METADATA_LENGTH:
|
|
31
32
|
raise LibrusError(ErrorKind.LIMIT)
|
|
33
|
+
# A known Librus notice is a whole line of its own; other unlabelled lines
|
|
34
|
+
# still fail in tooltip_fields.
|
|
35
|
+
notices: list[tuple[str, str]] = []
|
|
36
|
+
lines = []
|
|
37
|
+
for line in re.split(r"<br\s*/?>|\n", title, flags=re.I):
|
|
38
|
+
notice = ATTENDANCE_TOOLTIP_NOTICES.get(markup.tooltip_line(line))
|
|
39
|
+
if notice is None or notice in notices:
|
|
40
|
+
lines.append(line)
|
|
41
|
+
else:
|
|
42
|
+
notices.append(notice)
|
|
43
|
+
title = "<br>".join(lines)
|
|
32
44
|
holder = html.Element("span")
|
|
33
45
|
holder.set("title", re.sub(r"</b>\s*(?=<b[ >])", "</b>\n", title, flags=re.I))
|
|
34
46
|
fields = markup.tooltip_fields(holder)
|
|
@@ -57,7 +69,7 @@ def _entry(element: html.HtmlElement, semester: Literal[1, 2]) -> AttendanceReco
|
|
|
57
69
|
fields.get("Temat zajęć"),
|
|
58
70
|
fields.get("Lekcja"),
|
|
59
71
|
markup.attendance_detail_id(element),
|
|
60
|
-
|
|
72
|
+
(*fields.items(), *notices),
|
|
61
73
|
)
|
|
62
74
|
|
|
63
75
|
|
librus_python_api/config.py
CHANGED
|
@@ -721,8 +721,12 @@ GRADE_MAX_VALUE_LENGTH = 1024
|
|
|
721
721
|
GRADE_MERGED_SUBJECTS = frozenset({"Zachowanie"})
|
|
722
722
|
GRADE_INLINE_DETAIL_LABEL = "Ocena"
|
|
723
723
|
# Tooltip fields whose free text may continue on unlabelled lines. A teacher's
|
|
724
|
-
# multi-line grade comment was observed on 2026-10-27.
|
|
724
|
+
# multi-line grade comment was observed on 2026-10-27. A formative mirror's
|
|
725
|
+
# assessment text after `Ocena` also spans lines (2026-11-08, markup.py).
|
|
725
726
|
TOOLTIP_MULTILINE_LABELS = frozenset({"Komentarz"})
|
|
727
|
+
# Labels a tooltip may repeat. A grade correcting two earlier grades lists one
|
|
728
|
+
# `Poprawa oceny` line per corrected grade in the week view (2026-11-08).
|
|
729
|
+
TOOLTIP_REPEATABLE_LABELS = frozenset({"Poprawa oceny"})
|
|
726
730
|
# Tooltip labels observed on grade, attendance and point pages. Diagnostics
|
|
727
731
|
# show only these by name; any other label is masked as teacher-entered text.
|
|
728
732
|
TOOLTIP_LABELS = frozenset(
|
|
@@ -744,6 +748,7 @@ TOOLTIP_LABELS = frozenset(
|
|
|
744
748
|
"Nauczyciel",
|
|
745
749
|
"Obowiązek wyk. zadania",
|
|
746
750
|
"Ocena",
|
|
751
|
+
"Poprawa oceny",
|
|
747
752
|
"Rodzaj",
|
|
748
753
|
"Suma punktów",
|
|
749
754
|
"Temat zajęć",
|
|
@@ -783,10 +788,12 @@ GRADE_PERIOD_HEADERS = MappingProxyType(
|
|
|
783
788
|
"Ocena roczna": 0,
|
|
784
789
|
}
|
|
785
790
|
)
|
|
786
|
-
# New-schema descriptive grading: the page renders this table with empty
|
|
787
|
-
# semester cells
|
|
791
|
+
# New-schema descriptive grading: the page renders this table either with empty
|
|
792
|
+
# `gradesCell` semester cells, which its script fills from the gateway routes
|
|
793
|
+
# above, or with bare semester text, which needs no gateway read.
|
|
788
794
|
DESCRIPTIVE_TABLE_CLASS = "newSchemaGradesByStudentTable"
|
|
789
795
|
DESCRIPTIVE_ROW_CLASS = "studentRow"
|
|
796
|
+
DESCRIPTIVE_DETAILS_ROW_CLASS = "studentGradesDetailsRow"
|
|
790
797
|
# Gateway account and student identifiers are opaque strings, unlike every
|
|
791
798
|
# other `{id}` route, which takes a numeric ID.
|
|
792
799
|
DESCRIPTIVE_IDENTIFIER = re.compile(r"[A-Za-z0-9_-]{1,128}")
|
|
@@ -838,6 +845,17 @@ ATTENDANCE_SEMESTER_LABELS = MappingProxyType(
|
|
|
838
845
|
}
|
|
839
846
|
)
|
|
840
847
|
ATTENDANCE_EMPTY_MARKERS = frozenset({"", "-", "Brak nieobecności"})
|
|
848
|
+
# Fixed unlabelled tooltip lines Librus appends to an attendance entry, and the
|
|
849
|
+
# metadata pair each becomes. Observed live on 2026-11-06 on two logins: an
|
|
850
|
+
# absence excused through the e-Usprawiedliwienia module.
|
|
851
|
+
ATTENDANCE_TOOLTIP_NOTICES = MappingProxyType(
|
|
852
|
+
{
|
|
853
|
+
"Usprawiedliwienie dodane za pomocą modułu e-Usprawiedliwienia": (
|
|
854
|
+
"Usprawiedliwienie",
|
|
855
|
+
"dodane za pomocą modułu e-Usprawiedliwienia",
|
|
856
|
+
)
|
|
857
|
+
}
|
|
858
|
+
)
|
|
841
859
|
ATTENDANCE_MAX_RECORDS = 2048
|
|
842
860
|
ATTENDANCE_MAX_WINDOW_DAYS = 371
|
|
843
861
|
# Whole-school gateway collections resolve attendance metadata in two requests.
|
|
@@ -849,6 +867,8 @@ ATTENDANCE_DETAIL_PATH_PREFIX = "/przegladaj_nb/szczegoly/"
|
|
|
849
867
|
FORMATIVE_DETAIL_PATH_PREFIX = "/przegladaj_oceny/szczegoly/ksztaltujace/"
|
|
850
868
|
# Recognition only: behaviour-point detail links are classified, never requested.
|
|
851
869
|
BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX = "/przegladaj_oceny/zachowanie_szczegoly/"
|
|
870
|
+
# Tooltip labels of a behaviour grade symbol entry (#114, synthetic example).
|
|
871
|
+
BEHAVIOUR_GRADE_LABELS = frozenset({"Data wystawienia", "Dodał", "Komentarz"})
|
|
852
872
|
# Recognition only: point-grade detail links are classified, never requested.
|
|
853
873
|
POINT_GRADE_DETAIL_PATH_PREFIX = "/przegladaj_oceny_punktowe/szczegoly/"
|
|
854
874
|
FORMATIVE_HEADERS = (
|
|
@@ -916,7 +936,10 @@ ANNOUNCEMENT_LABELS = MappingProxyType(
|
|
|
916
936
|
ANNOUNCEMENT_EMPTY_CLASSES = frozenset(
|
|
917
937
|
{"container", "border-red", "resizeable", "center"}
|
|
918
938
|
)
|
|
919
|
-
|
|
939
|
+
# "Aktualnie brak ogłoszeń" reported in #120 on two accounts.
|
|
940
|
+
ANNOUNCEMENT_EMPTY_MARKERS = frozenset(
|
|
941
|
+
{"brak ogłoszeń", "nie ma ogłoszeń", "aktualnie brak ogłoszeń"}
|
|
942
|
+
)
|
|
920
943
|
TIMETABLE_MAX_PERIODS = 32
|
|
921
944
|
TIMETABLE_MAX_LESSONS_PER_SLOT = 16
|
|
922
945
|
TIMETABLE_MAX_CHANGES_PER_SLOT = 16
|
|
@@ -6,6 +6,11 @@ grades from gateway JSON routes. Routes and field names come from public client
|
|
|
6
6
|
code and the official page script. One authorized read on 2026-10-28 parsed four
|
|
7
7
|
PartialGrades records on one school; pagination keys are still unknown, so every
|
|
8
8
|
unexpected shape fails instead of producing a partial collection.
|
|
9
|
+
|
|
10
|
+
Another school renders the same table with plain text in its semester cells and
|
|
11
|
+
no script hooks (#115, from a reporter's synthetic example). The grade record
|
|
12
|
+
parser reads those cells like any descriptive row, so that table needs no
|
|
13
|
+
gateway read.
|
|
9
14
|
"""
|
|
10
15
|
|
|
11
16
|
import re
|
|
@@ -20,6 +25,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_valida
|
|
|
20
25
|
|
|
21
26
|
from librus_python_api import markup
|
|
22
27
|
from librus_python_api.config import (
|
|
28
|
+
DESCRIPTIVE_DETAILS_ROW_CLASS,
|
|
23
29
|
DESCRIPTIVE_IDENTIFIER,
|
|
24
30
|
DESCRIPTIVE_MAX_NAME_LENGTH,
|
|
25
31
|
DESCRIPTIVE_MAX_SUBJECTS,
|
|
@@ -331,9 +337,13 @@ def parse_gateway_users(body: bytes) -> Mapping[str, str]:
|
|
|
331
337
|
|
|
332
338
|
@dataclass(frozen=True, slots=True)
|
|
333
339
|
class DescriptiveTable:
|
|
334
|
-
"""Numeric subject ID to the subject name the grades page shows.
|
|
340
|
+
"""Numeric subject ID to the subject name the grades page shows.
|
|
341
|
+
|
|
342
|
+
A filled table already holds its grades and has no gateway grades.
|
|
343
|
+
"""
|
|
335
344
|
|
|
336
345
|
subjects: Mapping[int, str] = field(repr=False)
|
|
346
|
+
filled: bool = False
|
|
337
347
|
|
|
338
348
|
|
|
339
349
|
def _classes(element: html.HtmlElement) -> set[str]:
|
|
@@ -343,9 +353,11 @@ def _classes(element: html.HtmlElement) -> set[str]:
|
|
|
343
353
|
def read_descriptive_table(document: html.HtmlElement) -> DescriptiveTable | None:
|
|
344
354
|
"""The new-schema subject table, or None when the page has none.
|
|
345
355
|
|
|
346
|
-
Its rows hold an expander, the subject and two
|
|
347
|
-
|
|
348
|
-
|
|
356
|
+
Its rows hold an expander, the subject and two semester cells. Either
|
|
357
|
+
every row has empty `gradesCell` cells that the page script fills from
|
|
358
|
+
the gateway, or every row has plain cells the page filled itself, which
|
|
359
|
+
`read_grade_records` reads and validates. A script cell the page already
|
|
360
|
+
filled would duplicate the gateway records, so it is unsupported.
|
|
349
361
|
"""
|
|
350
362
|
tables = [
|
|
351
363
|
table
|
|
@@ -369,20 +381,33 @@ def read_descriptive_table(document: html.HtmlElement) -> DescriptiveTable | Non
|
|
|
369
381
|
tables = populated
|
|
370
382
|
subjects: dict[int, str] = {}
|
|
371
383
|
students: set[str | None] = set()
|
|
384
|
+
layouts: set[bool] = set()
|
|
372
385
|
for row in markup.rows(tables[0]):
|
|
373
386
|
if DESCRIPTIVE_ROW_CLASS not in _classes(row):
|
|
374
387
|
continue
|
|
375
388
|
cells = markup.cells(row)
|
|
376
|
-
if (
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
389
|
+
if len(cells) != 4 or not {"micro", "center", "screen-only"} <= _classes(
|
|
390
|
+
cells[0]
|
|
391
|
+
):
|
|
392
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
393
|
+
semesters = cells[2:]
|
|
394
|
+
# A filled cell is bare text: no attributes and no markup, so a renamed
|
|
395
|
+
# or moved script hook never passes as a filled table.
|
|
396
|
+
filled = all(
|
|
397
|
+
not cell.attrib
|
|
398
|
+
and markup.text(cell)
|
|
399
|
+
and not any(isinstance(node.tag, str) for node in cell)
|
|
400
|
+
for cell in semesters
|
|
401
|
+
)
|
|
402
|
+
if not filled and (
|
|
403
|
+
[cell.get("data-semester") for cell in semesters] != ["1", "2"]
|
|
404
|
+
or not all("gradesCell" in _classes(cell) for cell in semesters)
|
|
381
405
|
):
|
|
382
406
|
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
383
|
-
|
|
407
|
+
layouts.add(filled)
|
|
408
|
+
if not filled and any(
|
|
384
409
|
markup.text(cell) or any(isinstance(node.tag, str) for node in cell)
|
|
385
|
-
for cell in
|
|
410
|
+
for cell in semesters
|
|
386
411
|
):
|
|
387
412
|
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
388
413
|
identifier = row.get("data-subject_id", "")
|
|
@@ -398,7 +423,15 @@ def read_descriptive_table(document: html.HtmlElement) -> DescriptiveTable | Non
|
|
|
398
423
|
if len(students) > 1:
|
|
399
424
|
# One page shows one student; rows of several students are unknown.
|
|
400
425
|
raise LibrusError(ErrorKind.PARSE)
|
|
401
|
-
|
|
426
|
+
if len(layouts) > 1:
|
|
427
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
428
|
+
if layouts == {True} and any(
|
|
429
|
+
DESCRIPTIVE_DETAILS_ROW_CLASS in _classes(row) for row in markup.rows(tables[0])
|
|
430
|
+
):
|
|
431
|
+
# Detail rows are what the page script fills from the gateway; a filled
|
|
432
|
+
# table that still has them may also have gateway grades.
|
|
433
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
434
|
+
return DescriptiveTable(MappingProxyType(subjects), layouts == {True})
|
|
402
435
|
|
|
403
436
|
|
|
404
437
|
def parse_grade_page(body: bytes) -> tuple[GradeRecords, DescriptiveTable | None]:
|
|
@@ -8,12 +8,14 @@ from lxml import html
|
|
|
8
8
|
|
|
9
9
|
from librus_python_api import markup
|
|
10
10
|
from librus_python_api.config import (
|
|
11
|
+
BEHAVIOUR_GRADE_LABELS,
|
|
11
12
|
BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX,
|
|
12
13
|
FORMATIVE_DETAIL_PATH_PREFIX,
|
|
13
14
|
GRADE_AVERAGE_HEADERS,
|
|
14
15
|
GRADE_BODY_PREFIX_COLUMNS,
|
|
15
16
|
GRADE_CURRENT_HEADER,
|
|
16
17
|
GRADE_EMPTY_MARKERS,
|
|
18
|
+
GRADE_INLINE_DETAIL_LABEL,
|
|
17
19
|
GRADE_MAX_METADATA_LENGTH,
|
|
18
20
|
GRADE_MAX_RECORDS,
|
|
19
21
|
GRADE_MAX_SUBJECTS,
|
|
@@ -26,6 +28,8 @@ from librus_python_api.config import (
|
|
|
26
28
|
GRADE_PUBLICATION_DATE_LABEL,
|
|
27
29
|
GRADE_PUBLICATION_PERIOD_LABELS,
|
|
28
30
|
GRADE_PUBLICATION_TEACHER_LABEL,
|
|
31
|
+
TOOLTIP_LABELS,
|
|
32
|
+
TOOLTIP_MULTILINE_LABELS,
|
|
29
33
|
)
|
|
30
34
|
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
31
35
|
from librus_python_api.formative_grades import is_formative_table, read_formative_table
|
|
@@ -97,7 +101,16 @@ def _numeric(
|
|
|
97
101
|
semester: Literal[0, 1, 2],
|
|
98
102
|
kind: GradeKind = GradeKind.CURRENT,
|
|
99
103
|
) -> NumericGrade:
|
|
100
|
-
|
|
104
|
+
if markup.detail_id(element.get("href"), FORMATIVE_DETAIL_PATH_PREFIX) is None:
|
|
105
|
+
metadata = markup.tooltip_fields(element)
|
|
106
|
+
else:
|
|
107
|
+
# A formative mirror's assessment text after `Ocena` is free text that
|
|
108
|
+
# may span lines and hold colons (observed live 2026-11-08).
|
|
109
|
+
metadata = markup.tooltip_fields(
|
|
110
|
+
element,
|
|
111
|
+
multiline=TOOLTIP_MULTILINE_LABELS | {GRADE_INLINE_DETAIL_LABEL},
|
|
112
|
+
unknown_multiline=True,
|
|
113
|
+
)
|
|
101
114
|
counts = metadata.get("Licz do średniej")
|
|
102
115
|
if counts is not None and counts.casefold() not in ("tak", "nie"):
|
|
103
116
|
raise LibrusError(ErrorKind.PARSE)
|
|
@@ -126,6 +139,51 @@ def _numeric(
|
|
|
126
139
|
)
|
|
127
140
|
|
|
128
141
|
|
|
142
|
+
def _behaviour_grade(
|
|
143
|
+
element: html.HtmlElement,
|
|
144
|
+
subject: str,
|
|
145
|
+
semester: Literal[0, 1, 2],
|
|
146
|
+
kind: GradeKind = GradeKind.CURRENT,
|
|
147
|
+
) -> NumericGrade:
|
|
148
|
+
"""A behaviour entry with a grade symbol, such as `wz`, instead of points.
|
|
149
|
+
|
|
150
|
+
Its tooltip has the point entries' layout: an unlabelled first line, then
|
|
151
|
+
`Data wystawienia` and `Dodał`. The first line names the entry, as the
|
|
152
|
+
point category does, so it is the category. `Dodał` stays in metadata. A
|
|
153
|
+
label with digits, a point entry's range labels or any other label fails.
|
|
154
|
+
"""
|
|
155
|
+
heading: list[str] = []
|
|
156
|
+
metadata = markup.tooltip_fields(element, heading)
|
|
157
|
+
raw = markup.text(element)
|
|
158
|
+
if (
|
|
159
|
+
not heading
|
|
160
|
+
# A labelled first line means the heading is missing.
|
|
161
|
+
or heading[0].partition(":")[0].strip() in TOOLTIP_LABELS
|
|
162
|
+
# Symbols carry no digits; points or a range are a point entry.
|
|
163
|
+
or not raw
|
|
164
|
+
or re.search(r"[0-9]", raw)
|
|
165
|
+
or not BEHAVIOUR_GRADE_LABELS >= metadata.keys()
|
|
166
|
+
):
|
|
167
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
168
|
+
href = element.get("href")
|
|
169
|
+
if href is not None and len(href) > GRADE_MAX_METADATA_LENGTH:
|
|
170
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
171
|
+
return NumericGrade(
|
|
172
|
+
subject,
|
|
173
|
+
raw,
|
|
174
|
+
markup.civil_date(metadata.get("Data wystawienia")),
|
|
175
|
+
semester,
|
|
176
|
+
None,
|
|
177
|
+
None,
|
|
178
|
+
heading[0],
|
|
179
|
+
None,
|
|
180
|
+
metadata.get("Komentarz"),
|
|
181
|
+
href,
|
|
182
|
+
tuple(metadata.items()),
|
|
183
|
+
kind,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
129
187
|
def _descriptive(
|
|
130
188
|
box: html.HtmlElement,
|
|
131
189
|
element: html.HtmlElement,
|
|
@@ -284,7 +342,21 @@ def _entries(
|
|
|
284
342
|
if len(anchors) + len(descriptions) != 1:
|
|
285
343
|
raise LibrusError(ErrorKind.PARSE)
|
|
286
344
|
if anchors:
|
|
287
|
-
|
|
345
|
+
behaviour = (
|
|
346
|
+
subject in GRADE_MERGED_SUBJECTS
|
|
347
|
+
and markup.detail_id(
|
|
348
|
+
anchors[0].get("href"), BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX
|
|
349
|
+
)
|
|
350
|
+
is not None
|
|
351
|
+
)
|
|
352
|
+
if behaviour and descriptive:
|
|
353
|
+
# A descriptive record has no category for the first line.
|
|
354
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
355
|
+
record = (
|
|
356
|
+
_behaviour_grade(anchors[0], subject, period, kind)
|
|
357
|
+
if behaviour
|
|
358
|
+
else _numeric(anchors[0], subject, period, kind)
|
|
359
|
+
)
|
|
288
360
|
if descriptive:
|
|
289
361
|
href = record.href
|
|
290
362
|
if href is not None and re.sub(
|
|
@@ -540,6 +612,9 @@ def read_grade_records(document: html.HtmlElement) -> GradeRecords:
|
|
|
540
612
|
_read_numeric_table(table, collection, points=True)
|
|
541
613
|
else:
|
|
542
614
|
for row in markup.rows(table):
|
|
615
|
+
if markup.in_header(row):
|
|
616
|
+
# The new-schema table's column labels, not a subject.
|
|
617
|
+
continue
|
|
543
618
|
cells = markup.cells(row)
|
|
544
619
|
if _descriptive_row(cells):
|
|
545
620
|
_read_descriptive_row(cells, collection)
|
|
@@ -19,8 +19,8 @@ from librus_python_api import __version__
|
|
|
19
19
|
from librus_python_api.config import TransportLimits
|
|
20
20
|
from librus_python_api.diagnostics import describe_failure, failure_frames
|
|
21
21
|
from librus_python_api.exceptions import LibrusError
|
|
22
|
+
from librus_python_api.gateway_grades import parse_grade_page
|
|
22
23
|
from librus_python_api.grade_parsers import parse_final_grades
|
|
23
|
-
from librus_python_api.grade_records import parse_grade_records
|
|
24
24
|
from librus_python_api.models import Availability
|
|
25
25
|
from librus_python_api.parsers import parse_profile
|
|
26
26
|
|
|
@@ -31,7 +31,8 @@ NOTICE = (
|
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def _grades(body: bytes) -> dict[str, int]:
|
|
34
|
-
|
|
34
|
+
# Every grade view also checks the new-schema table, as `grades()` does.
|
|
35
|
+
records, table = parse_grade_page(body)
|
|
35
36
|
return {
|
|
36
37
|
"numeric": sum(1 for g in records.numeric if g.point_grade_id is None),
|
|
37
38
|
"point": sum(1 for g in records.numeric if g.point_grade_id is not None),
|
|
@@ -41,6 +42,8 @@ def _grades(body: bytes) -> dict[str, int]:
|
|
|
41
42
|
"averages": len(records.averages),
|
|
42
43
|
"point_results": len(records.point_results),
|
|
43
44
|
"formative": len(records.formative),
|
|
45
|
+
"descriptive_table": int(table is not None),
|
|
46
|
+
"descriptive_table_filled": int(table is not None and table.filled),
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
|
librus_python_api/markup.py
CHANGED
|
@@ -13,7 +13,9 @@ from librus_python_api.config import (
|
|
|
13
13
|
GRADE_MAX_COLUMNS,
|
|
14
14
|
GRADE_MAX_METADATA_FIELDS,
|
|
15
15
|
GRADE_MAX_METADATA_LENGTH,
|
|
16
|
+
TOOLTIP_LABELS,
|
|
16
17
|
TOOLTIP_MULTILINE_LABELS,
|
|
18
|
+
TOOLTIP_REPEATABLE_LABELS,
|
|
17
19
|
UPSTREAM_ORIGINS,
|
|
18
20
|
)
|
|
19
21
|
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
@@ -81,12 +83,28 @@ def colspan(cell: html.HtmlElement) -> int:
|
|
|
81
83
|
return span
|
|
82
84
|
|
|
83
85
|
|
|
84
|
-
def tooltip_fields(
|
|
86
|
+
def tooltip_fields(
|
|
87
|
+
element: html.HtmlElement,
|
|
88
|
+
heading: list[str] | None = None,
|
|
89
|
+
*,
|
|
90
|
+
multiline: frozenset[str] | None = None,
|
|
91
|
+
unknown_multiline: bool = False,
|
|
92
|
+
) -> dict[str, str]:
|
|
85
93
|
"""Parse a 'Label: value<br>' title attribute into unique labelled fields.
|
|
86
94
|
|
|
87
95
|
A free-text field may span lines; an unlabelled line after it continues
|
|
88
|
-
its value. Any other unlabelled line is an unknown layout.
|
|
96
|
+
its value. Any other unlabelled line is an unknown layout. With `heading`,
|
|
97
|
+
the first line is taken whole, colon included, and appended to it.
|
|
98
|
+
`multiline` names the free-text labels (default `Komentarz`); with
|
|
99
|
+
`unknown_multiline`, a field whose label is not a known one (teacher text
|
|
100
|
+
holding a colon) is free text too, and inside such free text a line
|
|
101
|
+
starting with a known label is text unless the tooltip's closing run of
|
|
102
|
+
known-label lines begins there. Otherwise a known label such as `Data`
|
|
103
|
+
never continues. A repeatable label (`Poprawa oceny`) adds its value as
|
|
104
|
+
one more line of the same field.
|
|
89
105
|
"""
|
|
106
|
+
if multiline is None:
|
|
107
|
+
multiline = TOOLTIP_MULTILINE_LABELS
|
|
90
108
|
title = element.get("title", "")
|
|
91
109
|
if len(title) > GRADE_MAX_METADATA_LENGTH:
|
|
92
110
|
raise LibrusError(ErrorKind.LIMIT)
|
|
@@ -95,20 +113,34 @@ def tooltip_fields(element: html.HtmlElement) -> dict[str, str]:
|
|
|
95
113
|
chunks = re.split(r"<br\s*/?>|\n", title, flags=re.I)
|
|
96
114
|
if len(chunks) > GRADE_MAX_METADATA_FIELDS:
|
|
97
115
|
raise LibrusError(ErrorKind.LIMIT)
|
|
98
|
-
for chunk in chunks
|
|
99
|
-
|
|
116
|
+
lines = [tooltip_line(chunk) for chunk in chunks if chunk.strip()]
|
|
117
|
+
# Start of the closing run of known-label lines, such as Kategoria, Data.
|
|
118
|
+
closing = len(lines)
|
|
119
|
+
while closing and _known_label(lines[closing - 1]):
|
|
120
|
+
closing -= 1
|
|
121
|
+
for index, rendered in enumerate(lines):
|
|
122
|
+
if heading is not None and not heading:
|
|
123
|
+
heading.append(rendered)
|
|
100
124
|
continue
|
|
101
|
-
# Tooltips are HTML inside an attribute; parse markup only where present.
|
|
102
|
-
rendered = (
|
|
103
|
-
text(parse_html_document(chunk.encode()))
|
|
104
|
-
if "<" in chunk
|
|
105
|
-
else " ".join(chunk.split())
|
|
106
|
-
)
|
|
107
125
|
key, separator, value = rendered.partition(":")
|
|
108
126
|
key, value = key.strip(), value.strip()
|
|
109
|
-
if
|
|
127
|
+
if (
|
|
128
|
+
last is not None
|
|
129
|
+
and (
|
|
130
|
+
last in multiline or (unknown_multiline and last not in TOOLTIP_LABELS)
|
|
131
|
+
)
|
|
132
|
+
and (
|
|
133
|
+
not separator
|
|
134
|
+
or (unknown_multiline and index < closing and key in TOOLTIP_LABELS)
|
|
135
|
+
)
|
|
136
|
+
):
|
|
110
137
|
result[last] = f"{result[last]}\n{rendered}".strip()
|
|
111
138
|
continue
|
|
139
|
+
if separator and key in result and key in TOOLTIP_REPEATABLE_LABELS:
|
|
140
|
+
# Each repeat keeps its value, in order, as one more line.
|
|
141
|
+
result[key] = f"{result[key]}\n{value}"
|
|
142
|
+
last = key
|
|
143
|
+
continue
|
|
112
144
|
if not separator or not key or key in result:
|
|
113
145
|
raise LibrusError(ErrorKind.PARSE)
|
|
114
146
|
result[key] = value
|
|
@@ -116,6 +148,19 @@ def tooltip_fields(element: html.HtmlElement) -> dict[str, str]:
|
|
|
116
148
|
return result
|
|
117
149
|
|
|
118
150
|
|
|
151
|
+
def _known_label(line: str) -> bool:
|
|
152
|
+
key, separator, _ = line.partition(":")
|
|
153
|
+
return bool(separator) and key.strip() in TOOLTIP_LABELS
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def tooltip_line(chunk: str) -> str:
|
|
157
|
+
"""One tooltip line's text, whitespace normalized."""
|
|
158
|
+
# Tooltips are HTML inside an attribute; parse markup only where present.
|
|
159
|
+
if "<" in chunk:
|
|
160
|
+
return text(parse_html_document(chunk.encode()))
|
|
161
|
+
return " ".join(chunk.split())
|
|
162
|
+
|
|
163
|
+
|
|
119
164
|
def civil_date(value: str | None) -> date:
|
|
120
165
|
"""An ISO date, optionally followed by its weekday label in parentheses."""
|
|
121
166
|
match = re.fullmatch(r"([0-9]{4}-[0-9]{2}-[0-9]{2})(?: \(([^()]+)\))?", value or "")
|
|
@@ -130,7 +130,8 @@ class NotificationWorkflow:
|
|
|
130
130
|
if uncertain:
|
|
131
131
|
raise LibrusError(ErrorKind.CHECKPOINT)
|
|
132
132
|
if not consume:
|
|
133
|
-
|
|
133
|
+
# Nothing is read before the caller agrees to consume new events.
|
|
134
|
+
raise LibrusError(ErrorKind.CONSENT_REQUIRED)
|
|
134
135
|
state = await store._io(lambda: store._read_state(context.identifier))
|
|
135
136
|
ordinary = await self._ordinary(categories, window, budget)
|
|
136
137
|
# Validate whole ordinary results and state capacity before consuming.
|
librus_python_api/service.py
CHANGED
|
@@ -89,7 +89,6 @@ from librus_python_api.gateway_grades import (
|
|
|
89
89
|
parse_user_info,
|
|
90
90
|
)
|
|
91
91
|
from librus_python_api.grade_parsers import parse_final_grades
|
|
92
|
-
from librus_python_api.grade_records import parse_grade_records
|
|
93
92
|
from librus_python_api.homework_range import HomeworkAccumulator
|
|
94
93
|
from librus_python_api.lifecycle import join_owned
|
|
95
94
|
from librus_python_api.message_content import parse_message_content, validate_reference
|
|
@@ -394,6 +393,18 @@ def _require_dates(*days: date | None, max_days: int | None = None) -> None:
|
|
|
394
393
|
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
395
394
|
|
|
396
395
|
|
|
396
|
+
def _check_record_count(records: GradeRecords, added: int) -> None:
|
|
397
|
+
if (
|
|
398
|
+
len(records.numeric)
|
|
399
|
+
+ len(records.descriptive)
|
|
400
|
+
+ len(records.descriptive_summaries)
|
|
401
|
+
+ len(records.formative)
|
|
402
|
+
+ added
|
|
403
|
+
> GRADE_MAX_RECORDS
|
|
404
|
+
):
|
|
405
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
406
|
+
|
|
407
|
+
|
|
397
408
|
def _cooldown_due(error: LibrusError) -> bool:
|
|
398
409
|
"""A denial, an unrecovered expiry, or a logged-out page that a session
|
|
399
410
|
just signed in also got: signing in again at once cannot help."""
|
|
@@ -1072,11 +1083,11 @@ class AccountClient:
|
|
|
1072
1083
|
Callback success acknowledges consumer-owned durable storage. Callback
|
|
1073
1084
|
failure/timeout means acknowledgement unknown, never automatic replay.
|
|
1074
1085
|
"""
|
|
1075
|
-
if (
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1086
|
+
if type(allow_consume_events) is not bool:
|
|
1087
|
+
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
1088
|
+
if not allow_consume_events:
|
|
1089
|
+
raise LibrusError(ErrorKind.CONSENT_REQUIRED)
|
|
1090
|
+
if self._consuming_schedule:
|
|
1080
1091
|
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
1081
1092
|
validate_checkpoint(checkpoint, checkpoint_timeout_seconds)
|
|
1082
1093
|
self._consuming_schedule = True
|
|
@@ -1312,24 +1323,22 @@ class AccountClient:
|
|
|
1312
1323
|
"""Select a grade view with one view-changing POST.
|
|
1313
1324
|
|
|
1314
1325
|
Changes the selected grade filter, not school records. A cache hit makes
|
|
1315
|
-
no POST. Missing inline weight/count data remains unknown.
|
|
1316
|
-
|
|
1326
|
+
no POST. Missing inline weight/count data remains unknown.
|
|
1327
|
+
|
|
1328
|
+
Every view checks a new-schema descriptive table the same way. Its
|
|
1329
|
+
semester cells either are empty script hooks or hold bare text, which
|
|
1330
|
+
becomes undated summaries with no extra request; any other layout
|
|
1331
|
+
fails the read. Only the all view with empty hooks adds that table's
|
|
1317
1332
|
grades from the gateway: three more reads, five when there are grades.
|
|
1318
|
-
Other views do not request them.
|
|
1319
1333
|
"""
|
|
1320
1334
|
form = grade_view_form(view)
|
|
1321
1335
|
|
|
1322
1336
|
async def fetch(budget: RequestBudget, _: bool) -> Grades:
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
records, table = await self._page(
|
|
1329
|
-
"grades", budget, parse_grade_page, form=form
|
|
1330
|
-
)
|
|
1331
|
-
if table is not None:
|
|
1332
|
-
records = await self._gateway_descriptive(records, table, budget)
|
|
1337
|
+
records, table = await self._page(
|
|
1338
|
+
"grades", budget, parse_grade_page, form=form
|
|
1339
|
+
)
|
|
1340
|
+
if view is GradeView.ALL and table is not None and not table.filled:
|
|
1341
|
+
records = await self._gateway_descriptive(records, table, budget)
|
|
1333
1342
|
return Grades(
|
|
1334
1343
|
self._session_identity(), records, self._observation("grades"), view
|
|
1335
1344
|
)
|
|
@@ -2508,15 +2517,7 @@ class AccountClient:
|
|
|
2508
2517
|
"descriptive_users", budget, parse_gateway_users, content_type=JSON
|
|
2509
2518
|
)
|
|
2510
2519
|
added = descriptive_grades(items, subjects, users, table)
|
|
2511
|
-
|
|
2512
|
-
len(records.numeric)
|
|
2513
|
-
+ len(records.descriptive)
|
|
2514
|
-
+ len(records.descriptive_summaries)
|
|
2515
|
-
+ len(records.formative)
|
|
2516
|
-
+ len(added)
|
|
2517
|
-
> GRADE_MAX_RECORDS
|
|
2518
|
-
):
|
|
2519
|
-
raise LibrusError(ErrorKind.LIMIT)
|
|
2520
|
+
_check_record_count(records, len(added))
|
|
2520
2521
|
return replace(records, descriptive=records.descriptive + added)
|
|
2521
2522
|
|
|
2522
2523
|
async def _metadata_value(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: librus-python-api
|
|
3
|
-
Version: 1.8.
|
|
3
|
+
Version: 1.8.4
|
|
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
|
|
@@ -42,7 +42,12 @@ private ACLs. Windows installations include `pywin32` and `tzdata` automatically
|
|
|
42
42
|
Network paths, reparse-point paths and unsafe storage permissions fail closed.
|
|
43
43
|
See [platform requirements](contracts/persistence.md#windows-disk-boundary-25).
|
|
44
44
|
|
|
45
|
-
**Status:** `1.8.
|
|
45
|
+
**Status:** `1.8.4` reads multi-line formative assessment text and repeated
|
|
46
|
+
corrections in grade tooltips, checks the new-schema grade table in every grade view and reports
|
|
47
|
+
missing calendar-consumption consent as `ConsentRequiredError`.
|
|
48
|
+
`1.8.3` reads behaviour grade symbols, filled descriptive
|
|
49
|
+
grade tables, the "Aktualnie brak ogłoszeń" page and absences excused
|
|
50
|
+
through e-Usprawiedliwienia. `1.8.2` keeps the modern mailbox session across a cancelled read
|
|
46
51
|
or a reached limit and sends its own version in the `User-Agent`. `1.8.1` recovers a session Synergia no longer knows (it answers
|
|
47
52
|
with a "Brak dostępu" page, not a login redirect), reads the lucky number
|
|
48
53
|
from the page header and summarizes a page that fails as a whole in
|
|
@@ -64,7 +69,7 @@ See [limitations](#supported-features-and-limitations) below.
|
|
|
64
69
|
Install the exact release from PyPI after its gated publication completes:
|
|
65
70
|
|
|
66
71
|
```sh
|
|
67
|
-
python -m pip install librus-python-api==1.8.
|
|
72
|
+
python -m pip install librus-python-api==1.8.4
|
|
68
73
|
```
|
|
69
74
|
|
|
70
75
|
Pin the exact version qualified by your application. Before publication, use a
|
|
@@ -80,7 +85,7 @@ python -m pip install .
|
|
|
80
85
|
To install a locally built wheel instead:
|
|
81
86
|
|
|
82
87
|
```sh
|
|
83
|
-
python -m pip install ./dist/librus_python_api-1.8.
|
|
88
|
+
python -m pip install ./dist/librus_python_api-1.8.4-py3-none-any.whl
|
|
84
89
|
```
|
|
85
90
|
|
|
86
91
|
No console script or background process is installed: import the library in your
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
librus_python_api/__init__.py,sha256=
|
|
1
|
+
librus_python_api/__init__.py,sha256=A9OaGlvvc-wAqzAp7925LUh9EnmcEx_WncmUXoaZFWU,6571
|
|
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
|
|
@@ -6,25 +6,25 @@ librus_python_api/_windows_filesystem.py,sha256=pQANIGkYrb4Q75gz5mdr5mqYhM_PFblW
|
|
|
6
6
|
librus_python_api/announcements.py,sha256=13FoOR1sq7tt9w6aLf0vclcFKDqLUiIYaO5iYD2QVLA,5928
|
|
7
7
|
librus_python_api/attachment_routes.py,sha256=uSg5wCQqHJiphF0KdI0GatQxZr5SLUx6xWQRsl_U4_U,4123
|
|
8
8
|
librus_python_api/attachments.py,sha256=Iq-ERZWc3gYXu6Lcv6womT2EXCc-c1Q7r_Q6VyaCm2E,10818
|
|
9
|
-
librus_python_api/attendance.py,sha256=
|
|
9
|
+
librus_python_api/attendance.py,sha256=Dq2IC8WERO-9x-LZIN5Nl0PTLYmL1Iq7BZtOz6nssOI,7428
|
|
10
10
|
librus_python_api/attendance_frequency.py,sha256=SBhED5zuyRIHWdw4urFyTJYykb6EYB7eL4dU3mif1EY,5309
|
|
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/class_free_days.py,sha256=IwoCgr-9ZgEYYQ6717s4cDpyFzbosHMzESMabXJLGqA,2422
|
|
14
14
|
librus_python_api/completed_lessons.py,sha256=ngGfBD6K4B6dR4elPYvNRvp40Q-GlrhUsUKqBbYY1KE,10727
|
|
15
|
-
librus_python_api/config.py,sha256=
|
|
15
|
+
librus_python_api/config.py,sha256=rAZK4YfQbhAftSj56tkLJ52DrrhWHzYoSz-ZdwjKUKM,62420
|
|
16
16
|
librus_python_api/detail_fields.py,sha256=vDDi5sV-e30-42SF0hwAUoldiZ5wvYtb1UlLt03lBHw,1708
|
|
17
17
|
librus_python_api/diagnostics.py,sha256=5YbJB8J8RXMqMTmulcn5mJRF1vpi0Pvx00ETrXn9hUY,19036
|
|
18
18
|
librus_python_api/exceptions.py,sha256=x8_z5UTNmuRINTK4buLYiKdYcFA2w6GZP9ZXOL-OnRU,5531
|
|
19
19
|
librus_python_api/files.py,sha256=pQBsIJyfTOZDz83ZB-5fcd-nPMcxR1HJ155h4zTHmsA,9223
|
|
20
20
|
librus_python_api/formative_grades.py,sha256=VykwtGvViZVOJMwPH3Re1RTDCHMr1_m9_xHjZyaXDIQ,6452
|
|
21
|
-
librus_python_api/gateway_grades.py,sha256=
|
|
21
|
+
librus_python_api/gateway_grades.py,sha256=uaCCz743aPlymAa6rDfRb0w2JFC2hJ4yis0hbKdqTeM,17663
|
|
22
22
|
librus_python_api/grade_parsers.py,sha256=ROfffkCP8yj5Q708TZdpp0PDTyVmoh6X7m0y2fyJcwg,11202
|
|
23
|
-
librus_python_api/grade_records.py,sha256=
|
|
23
|
+
librus_python_api/grade_records.py,sha256=xxQ8HrlIMXPtp8oEd3-TUewBhIIBc6Qu7V9Pu2ASxqk,25472
|
|
24
24
|
librus_python_api/homework_range.py,sha256=erYfvS6PhHU5XhnR6LkiLNy677hGVflY3UAuHOs-YUw,1663
|
|
25
|
-
librus_python_api/inspect_page.py,sha256=
|
|
25
|
+
librus_python_api/inspect_page.py,sha256=zSo9SZVWz2POUWC0mNBnwhgHk5arrEOs3l209cxkPSg,5494
|
|
26
26
|
librus_python_api/lifecycle.py,sha256=XzA5CEdZFo7iL5IMUrawnRvf2m9BFe0OseP-fpQFaAE,856
|
|
27
|
-
librus_python_api/markup.py,sha256=
|
|
27
|
+
librus_python_api/markup.py,sha256=TzGt8pRed2n--BpxFtLY0eq1wzdOhvKhehS50uPBi1o,7935
|
|
28
28
|
librus_python_api/message_content.py,sha256=v_qoEAoaYS0_9MQBaj57i5XnC7pzv_HHWefgTt4CNyM,9043
|
|
29
29
|
librus_python_api/messages.py,sha256=K9F8XVs1SojfFpdxxLV8gx9E0g81YxEHjigDzLHmDCg,10230
|
|
30
30
|
librus_python_api/models.py,sha256=IO-SYpqpQeuTej6kqNjBRXDbAbKITRZ1njRUyoEZ8t0,41951
|
|
@@ -33,7 +33,7 @@ librus_python_api/modern_mailbox.py,sha256=2jF9UnJZ5AF6dip-GrMcv_z2kSLpp87dIpVeX
|
|
|
33
33
|
librus_python_api/modern_messages.py,sha256=qt2CXtrxCZQ-jTt0aQCD6ZaPAJQZXf0BxmvacPo9B0w,10653
|
|
34
34
|
librus_python_api/notification_models.py,sha256=4eLTAE9JzHhveoaWtZQwGBolmb87QUP5kOSW7Jjt7J0,4199
|
|
35
35
|
librus_python_api/notification_persistence.py,sha256=8GYPbCVD0AecrC4IUXFkHkhNS-uGXkXzsuKxreTIk9I,49454
|
|
36
|
-
librus_python_api/notification_workflow.py,sha256=
|
|
36
|
+
librus_python_api/notification_workflow.py,sha256=XpWNGWbRsqtpTJinPsw65bjnnEnNpm-qzn0QPxz43Xg,13009
|
|
37
37
|
librus_python_api/notifications.py,sha256=VbA8pqNG_19yYo9y7IqpGRmfEb4qDcvA5gBb1syGJKs,7806
|
|
38
38
|
librus_python_api/parsers.py,sha256=8sdfPsOZYHNYVZ4rwX_de5Y5rPl0umoQPon3uflQFlY,9178
|
|
39
39
|
librus_python_api/parsing.py,sha256=H71srw5yx-RFrAKZFgj-QFX-DIXZmhnGrDTmJhazCB8,1838
|
|
@@ -44,10 +44,10 @@ librus_python_api/scheduler.py,sha256=3JplYxqvQdFz3aKXcoQUJHE9s3FPw_xRhQ2CPNLuQm
|
|
|
44
44
|
librus_python_api/school_reads.py,sha256=YHta7DmhjS5vSa4Djq8pzp01-2jhVfGOo1Mnc738rV4,15209
|
|
45
45
|
librus_python_api/school_year_archive.py,sha256=D0A1ywMKgoYgvi-d70__SFreMGvMb_72870Akkbb_m8,15492
|
|
46
46
|
librus_python_api/sending.py,sha256=Nvvk2r-6D6Us53a2fH5GW9PcA6bWEfuOvy6c3M5QVcE,4549
|
|
47
|
-
librus_python_api/service.py,sha256=
|
|
47
|
+
librus_python_api/service.py,sha256=A24_70Doqk0mh9fAOxOUSNMtuPG-7qaDvUMlBSE8Lrw,103073
|
|
48
48
|
librus_python_api/timetable.py,sha256=tX1QK9fQABxzfLaHHqTe8e6rAgdOkCoTOfAHdPwNmLE,9836
|
|
49
49
|
librus_python_api/transport.py,sha256=yF-DnFy4xGYS_RWQ9161b9WJXOlYF8cFRgdjXmVVUIk,34988
|
|
50
|
-
librus_python_api-1.8.
|
|
51
|
-
librus_python_api-1.8.
|
|
52
|
-
librus_python_api-1.8.
|
|
53
|
-
librus_python_api-1.8.
|
|
50
|
+
librus_python_api-1.8.4.dist-info/METADATA,sha256=M-xBnjR60MNy2jfQSjQsmwDPnwWUNVnIhaoi8dXRSe4,14586
|
|
51
|
+
librus_python_api-1.8.4.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
52
|
+
librus_python_api-1.8.4.dist-info/licenses/LICENSE,sha256=IuerLb3cnZCsaB23znydjjvKqP-LmSJd3syKa9NGUts,1071
|
|
53
|
+
librus_python_api-1.8.4.dist-info/RECORD,,
|
|
File without changes
|