librus-python-api 1.8.1__py3-none-any.whl → 1.8.3__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 +3 -2
- librus_python_api/attachments.py +5 -4
- librus_python_api/attendance.py +13 -1
- librus_python_api/config.py +24 -4
- librus_python_api/exceptions.py +3 -1
- librus_python_api/gateway_grades.py +48 -14
- librus_python_api/grade_records.py +65 -1
- librus_python_api/inspect_page.py +5 -2
- librus_python_api/markup.py +17 -8
- librus_python_api/service.py +44 -24
- librus_python_api/transport.py +16 -10
- {librus_python_api-1.8.1.dist-info → librus_python_api-1.8.3.dist-info}/METADATA +7 -4
- {librus_python_api-1.8.1.dist-info → librus_python_api-1.8.3.dist-info}/RECORD +15 -15
- {librus_python_api-1.8.1.dist-info → librus_python_api-1.8.3.dist-info}/WHEEL +1 -1
- {librus_python_api-1.8.1.dist-info → librus_python_api-1.8.3.dist-info}/licenses/LICENSE +0 -0
librus_python_api/__init__.py
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
"""Independent bounded async client. General live compatibility is unqualified."""
|
|
2
2
|
|
|
3
|
+
# Before the imports: config reads it for the User-Agent.
|
|
4
|
+
__version__ = "1.8.3"
|
|
5
|
+
|
|
3
6
|
from librus_python_api.attachments import AttachmentStream, ModernAttachmentStream
|
|
4
7
|
from librus_python_api.budget import RequestBudget
|
|
5
8
|
from librus_python_api.config import (
|
|
@@ -138,8 +141,6 @@ from librus_python_api.models import (
|
|
|
138
141
|
from librus_python_api.sending import SendAttempt
|
|
139
142
|
from librus_python_api.service import AccountClient, LibrusService
|
|
140
143
|
|
|
141
|
-
__version__ = "1.8.1"
|
|
142
|
-
|
|
143
144
|
__all__ = [
|
|
144
145
|
"ArchiveAbsences",
|
|
145
146
|
"ArchiveAchievement",
|
librus_python_api/attachments.py
CHANGED
|
@@ -15,7 +15,10 @@ from librus_python_api.attachment_routes import (
|
|
|
15
15
|
)
|
|
16
16
|
from librus_python_api.budget import RequestBudget
|
|
17
17
|
from librus_python_api.config import ATTACHMENT_MAX_BYTES
|
|
18
|
-
from librus_python_api.exceptions import
|
|
18
|
+
from librus_python_api.exceptions import (
|
|
19
|
+
ErrorKind,
|
|
20
|
+
LibrusError,
|
|
21
|
+
)
|
|
19
22
|
from librus_python_api.lifecycle import join_owned
|
|
20
23
|
from librus_python_api.models import (
|
|
21
24
|
AttachmentHeaders,
|
|
@@ -283,9 +286,7 @@ class ModernAttachmentStream(AttachmentStream):
|
|
|
283
286
|
budget,
|
|
284
287
|
)
|
|
285
288
|
except BaseException as error:
|
|
286
|
-
client.
|
|
287
|
-
if isinstance(error, SessionExpiredError):
|
|
288
|
-
error._messages_origin = True
|
|
289
|
+
client._modern_read_failed(error)
|
|
289
290
|
raise
|
|
290
291
|
await client._transport.stream_download(
|
|
291
292
|
key,
|
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
|
@@ -31,6 +31,7 @@ from pydantic import (
|
|
|
31
31
|
model_validator,
|
|
32
32
|
)
|
|
33
33
|
|
|
34
|
+
from librus_python_api import __version__
|
|
34
35
|
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
35
36
|
from librus_python_api.models import (
|
|
36
37
|
AttendanceView,
|
|
@@ -113,7 +114,7 @@ UPSTREAM_ORIGINS = MappingProxyType(
|
|
|
113
114
|
OAUTH_QUERY = (("client_id", "46"),)
|
|
114
115
|
SESSION_COOKIE = "oauth_token"
|
|
115
116
|
AUTH_COOKIES = frozenset({SESSION_COOKIE, "DZIENNIKSID", "SDZIENNIKSID"})
|
|
116
|
-
USER_AGENT = "librus-python-api/
|
|
117
|
+
USER_AGENT = f"librus-python-api/{__version__} (independent client)"
|
|
117
118
|
ENDPOINTS: Mapping[str, Endpoint] = MappingProxyType(
|
|
118
119
|
{
|
|
119
120
|
item.operation_id: item
|
|
@@ -435,7 +436,7 @@ ENDPOINTS: Mapping[str, Endpoint] = MappingProxyType(
|
|
|
435
436
|
),
|
|
436
437
|
# Gateway reads behind the grades page's new-schema descriptive
|
|
437
438
|
# table. Routes and field names come from public client code and the
|
|
438
|
-
# official page script
|
|
439
|
+
# official page script; one authorized read parsed four records.
|
|
439
440
|
Endpoint(
|
|
440
441
|
"descriptive_token_info",
|
|
441
442
|
"GET",
|
|
@@ -786,6 +787,7 @@ GRADE_PERIOD_HEADERS = MappingProxyType(
|
|
|
786
787
|
# semester cells and its script loads the grades from the gateway routes above.
|
|
787
788
|
DESCRIPTIVE_TABLE_CLASS = "newSchemaGradesByStudentTable"
|
|
788
789
|
DESCRIPTIVE_ROW_CLASS = "studentRow"
|
|
790
|
+
DESCRIPTIVE_DETAILS_ROW_CLASS = "studentGradesDetailsRow"
|
|
789
791
|
# Gateway account and student identifiers are opaque strings, unlike every
|
|
790
792
|
# other `{id}` route, which takes a numeric ID.
|
|
791
793
|
DESCRIPTIVE_IDENTIFIER = re.compile(r"[A-Za-z0-9_-]{1,128}")
|
|
@@ -837,6 +839,17 @@ ATTENDANCE_SEMESTER_LABELS = MappingProxyType(
|
|
|
837
839
|
}
|
|
838
840
|
)
|
|
839
841
|
ATTENDANCE_EMPTY_MARKERS = frozenset({"", "-", "Brak nieobecności"})
|
|
842
|
+
# Fixed unlabelled tooltip lines Librus appends to an attendance entry, and the
|
|
843
|
+
# metadata pair each becomes. Observed live on 2026-11-06 on two logins: an
|
|
844
|
+
# absence excused through the e-Usprawiedliwienia module.
|
|
845
|
+
ATTENDANCE_TOOLTIP_NOTICES = MappingProxyType(
|
|
846
|
+
{
|
|
847
|
+
"Usprawiedliwienie dodane za pomocą modułu e-Usprawiedliwienia": (
|
|
848
|
+
"Usprawiedliwienie",
|
|
849
|
+
"dodane za pomocą modułu e-Usprawiedliwienia",
|
|
850
|
+
)
|
|
851
|
+
}
|
|
852
|
+
)
|
|
840
853
|
ATTENDANCE_MAX_RECORDS = 2048
|
|
841
854
|
ATTENDANCE_MAX_WINDOW_DAYS = 371
|
|
842
855
|
# Whole-school gateway collections resolve attendance metadata in two requests.
|
|
@@ -848,6 +861,8 @@ ATTENDANCE_DETAIL_PATH_PREFIX = "/przegladaj_nb/szczegoly/"
|
|
|
848
861
|
FORMATIVE_DETAIL_PATH_PREFIX = "/przegladaj_oceny/szczegoly/ksztaltujace/"
|
|
849
862
|
# Recognition only: behaviour-point detail links are classified, never requested.
|
|
850
863
|
BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX = "/przegladaj_oceny/zachowanie_szczegoly/"
|
|
864
|
+
# Tooltip labels of a behaviour grade symbol entry (#114, synthetic example).
|
|
865
|
+
BEHAVIOUR_GRADE_LABELS = frozenset({"Data wystawienia", "Dodał", "Komentarz"})
|
|
851
866
|
# Recognition only: point-grade detail links are classified, never requested.
|
|
852
867
|
POINT_GRADE_DETAIL_PATH_PREFIX = "/przegladaj_oceny_punktowe/szczegoly/"
|
|
853
868
|
FORMATIVE_HEADERS = (
|
|
@@ -862,9 +877,11 @@ FORMATIVE_TEMPLATE_ID = "000000"
|
|
|
862
877
|
FORMATIVE_MAX_TEXT_LENGTH = 65536
|
|
863
878
|
ATTENDANCE_DETAIL_MAX_FIELDS = 32
|
|
864
879
|
ATTENDANCE_METADATA_CACHE_SIZE = 256
|
|
865
|
-
ATTENDANCE_RESULT_CACHE_SIZE = 64
|
|
866
880
|
ATTENDANCE_METADATA_TTL_SECONDS = 3600
|
|
867
881
|
|
|
882
|
+
# Entries of every cached read of one account, oldest first out.
|
|
883
|
+
ACCOUNT_RESULT_CACHE_SIZE = 64
|
|
884
|
+
|
|
868
885
|
ANNOUNCEMENT_MAX_ITEMS = 256
|
|
869
886
|
ANNOUNCEMENT_MAX_FIELD_LENGTH = 1024
|
|
870
887
|
ANNOUNCEMENT_MAX_CONTENT_LENGTH = 65536
|
|
@@ -913,7 +930,10 @@ ANNOUNCEMENT_LABELS = MappingProxyType(
|
|
|
913
930
|
ANNOUNCEMENT_EMPTY_CLASSES = frozenset(
|
|
914
931
|
{"container", "border-red", "resizeable", "center"}
|
|
915
932
|
)
|
|
916
|
-
|
|
933
|
+
# "Aktualnie brak ogłoszeń" reported in #120 on two accounts.
|
|
934
|
+
ANNOUNCEMENT_EMPTY_MARKERS = frozenset(
|
|
935
|
+
{"brak ogłoszeń", "nie ma ogłoszeń", "aktualnie brak ogłoszeń"}
|
|
936
|
+
)
|
|
917
937
|
TIMETABLE_MAX_PERIODS = 32
|
|
918
938
|
TIMETABLE_MAX_LESSONS_PER_SLOT = 16
|
|
919
939
|
TIMETABLE_MAX_CHANGES_PER_SLOT = 16
|
librus_python_api/exceptions.py
CHANGED
|
@@ -136,7 +136,9 @@ class OperationTimeoutError(LibrusError):
|
|
|
136
136
|
|
|
137
137
|
|
|
138
138
|
class LimitError(LibrusError):
|
|
139
|
-
|
|
139
|
+
# Library-private: the bound was the cookie count and the jar was emptied,
|
|
140
|
+
# so the session behind it is gone.
|
|
141
|
+
_cookies_cleared = False
|
|
140
142
|
|
|
141
143
|
|
|
142
144
|
class ParseError(LibrusError):
|
|
@@ -3,8 +3,14 @@
|
|
|
3
3
|
The grades page of a school on the new descriptive grading schema renders a
|
|
4
4
|
subject table whose semester cells are empty; the page script then loads the
|
|
5
5
|
grades from gateway JSON routes. Routes and field names come from public client
|
|
6
|
-
code and the official page script
|
|
7
|
-
|
|
6
|
+
code and the official page script. One authorized read on 2026-10-28 parsed four
|
|
7
|
+
PartialGrades records on one school; pagination keys are still unknown, so every
|
|
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.
|
|
8
14
|
"""
|
|
9
15
|
|
|
10
16
|
import re
|
|
@@ -19,6 +25,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_valida
|
|
|
19
25
|
|
|
20
26
|
from librus_python_api import markup
|
|
21
27
|
from librus_python_api.config import (
|
|
28
|
+
DESCRIPTIVE_DETAILS_ROW_CLASS,
|
|
22
29
|
DESCRIPTIVE_IDENTIFIER,
|
|
23
30
|
DESCRIPTIVE_MAX_NAME_LENGTH,
|
|
24
31
|
DESCRIPTIVE_MAX_SUBJECTS,
|
|
@@ -330,9 +337,13 @@ def parse_gateway_users(body: bytes) -> Mapping[str, str]:
|
|
|
330
337
|
|
|
331
338
|
@dataclass(frozen=True, slots=True)
|
|
332
339
|
class DescriptiveTable:
|
|
333
|
-
"""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
|
+
"""
|
|
334
344
|
|
|
335
345
|
subjects: Mapping[int, str] = field(repr=False)
|
|
346
|
+
filled: bool = False
|
|
336
347
|
|
|
337
348
|
|
|
338
349
|
def _classes(element: html.HtmlElement) -> set[str]:
|
|
@@ -342,9 +353,11 @@ def _classes(element: html.HtmlElement) -> set[str]:
|
|
|
342
353
|
def read_descriptive_table(document: html.HtmlElement) -> DescriptiveTable | None:
|
|
343
354
|
"""The new-schema subject table, or None when the page has none.
|
|
344
355
|
|
|
345
|
-
Its rows hold an expander, the subject and two
|
|
346
|
-
|
|
347
|
-
|
|
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.
|
|
348
361
|
"""
|
|
349
362
|
tables = [
|
|
350
363
|
table
|
|
@@ -368,20 +381,33 @@ def read_descriptive_table(document: html.HtmlElement) -> DescriptiveTable | Non
|
|
|
368
381
|
tables = populated
|
|
369
382
|
subjects: dict[int, str] = {}
|
|
370
383
|
students: set[str | None] = set()
|
|
384
|
+
layouts: set[bool] = set()
|
|
371
385
|
for row in markup.rows(tables[0]):
|
|
372
386
|
if DESCRIPTIVE_ROW_CLASS not in _classes(row):
|
|
373
387
|
continue
|
|
374
388
|
cells = markup.cells(row)
|
|
375
|
-
if (
|
|
376
|
-
|
|
377
|
-
or not {"micro", "center", "screen-only"} <= _classes(cells[0])
|
|
378
|
-
or [cell.get("data-semester") for cell in cells[2:]] != ["1", "2"]
|
|
379
|
-
or not all("gradesCell" in _classes(cell) for cell in cells[2:])
|
|
389
|
+
if len(cells) != 4 or not {"micro", "center", "screen-only"} <= _classes(
|
|
390
|
+
cells[0]
|
|
380
391
|
):
|
|
381
392
|
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
382
|
-
|
|
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)
|
|
405
|
+
):
|
|
406
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
407
|
+
layouts.add(filled)
|
|
408
|
+
if not filled and any(
|
|
383
409
|
markup.text(cell) or any(isinstance(node.tag, str) for node in cell)
|
|
384
|
-
for cell in
|
|
410
|
+
for cell in semesters
|
|
385
411
|
):
|
|
386
412
|
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
387
413
|
identifier = row.get("data-subject_id", "")
|
|
@@ -397,7 +423,15 @@ def read_descriptive_table(document: html.HtmlElement) -> DescriptiveTable | Non
|
|
|
397
423
|
if len(students) > 1:
|
|
398
424
|
# One page shows one student; rows of several students are unknown.
|
|
399
425
|
raise LibrusError(ErrorKind.PARSE)
|
|
400
|
-
|
|
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})
|
|
401
435
|
|
|
402
436
|
|
|
403
437
|
def parse_grade_page(body: bytes) -> tuple[GradeRecords, DescriptiveTable | None]:
|
|
@@ -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
|
+
BEHAVIOUR_GRADE_LABELS,
|
|
11
12
|
BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX,
|
|
12
13
|
FORMATIVE_DETAIL_PATH_PREFIX,
|
|
13
14
|
GRADE_AVERAGE_HEADERS,
|
|
@@ -26,6 +27,7 @@ from librus_python_api.config import (
|
|
|
26
27
|
GRADE_PUBLICATION_DATE_LABEL,
|
|
27
28
|
GRADE_PUBLICATION_PERIOD_LABELS,
|
|
28
29
|
GRADE_PUBLICATION_TEACHER_LABEL,
|
|
30
|
+
TOOLTIP_LABELS,
|
|
29
31
|
)
|
|
30
32
|
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
31
33
|
from librus_python_api.formative_grades import is_formative_table, read_formative_table
|
|
@@ -126,6 +128,51 @@ def _numeric(
|
|
|
126
128
|
)
|
|
127
129
|
|
|
128
130
|
|
|
131
|
+
def _behaviour_grade(
|
|
132
|
+
element: html.HtmlElement,
|
|
133
|
+
subject: str,
|
|
134
|
+
semester: Literal[0, 1, 2],
|
|
135
|
+
kind: GradeKind = GradeKind.CURRENT,
|
|
136
|
+
) -> NumericGrade:
|
|
137
|
+
"""A behaviour entry with a grade symbol, such as `wz`, instead of points.
|
|
138
|
+
|
|
139
|
+
Its tooltip has the point entries' layout: an unlabelled first line, then
|
|
140
|
+
`Data wystawienia` and `Dodał`. The first line names the entry, as the
|
|
141
|
+
point category does, so it is the category. `Dodał` stays in metadata. A
|
|
142
|
+
label with digits, a point entry's range labels or any other label fails.
|
|
143
|
+
"""
|
|
144
|
+
heading: list[str] = []
|
|
145
|
+
metadata = markup.tooltip_fields(element, heading)
|
|
146
|
+
raw = markup.text(element)
|
|
147
|
+
if (
|
|
148
|
+
not heading
|
|
149
|
+
# A labelled first line means the heading is missing.
|
|
150
|
+
or heading[0].partition(":")[0].strip() in TOOLTIP_LABELS
|
|
151
|
+
# Symbols carry no digits; points or a range are a point entry.
|
|
152
|
+
or not raw
|
|
153
|
+
or re.search(r"[0-9]", raw)
|
|
154
|
+
or not BEHAVIOUR_GRADE_LABELS >= metadata.keys()
|
|
155
|
+
):
|
|
156
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
157
|
+
href = element.get("href")
|
|
158
|
+
if href is not None and len(href) > GRADE_MAX_METADATA_LENGTH:
|
|
159
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
160
|
+
return NumericGrade(
|
|
161
|
+
subject,
|
|
162
|
+
raw,
|
|
163
|
+
markup.civil_date(metadata.get("Data wystawienia")),
|
|
164
|
+
semester,
|
|
165
|
+
None,
|
|
166
|
+
None,
|
|
167
|
+
heading[0],
|
|
168
|
+
None,
|
|
169
|
+
metadata.get("Komentarz"),
|
|
170
|
+
href,
|
|
171
|
+
tuple(metadata.items()),
|
|
172
|
+
kind,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
129
176
|
def _descriptive(
|
|
130
177
|
box: html.HtmlElement,
|
|
131
178
|
element: html.HtmlElement,
|
|
@@ -284,7 +331,21 @@ def _entries(
|
|
|
284
331
|
if len(anchors) + len(descriptions) != 1:
|
|
285
332
|
raise LibrusError(ErrorKind.PARSE)
|
|
286
333
|
if anchors:
|
|
287
|
-
|
|
334
|
+
behaviour = (
|
|
335
|
+
subject in GRADE_MERGED_SUBJECTS
|
|
336
|
+
and markup.detail_id(
|
|
337
|
+
anchors[0].get("href"), BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX
|
|
338
|
+
)
|
|
339
|
+
is not None
|
|
340
|
+
)
|
|
341
|
+
if behaviour and descriptive:
|
|
342
|
+
# A descriptive record has no category for the first line.
|
|
343
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
344
|
+
record = (
|
|
345
|
+
_behaviour_grade(anchors[0], subject, period, kind)
|
|
346
|
+
if behaviour
|
|
347
|
+
else _numeric(anchors[0], subject, period, kind)
|
|
348
|
+
)
|
|
288
349
|
if descriptive:
|
|
289
350
|
href = record.href
|
|
290
351
|
if href is not None and re.sub(
|
|
@@ -540,6 +601,9 @@ def read_grade_records(document: html.HtmlElement) -> GradeRecords:
|
|
|
540
601
|
_read_numeric_table(table, collection, points=True)
|
|
541
602
|
else:
|
|
542
603
|
for row in markup.rows(table):
|
|
604
|
+
if markup.in_header(row):
|
|
605
|
+
# The new-schema table's column labels, not a subject.
|
|
606
|
+
continue
|
|
543
607
|
cells = markup.cells(row)
|
|
544
608
|
if _descriptive_row(cells):
|
|
545
609
|
_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
|
+
# The all 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
|
@@ -81,11 +81,14 @@ def colspan(cell: html.HtmlElement) -> int:
|
|
|
81
81
|
return span
|
|
82
82
|
|
|
83
83
|
|
|
84
|
-
def tooltip_fields(
|
|
84
|
+
def tooltip_fields(
|
|
85
|
+
element: html.HtmlElement, heading: list[str] | None = None
|
|
86
|
+
) -> dict[str, str]:
|
|
85
87
|
"""Parse a 'Label: value<br>' title attribute into unique labelled fields.
|
|
86
88
|
|
|
87
89
|
A free-text field may span lines; an unlabelled line after it continues
|
|
88
|
-
its value. Any other unlabelled line is an unknown layout.
|
|
90
|
+
its value. Any other unlabelled line is an unknown layout. With `heading`,
|
|
91
|
+
the first line is taken whole, colon included, and appended to it.
|
|
89
92
|
"""
|
|
90
93
|
title = element.get("title", "")
|
|
91
94
|
if len(title) > GRADE_MAX_METADATA_LENGTH:
|
|
@@ -98,12 +101,10 @@ def tooltip_fields(element: html.HtmlElement) -> dict[str, str]:
|
|
|
98
101
|
for chunk in chunks:
|
|
99
102
|
if not chunk.strip():
|
|
100
103
|
continue
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
else " ".join(chunk.split())
|
|
106
|
-
)
|
|
104
|
+
rendered = tooltip_line(chunk)
|
|
105
|
+
if heading is not None and not heading:
|
|
106
|
+
heading.append(rendered)
|
|
107
|
+
continue
|
|
107
108
|
key, separator, value = rendered.partition(":")
|
|
108
109
|
key, value = key.strip(), value.strip()
|
|
109
110
|
if not separator and last in TOOLTIP_MULTILINE_LABELS:
|
|
@@ -116,6 +117,14 @@ def tooltip_fields(element: html.HtmlElement) -> dict[str, str]:
|
|
|
116
117
|
return result
|
|
117
118
|
|
|
118
119
|
|
|
120
|
+
def tooltip_line(chunk: str) -> str:
|
|
121
|
+
"""One tooltip line's text, whitespace normalized."""
|
|
122
|
+
# Tooltips are HTML inside an attribute; parse markup only where present.
|
|
123
|
+
if "<" in chunk:
|
|
124
|
+
return text(parse_html_document(chunk.encode()))
|
|
125
|
+
return " ".join(chunk.split())
|
|
126
|
+
|
|
127
|
+
|
|
119
128
|
def civil_date(value: str | None) -> date:
|
|
120
129
|
"""An ISO date, optionally followed by its weekday label in parentheses."""
|
|
121
130
|
match = re.fullmatch(r"([0-9]{4}-[0-9]{2}-[0-9]{2})(?: \(([^()]+)\))?", value or "")
|
librus_python_api/service.py
CHANGED
|
@@ -34,11 +34,11 @@ from librus_python_api.completed_lessons import (
|
|
|
34
34
|
validate_selection,
|
|
35
35
|
)
|
|
36
36
|
from librus_python_api.config import (
|
|
37
|
+
ACCOUNT_RESULT_CACHE_SIZE,
|
|
37
38
|
ATTACHMENT_MAX_BYTES,
|
|
38
39
|
ATTENDANCE_MAX_WINDOW_DAYS,
|
|
39
40
|
ATTENDANCE_METADATA_CACHE_SIZE,
|
|
40
41
|
ATTENDANCE_METADATA_TTL_SECONDS,
|
|
41
|
-
ATTENDANCE_RESULT_CACHE_SIZE,
|
|
42
42
|
CHECKPOINT_TIMEOUT_SECONDS,
|
|
43
43
|
ENDPOINTS,
|
|
44
44
|
GRADE_MAX_RECORDS,
|
|
@@ -74,6 +74,7 @@ from librus_python_api.diagnostics import DiagnosticSink, failure_frames
|
|
|
74
74
|
from librus_python_api.exceptions import (
|
|
75
75
|
ErrorKind,
|
|
76
76
|
LibrusError,
|
|
77
|
+
LimitError,
|
|
77
78
|
ParseError,
|
|
78
79
|
SessionExpiredError,
|
|
79
80
|
)
|
|
@@ -393,6 +394,18 @@ def _require_dates(*days: date | None, max_days: int | None = None) -> None:
|
|
|
393
394
|
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
394
395
|
|
|
395
396
|
|
|
397
|
+
def _check_record_count(records: GradeRecords, added: int) -> None:
|
|
398
|
+
if (
|
|
399
|
+
len(records.numeric)
|
|
400
|
+
+ len(records.descriptive)
|
|
401
|
+
+ len(records.descriptive_summaries)
|
|
402
|
+
+ len(records.formative)
|
|
403
|
+
+ added
|
|
404
|
+
> GRADE_MAX_RECORDS
|
|
405
|
+
):
|
|
406
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
407
|
+
|
|
408
|
+
|
|
396
409
|
def _cooldown_due(error: LibrusError) -> bool:
|
|
397
410
|
"""A denial, an unrecovered expiry, or a logged-out page that a session
|
|
398
411
|
just signed in also got: signing in again at once cannot help."""
|
|
@@ -1308,7 +1321,7 @@ class AccountClient:
|
|
|
1308
1321
|
budget: RequestBudget | None = None,
|
|
1309
1322
|
max_age_seconds: float = 0.0,
|
|
1310
1323
|
) -> Grades:
|
|
1311
|
-
"""Select a grade view with one
|
|
1324
|
+
"""Select a grade view with one view-changing POST.
|
|
1312
1325
|
|
|
1313
1326
|
Changes the selected grade filter, not school records. A cache hit makes
|
|
1314
1327
|
no POST. Missing inline weight/count data remains unknown. In the all
|
|
@@ -1327,7 +1340,7 @@ class AccountClient:
|
|
|
1327
1340
|
records, table = await self._page(
|
|
1328
1341
|
"grades", budget, parse_grade_page, form=form
|
|
1329
1342
|
)
|
|
1330
|
-
if table is not None:
|
|
1343
|
+
if table is not None and not table.filled:
|
|
1331
1344
|
records = await self._gateway_descriptive(records, table, budget)
|
|
1332
1345
|
return Grades(
|
|
1333
1346
|
self._session_identity(), records, self._observation("grades"), view
|
|
@@ -1401,7 +1414,7 @@ class AccountClient:
|
|
|
1401
1414
|
budget: RequestBudget | None = None,
|
|
1402
1415
|
max_age_seconds: float = 0.0,
|
|
1403
1416
|
) -> Attendance:
|
|
1404
|
-
"""Read a fixed upstream attendance view
|
|
1417
|
+
"""Read a fixed upstream attendance view with one view-changing POST."""
|
|
1405
1418
|
form = attendance_view_form(view)
|
|
1406
1419
|
|
|
1407
1420
|
async def fetch(budget: RequestBudget, _: bool) -> Attendance:
|
|
@@ -1540,7 +1553,7 @@ class AccountClient:
|
|
|
1540
1553
|
budget: RequestBudget | None = None,
|
|
1541
1554
|
max_age_seconds: float = 0.0,
|
|
1542
1555
|
) -> Timetable:
|
|
1543
|
-
"""Select an explicit school civil week
|
|
1556
|
+
"""Select an explicit school civil week with one view-changing POST."""
|
|
1544
1557
|
form = timetable_form(monday)
|
|
1545
1558
|
|
|
1546
1559
|
async def fetch(budget: RequestBudget, _: bool) -> Timetable:
|
|
@@ -2143,21 +2156,22 @@ class AccountClient:
|
|
|
2143
2156
|
)
|
|
2144
2157
|
raise
|
|
2145
2158
|
self._cache[key] = (time.monotonic(), result)
|
|
2146
|
-
if len(self._cache) >
|
|
2159
|
+
if len(self._cache) > ACCOUNT_RESULT_CACHE_SIZE:
|
|
2147
2160
|
del self._cache[next(iter(self._cache))]
|
|
2148
2161
|
return result
|
|
2149
2162
|
|
|
2150
2163
|
async def _authenticated[T](
|
|
2151
2164
|
self, fetch: Fetch[T], budget: RequestBudget, retry_safe: bool
|
|
2152
2165
|
) -> T:
|
|
2153
|
-
"""Run a read in a session; recover
|
|
2166
|
+
"""Run a read in a session; recover expiry once where a repeat is safe.
|
|
2154
2167
|
|
|
2155
2168
|
Initial login is never retried: a failed credential submission must not
|
|
2156
|
-
be replayed. A
|
|
2157
|
-
|
|
2158
|
-
logged-out page proves the request
|
|
2159
|
-
|
|
2160
|
-
|
|
2169
|
+
be replayed. A retry-safe read is repeated once after a new login, and
|
|
2170
|
+
so is a read without an account effect (`NONE` or `SELECT_VIEW`, never
|
|
2171
|
+
mark-read) answered with a logged-out page, which proves the request
|
|
2172
|
+
was not applied. Other expiry only clears the session so a later
|
|
2173
|
+
explicit call can log in again. A session just signed in that gets the
|
|
2174
|
+
logged-out page fails as PARSE.
|
|
2161
2175
|
"""
|
|
2162
2176
|
fresh_login = self._identity is None
|
|
2163
2177
|
if fresh_login:
|
|
@@ -2231,9 +2245,7 @@ class AccountClient:
|
|
|
2231
2245
|
raise expired from error
|
|
2232
2246
|
except BaseException as error:
|
|
2233
2247
|
if ENDPOINTS[endpoint].origin == "messages":
|
|
2234
|
-
self.
|
|
2235
|
-
if isinstance(error, SessionExpiredError):
|
|
2236
|
-
error._messages_origin = True
|
|
2248
|
+
self._modern_read_failed(error)
|
|
2237
2249
|
raise
|
|
2238
2250
|
|
|
2239
2251
|
def _check_cooldown(self, key: str) -> None:
|
|
@@ -2249,6 +2261,22 @@ class AccountClient:
|
|
|
2249
2261
|
self._metadata_maps.clear()
|
|
2250
2262
|
self._transport.clear_auth()
|
|
2251
2263
|
|
|
2264
|
+
def _modern_read_failed(self, error: BaseException) -> None:
|
|
2265
|
+
"""Drop the modern session unless the failure says nothing about it.
|
|
2266
|
+
|
|
2267
|
+
A cancel or a local bound keeps it. Every other failure drops it, an
|
|
2268
|
+
unrecognized body included: a session the upstream no longer knows may
|
|
2269
|
+
be answered with one, and keeping it would fail every later read, while
|
|
2270
|
+
dropping it costs one launch.
|
|
2271
|
+
"""
|
|
2272
|
+
if isinstance(error, asyncio.CancelledError) or (
|
|
2273
|
+
isinstance(error, LimitError) and not error._cookies_cleared
|
|
2274
|
+
):
|
|
2275
|
+
return
|
|
2276
|
+
self._invalidate_modern()
|
|
2277
|
+
if isinstance(error, SessionExpiredError):
|
|
2278
|
+
error._messages_origin = True
|
|
2279
|
+
|
|
2252
2280
|
def _invalidate_modern(self) -> None:
|
|
2253
2281
|
self._modern_account = None
|
|
2254
2282
|
for key in tuple(self._cache):
|
|
@@ -2492,15 +2520,7 @@ class AccountClient:
|
|
|
2492
2520
|
"descriptive_users", budget, parse_gateway_users, content_type=JSON
|
|
2493
2521
|
)
|
|
2494
2522
|
added = descriptive_grades(items, subjects, users, table)
|
|
2495
|
-
|
|
2496
|
-
len(records.numeric)
|
|
2497
|
-
+ len(records.descriptive)
|
|
2498
|
-
+ len(records.descriptive_summaries)
|
|
2499
|
-
+ len(records.formative)
|
|
2500
|
-
+ len(added)
|
|
2501
|
-
> GRADE_MAX_RECORDS
|
|
2502
|
-
):
|
|
2503
|
-
raise LibrusError(ErrorKind.LIMIT)
|
|
2523
|
+
_check_record_count(records, len(added))
|
|
2504
2524
|
return replace(records, descriptive=records.descriptive + added)
|
|
2505
2525
|
|
|
2506
2526
|
async def _metadata_value(
|
librus_python_api/transport.py
CHANGED
|
@@ -50,7 +50,12 @@ from librus_python_api.config import (
|
|
|
50
50
|
recipient_form,
|
|
51
51
|
validate_modern_query,
|
|
52
52
|
)
|
|
53
|
-
from librus_python_api.exceptions import
|
|
53
|
+
from librus_python_api.exceptions import (
|
|
54
|
+
ErrorKind,
|
|
55
|
+
LibrusError,
|
|
56
|
+
LimitError,
|
|
57
|
+
SessionExpiredError,
|
|
58
|
+
)
|
|
54
59
|
from librus_python_api.models import (
|
|
55
60
|
AttachmentHeaders,
|
|
56
61
|
LoginSubmission,
|
|
@@ -451,9 +456,7 @@ class AiohttpTransport:
|
|
|
451
456
|
proxy=proxy.get_secret_value() if proxy is not None else None,
|
|
452
457
|
) as response:
|
|
453
458
|
self._check_headers(response)
|
|
454
|
-
|
|
455
|
-
session.cookie_jar.clear()
|
|
456
|
-
raise LibrusError(ErrorKind.LIMIT)
|
|
459
|
+
self._check_cookies(session)
|
|
457
460
|
if not modern or response.status not in (400, 422):
|
|
458
461
|
self._check_status(response)
|
|
459
462
|
body = await self._read_body(response, budget)
|
|
@@ -542,9 +545,7 @@ class AiohttpTransport:
|
|
|
542
545
|
proxy=proxy.get_secret_value() if proxy is not None else None,
|
|
543
546
|
) as response:
|
|
544
547
|
self._check_headers(response)
|
|
545
|
-
|
|
546
|
-
session.cookie_jar.clear()
|
|
547
|
-
raise LibrusError(ErrorKind.LIMIT)
|
|
548
|
+
self._check_cookies(session)
|
|
548
549
|
self._check_status(response)
|
|
549
550
|
body = await self._read_payload(response, budget)
|
|
550
551
|
if response.status == 200:
|
|
@@ -843,9 +844,7 @@ class AiohttpTransport:
|
|
|
843
844
|
proxy=proxy.get_secret_value() if proxy is not None else None,
|
|
844
845
|
) as response:
|
|
845
846
|
self._check_headers(response)
|
|
846
|
-
|
|
847
|
-
session.cookie_jar.clear()
|
|
848
|
-
raise LibrusError(ErrorKind.LIMIT)
|
|
847
|
+
self._check_cookies(session)
|
|
849
848
|
self._check_status(response)
|
|
850
849
|
body = await self._read_body(response, budget)
|
|
851
850
|
return TransportResponse(
|
|
@@ -863,6 +862,13 @@ class AiohttpTransport:
|
|
|
863
862
|
):
|
|
864
863
|
raise LibrusError(ErrorKind.LIMIT)
|
|
865
864
|
|
|
865
|
+
def _check_cookies(self, session: aiohttp.ClientSession) -> None:
|
|
866
|
+
if len(session.cookie_jar) > self._limits.max_cookies:
|
|
867
|
+
session.cookie_jar.clear()
|
|
868
|
+
error = LimitError(ErrorKind.LIMIT)
|
|
869
|
+
error._cookies_cleared = True
|
|
870
|
+
raise error
|
|
871
|
+
|
|
866
872
|
def _check_status(self, response: aiohttp.ClientResponse) -> None:
|
|
867
873
|
if response.status in (429, 503):
|
|
868
874
|
pause = self._limits.cooldown_seconds
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: librus-python-api
|
|
3
|
-
Version: 1.8.
|
|
3
|
+
Version: 1.8.3
|
|
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,10 @@ 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.3` reads behaviour grade symbols, filled descriptive
|
|
46
|
+
grade tables, the "Aktualnie brak ogłoszeń" page and absences excused
|
|
47
|
+
through e-Usprawiedliwienia. `1.8.2` keeps the modern mailbox session across a cancelled read
|
|
48
|
+
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
|
|
46
49
|
with a "Brak dostępu" page, not a login redirect), reads the lucky number
|
|
47
50
|
from the page header and summarizes a page that fails as a whole in
|
|
48
51
|
diagnostics. `1.8.0` reports where a failure
|
|
@@ -63,7 +66,7 @@ See [limitations](#supported-features-and-limitations) below.
|
|
|
63
66
|
Install the exact release from PyPI after its gated publication completes:
|
|
64
67
|
|
|
65
68
|
```sh
|
|
66
|
-
python -m pip install librus-python-api==1.8.
|
|
69
|
+
python -m pip install librus-python-api==1.8.3
|
|
67
70
|
```
|
|
68
71
|
|
|
69
72
|
Pin the exact version qualified by your application. Before publication, use a
|
|
@@ -79,7 +82,7 @@ python -m pip install .
|
|
|
79
82
|
To install a locally built wheel instead:
|
|
80
83
|
|
|
81
84
|
```sh
|
|
82
|
-
python -m pip install ./dist/librus_python_api-1.8.
|
|
85
|
+
python -m pip install ./dist/librus_python_api-1.8.3-py3-none-any.whl
|
|
83
86
|
```
|
|
84
87
|
|
|
85
88
|
No console script or background process is installed: import the library in your
|
|
@@ -1,30 +1,30 @@
|
|
|
1
|
-
librus_python_api/__init__.py,sha256=
|
|
1
|
+
librus_python_api/__init__.py,sha256=Ts6HZg9LRMp-MLMbjajwEdNlxVsmW8TnnGKbnzytP1k,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
|
|
5
5
|
librus_python_api/_windows_filesystem.py,sha256=pQANIGkYrb4Q75gz5mdr5mqYhM_PFblWwTxPGM0bQ78,14063
|
|
6
6
|
librus_python_api/announcements.py,sha256=13FoOR1sq7tt9w6aLf0vclcFKDqLUiIYaO5iYD2QVLA,5928
|
|
7
7
|
librus_python_api/attachment_routes.py,sha256=uSg5wCQqHJiphF0KdI0GatQxZr5SLUx6xWQRsl_U4_U,4123
|
|
8
|
-
librus_python_api/attachments.py,sha256=
|
|
9
|
-
librus_python_api/attendance.py,sha256=
|
|
8
|
+
librus_python_api/attachments.py,sha256=Iq-ERZWc3gYXu6Lcv6womT2EXCc-c1Q7r_Q6VyaCm2E,10818
|
|
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=Sz6b5lWllzsKSbCbfGKqN6z-CZfwbCt9MkD2y-OG9CU,62019
|
|
16
16
|
librus_python_api/detail_fields.py,sha256=vDDi5sV-e30-42SF0hwAUoldiZ5wvYtb1UlLt03lBHw,1708
|
|
17
17
|
librus_python_api/diagnostics.py,sha256=5YbJB8J8RXMqMTmulcn5mJRF1vpi0Pvx00ETrXn9hUY,19036
|
|
18
|
-
librus_python_api/exceptions.py,sha256=
|
|
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=IT6A5_09Zu5h03g_p3pHVNepFCMoWag0WIJKg4A9MlQ,24978
|
|
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=W5M21v0DlFw3eaqzwGG6hsI6fkTZVQTwkO2x-NNk4y0,5490
|
|
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=AS0hfJS83lnOUpyfgoNHwUKf8MKBR3WN3p_4aeZ7BOk,6408
|
|
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
|
|
@@ -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=GaavbI6117fcbVuWDzZXDbbgQUczZXXFVNKEMXsFfaw,103063
|
|
48
48
|
librus_python_api/timetable.py,sha256=tX1QK9fQABxzfLaHHqTe8e6rAgdOkCoTOfAHdPwNmLE,9836
|
|
49
|
-
librus_python_api/transport.py,sha256=
|
|
50
|
-
librus_python_api-1.8.
|
|
51
|
-
librus_python_api-1.8.
|
|
52
|
-
librus_python_api-1.8.
|
|
53
|
-
librus_python_api-1.8.
|
|
49
|
+
librus_python_api/transport.py,sha256=yF-DnFy4xGYS_RWQ9161b9WJXOlYF8cFRgdjXmVVUIk,34988
|
|
50
|
+
librus_python_api-1.8.3.dist-info/METADATA,sha256=PwGb0FBnKt-moy91gG0veDWgZZzMcEtymgI-BMFU75k,14361
|
|
51
|
+
librus_python_api-1.8.3.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
52
|
+
librus_python_api-1.8.3.dist-info/licenses/LICENSE,sha256=IuerLb3cnZCsaB23znydjjvKqP-LmSJd3syKa9NGUts,1071
|
|
53
|
+
librus_python_api-1.8.3.dist-info/RECORD,,
|
|
File without changes
|