librus-python-api 1.6.4__py3-none-any.whl → 1.7.1__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/config.py +13 -0
- librus_python_api/formative_grades.py +6 -1
- librus_python_api/grade_parsers.py +153 -25
- librus_python_api/grade_records.py +92 -26
- librus_python_api/markup.py +11 -1
- librus_python_api/models.py +16 -0
- {librus_python_api-1.6.4.dist-info → librus_python_api-1.7.1.dist-info}/METADATA +7 -6
- {librus_python_api-1.6.4.dist-info → librus_python_api-1.7.1.dist-info}/RECORD +11 -11
- {librus_python_api-1.6.4.dist-info → librus_python_api-1.7.1.dist-info}/WHEEL +0 -0
- {librus_python_api-1.6.4.dist-info → librus_python_api-1.7.1.dist-info}/licenses/LICENSE +0 -0
librus_python_api/__init__.py
CHANGED
|
@@ -138,7 +138,7 @@ from librus_python_api.models import (
|
|
|
138
138
|
from librus_python_api.sending import SendAttempt
|
|
139
139
|
from librus_python_api.service import AccountClient, LibrusService
|
|
140
140
|
|
|
141
|
-
__version__ = "1.
|
|
141
|
+
__version__ = "1.7.1"
|
|
142
142
|
|
|
143
143
|
__all__ = [
|
|
144
144
|
"ArchiveAbsences",
|
librus_python_api/config.py
CHANGED
|
@@ -659,6 +659,12 @@ GRADE_MAX_SUBJECTS = 128
|
|
|
659
659
|
GRADE_MAX_VALUE_LENGTH = 1024
|
|
660
660
|
GRADE_MERGED_SUBJECTS = frozenset({"Zachowanie"})
|
|
661
661
|
GRADE_INLINE_DETAIL_LABEL = "Ocena"
|
|
662
|
+
# Tooltip fields whose free text may continue on unlabelled lines. A teacher's
|
|
663
|
+
# multi-line grade comment was observed on 2026-10-27.
|
|
664
|
+
TOOLTIP_MULTILINE_LABELS = frozenset({"Komentarz"})
|
|
665
|
+
# A point-grade ("Oceny punktowe") table marks each subject row's expander image
|
|
666
|
+
# with this ID; ordinary grade rows use `przedmioty_<id>_node`.
|
|
667
|
+
GRADE_POINT_ROW_NODE = re.compile(r"przedmioty_OP_[0-9A-Za-z_]{1,64}_node")
|
|
662
668
|
GRADE_VIEW_FIELDS = MappingProxyType(
|
|
663
669
|
{
|
|
664
670
|
"all": "zmiany_logowanie_wszystkie",
|
|
@@ -671,6 +677,11 @@ GRADE_EMPTY_MARKERS = frozenset({"", "-", "Brak ocen"})
|
|
|
671
677
|
GRADE_AVERAGE_HEADERS = MappingProxyType(
|
|
672
678
|
{"Średnia ocen": 1, "Średnia ocen z drugiego okresu": 2, "Średnia roczna": 0}
|
|
673
679
|
)
|
|
680
|
+
# Point tables label their period results by visible text, not by title. Only
|
|
681
|
+
# "Wyn.I" was observed (owner-relayed screenshot, 2026-10-27); the others
|
|
682
|
+
# follow the same period naming as the ordinary I/II/R summary columns.
|
|
683
|
+
GRADE_POINT_RESULT_HEADER = re.compile(r"Wyn\.\s?(I|II|R)")
|
|
684
|
+
GRADE_POINT_RESULT_PERIODS = MappingProxyType({"I": 1, "II": 2, "R": 0})
|
|
674
685
|
GRADE_MAX_RECORDS = 2048
|
|
675
686
|
GRADE_MAX_METADATA_LENGTH = 8192
|
|
676
687
|
GRADE_MAX_METADATA_FIELDS = 32
|
|
@@ -733,6 +744,8 @@ ATTENDANCE_DETAIL_PATH_PREFIX = "/przegladaj_nb/szczegoly/"
|
|
|
733
744
|
FORMATIVE_DETAIL_PATH_PREFIX = "/przegladaj_oceny/szczegoly/ksztaltujace/"
|
|
734
745
|
# Recognition only: behaviour-point detail links are classified, never requested.
|
|
735
746
|
BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX = "/przegladaj_oceny/zachowanie_szczegoly/"
|
|
747
|
+
# Recognition only: point-grade detail links are classified, never requested.
|
|
748
|
+
POINT_GRADE_DETAIL_PATH_PREFIX = "/przegladaj_oceny_punktowe/szczegoly/"
|
|
736
749
|
FORMATIVE_HEADERS = (
|
|
737
750
|
"przedmiot",
|
|
738
751
|
"ocena kształtująca",
|
|
@@ -58,7 +58,8 @@ def _link(cell: html.HtmlElement) -> tuple[str, str]:
|
|
|
58
58
|
"""The detail ID and full text of the assessment cell.
|
|
59
59
|
|
|
60
60
|
Observed: one link whose optional first child is an empty coloured
|
|
61
|
-
`span.grade-box` marker, followed by the text
|
|
61
|
+
`span.grade-box` marker, followed by the text, which may contain empty
|
|
62
|
+
line breaks (2026-10-27). Anything else is new markup.
|
|
62
63
|
"""
|
|
63
64
|
_single(cell)
|
|
64
65
|
anchors = [node for node in cell if isinstance(node.tag, str)]
|
|
@@ -66,6 +67,10 @@ def _link(cell: html.HtmlElement) -> tuple[str, str]:
|
|
|
66
67
|
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
67
68
|
anchor = anchors[0]
|
|
68
69
|
children = [node for node in anchor.iterdescendants() if isinstance(node.tag, str)]
|
|
70
|
+
breaks = [node for node in children if node.tag == "br"]
|
|
71
|
+
if any(node.getparent() is not anchor or len(node) for node in breaks):
|
|
72
|
+
_fail(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
73
|
+
children = [node for node in children if node.tag != "br"]
|
|
69
74
|
if children:
|
|
70
75
|
marker = children[0]
|
|
71
76
|
if (
|
|
@@ -8,11 +8,14 @@ from lxml import html
|
|
|
8
8
|
from librus_python_api import markup
|
|
9
9
|
from librus_python_api.config import (
|
|
10
10
|
GRADE_BODY_PREFIX_COLUMNS,
|
|
11
|
+
GRADE_CURRENT_HEADER,
|
|
12
|
+
GRADE_EMPTY_MARKERS,
|
|
11
13
|
GRADE_INLINE_DETAIL_LABEL,
|
|
12
14
|
GRADE_MAX_COLUMNS,
|
|
13
15
|
GRADE_MAX_SUBJECTS,
|
|
14
16
|
GRADE_MAX_VALUE_LENGTH,
|
|
15
17
|
GRADE_MERGED_SUBJECTS,
|
|
18
|
+
GRADE_POINT_ROW_NODE,
|
|
16
19
|
GRADE_SUMMARY_HEADERS,
|
|
17
20
|
)
|
|
18
21
|
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
@@ -24,22 +27,34 @@ from librus_python_api.models import (
|
|
|
24
27
|
from librus_python_api.parsers import parse_page
|
|
25
28
|
|
|
26
29
|
|
|
27
|
-
def
|
|
30
|
+
def title_label(title: str, *, points: bool = False) -> str:
|
|
31
|
+
"""A header title's label: its first line, or for a point table all of it.
|
|
32
|
+
|
|
33
|
+
Ordinary titles append an explanation after a line break. Point tables
|
|
34
|
+
break the label itself ("Ocena śródroczna <br> z pierwszego okresu").
|
|
35
|
+
"""
|
|
36
|
+
if points:
|
|
37
|
+
return " ".join(re.sub(r"<br\s*/?>", " ", title, flags=re.I).split())
|
|
38
|
+
return " ".join(re.split(r"<br\s*/?>", title, flags=re.I)[0].split())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _summary_field(cell: html.HtmlElement, *, points: bool = False) -> str | None:
|
|
28
42
|
title = cell.get("title", "")
|
|
29
43
|
if len(title) > GRADE_MAX_VALUE_LENGTH:
|
|
30
44
|
raise LibrusError(ErrorKind.LIMIT)
|
|
31
|
-
|
|
32
|
-
return GRADE_SUMMARY_HEADERS.get(label)
|
|
45
|
+
return GRADE_SUMMARY_HEADERS.get(title_label(title, points=points))
|
|
33
46
|
|
|
34
47
|
|
|
35
|
-
def _header(
|
|
48
|
+
def _header(
|
|
49
|
+
row: html.HtmlElement, *, points: bool = False
|
|
50
|
+
) -> tuple[dict[str, int], int]:
|
|
36
51
|
fields: dict[str, int] = {}
|
|
37
52
|
position = GRADE_BODY_PREFIX_COLUMNS
|
|
38
53
|
for cell in markup.cells(row):
|
|
39
54
|
span = markup.colspan(cell)
|
|
40
55
|
if position + span > GRADE_MAX_COLUMNS:
|
|
41
56
|
raise LibrusError(ErrorKind.LIMIT)
|
|
42
|
-
field = _summary_field(cell)
|
|
57
|
+
field = _summary_field(cell, points=points)
|
|
43
58
|
if field is not None:
|
|
44
59
|
if field in fields:
|
|
45
60
|
raise LibrusError(ErrorKind.PARSE)
|
|
@@ -48,13 +63,53 @@ def _header(row: html.HtmlElement) -> tuple[dict[str, int], int]:
|
|
|
48
63
|
return fields, position
|
|
49
64
|
|
|
50
65
|
|
|
51
|
-
def
|
|
66
|
+
def is_point_table(table: html.HtmlElement) -> bool:
|
|
67
|
+
"""A grade table whose subject rows carry point-grade expander images."""
|
|
68
|
+
if not {"decorated", "stretch"} <= set(table.get("class", "").split()):
|
|
69
|
+
return False
|
|
70
|
+
return any(
|
|
71
|
+
GRADE_POINT_ROW_NODE.fullmatch(image.get("id", ""))
|
|
72
|
+
for row in markup.rows(table)
|
|
73
|
+
for cell in markup.cells(row)[:1]
|
|
74
|
+
for image in cell.iter("img")
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _current_header(table: html.HtmlElement) -> bool:
|
|
79
|
+
"""A grade table: decorated, with a current-grade header row."""
|
|
80
|
+
return {"decorated", "stretch"} <= set(table.get("class", "").split()) and any(
|
|
81
|
+
next(row.iterancestors("thead"), None) is not None
|
|
82
|
+
and any(markup.text(cell) == GRADE_CURRENT_HEADER for cell in markup.cells(row))
|
|
83
|
+
for row in markup.rows(table)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def point_layout(table: html.HtmlElement) -> tuple[dict[str, int], int]:
|
|
88
|
+
"""Summary fields and width from the point table's current-grade header row.
|
|
89
|
+
|
|
90
|
+
Point tables are not required to have an annual column, so the row with
|
|
91
|
+
the two current-grade headers defines the body alignment instead.
|
|
92
|
+
"""
|
|
93
|
+
headers = [
|
|
94
|
+
row
|
|
95
|
+
for row in markup.rows(table)
|
|
96
|
+
if next(row.iterancestors("thead"), None) is not None
|
|
97
|
+
and any(markup.text(cell) == GRADE_CURRENT_HEADER for cell in markup.cells(row))
|
|
98
|
+
]
|
|
99
|
+
if len(headers) != 1:
|
|
100
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
101
|
+
return _header(headers[0], points=True)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _candidates(
|
|
52
105
|
document: html.HtmlElement,
|
|
53
|
-
) -> tuple[html.HtmlElement, dict[str, int], int]:
|
|
106
|
+
) -> list[tuple[html.HtmlElement, dict[str, int], int]]:
|
|
54
107
|
candidates: list[tuple[html.HtmlElement, dict[str, int], int]] = []
|
|
55
108
|
for table in document.iter("table"):
|
|
56
109
|
if not {"decorated", "stretch"} <= set(table.get("class", "").split()):
|
|
57
110
|
continue
|
|
111
|
+
if is_point_table(table):
|
|
112
|
+
continue
|
|
58
113
|
for row in markup.rows(table):
|
|
59
114
|
if next(row.iterancestors("thead"), None) is None:
|
|
60
115
|
continue
|
|
@@ -64,6 +119,13 @@ def _locate(
|
|
|
64
119
|
continue
|
|
65
120
|
fields, width = _header(row)
|
|
66
121
|
candidates.append((table, fields, width))
|
|
122
|
+
return candidates
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _locate(
|
|
126
|
+
document: html.HtmlElement,
|
|
127
|
+
) -> tuple[html.HtmlElement, dict[str, int], int]:
|
|
128
|
+
candidates = _candidates(document)
|
|
67
129
|
if len(candidates) != 1:
|
|
68
130
|
raise LibrusError(ErrorKind.PARSE)
|
|
69
131
|
return candidates[0]
|
|
@@ -85,11 +147,9 @@ def _outside_nested_tables(row: html.HtmlElement) -> str:
|
|
|
85
147
|
return " ".join(parts).strip()
|
|
86
148
|
|
|
87
149
|
|
|
88
|
-
def
|
|
89
|
-
cells: list[html.HtmlElement],
|
|
90
|
-
|
|
91
|
-
width: int,
|
|
92
|
-
) -> SubjectGradeSummary:
|
|
150
|
+
def _subject_columns(
|
|
151
|
+
cells: list[html.HtmlElement], width: int
|
|
152
|
+
) -> tuple[str, list[html.HtmlElement]]:
|
|
93
153
|
if len(cells) < GRADE_BODY_PREFIX_COLUMNS:
|
|
94
154
|
raise LibrusError(ErrorKind.PARSE)
|
|
95
155
|
subject = markup.text(cells[1])
|
|
@@ -105,6 +165,15 @@ def _summary(
|
|
|
105
165
|
columns.extend([cell] * span)
|
|
106
166
|
if len(columns) != width:
|
|
107
167
|
raise LibrusError(ErrorKind.PARSE)
|
|
168
|
+
return subject, columns
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _summary(
|
|
172
|
+
cells: list[html.HtmlElement],
|
|
173
|
+
fields: dict[str, int],
|
|
174
|
+
width: int,
|
|
175
|
+
) -> SubjectGradeSummary:
|
|
176
|
+
subject, columns = _subject_columns(cells, width)
|
|
108
177
|
selected = [columns[index] for index in fields.values()]
|
|
109
178
|
if len({id(cell) for cell in selected}) != len(selected):
|
|
110
179
|
# A single merged value cannot mean two different summary fields.
|
|
@@ -118,12 +187,13 @@ def _summary(
|
|
|
118
187
|
subject,
|
|
119
188
|
values.get("midterm", absent),
|
|
120
189
|
values.get("predicted_annual", absent),
|
|
121
|
-
|
|
190
|
+
# Only a point table may lack the annual column.
|
|
191
|
+
values.get("annual", absent),
|
|
122
192
|
)
|
|
123
193
|
|
|
124
194
|
|
|
125
195
|
def _subject_rows(
|
|
126
|
-
table: html.HtmlElement, width: int
|
|
196
|
+
table: html.HtmlElement, width: int, *, points: bool = False
|
|
127
197
|
) -> Iterator[list[html.HtmlElement]]:
|
|
128
198
|
for row in markup.rows(table):
|
|
129
199
|
if next(row.iterancestors("thead"), None) is not None:
|
|
@@ -131,6 +201,14 @@ def _subject_rows(
|
|
|
131
201
|
cells = markup.cells(row)
|
|
132
202
|
if not cells:
|
|
133
203
|
raise LibrusError(ErrorKind.PARSE)
|
|
204
|
+
# The observed point table closes with an empty footer cell wider than
|
|
205
|
+
# the table; only an empty footer row is skipped.
|
|
206
|
+
if (
|
|
207
|
+
points
|
|
208
|
+
and next(row.iterancestors("tfoot"), None) is not None
|
|
209
|
+
and not any(markup.text(cell) for cell in cells)
|
|
210
|
+
):
|
|
211
|
+
continue
|
|
134
212
|
if list(row.iter("table")):
|
|
135
213
|
if _outside_nested_tables(row):
|
|
136
214
|
raise LibrusError(ErrorKind.PARSE)
|
|
@@ -150,20 +228,70 @@ def _subject_rows(
|
|
|
150
228
|
yield cells
|
|
151
229
|
|
|
152
230
|
|
|
231
|
+
def merge_value(
|
|
232
|
+
first: GradeSummaryValue, other: GradeSummaryValue
|
|
233
|
+
) -> GradeSummaryValue:
|
|
234
|
+
"""One subject's value shown by both grade tables.
|
|
235
|
+
|
|
236
|
+
An assigned value fills an unassigned one and equal values are one value;
|
|
237
|
+
two different assigned values are ambiguous.
|
|
238
|
+
"""
|
|
239
|
+
first_set, other_set = (
|
|
240
|
+
value.raw is not None and value.raw not in GRADE_EMPTY_MARKERS
|
|
241
|
+
for value in (first, other)
|
|
242
|
+
)
|
|
243
|
+
if first_set and other_set and first.raw != other.raw:
|
|
244
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
245
|
+
if other_set and not first_set:
|
|
246
|
+
return other
|
|
247
|
+
# An unassigned shown column still says more than an absent one.
|
|
248
|
+
return other if first.availability is Availability.UNAVAILABLE else first
|
|
249
|
+
|
|
250
|
+
|
|
153
251
|
def parse_final_grades(body: bytes) -> tuple[SubjectGradeSummary, ...]:
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
252
|
+
document = parse_page(body)
|
|
253
|
+
point_tables = [table for table in document.iter("table") if is_point_table(table)]
|
|
254
|
+
if len(point_tables) > 1:
|
|
255
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
256
|
+
# A point-grade school may show the ordinary table or omit it. An ordinary
|
|
257
|
+
# grade table without an annual column is still an unknown layout.
|
|
258
|
+
sources = _candidates(document)
|
|
259
|
+
if len(sources) > 1 or not (sources or point_tables):
|
|
260
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
261
|
+
if point_tables:
|
|
262
|
+
located = {id(table) for table, _, _ in sources}
|
|
263
|
+
if any(
|
|
264
|
+
_current_header(table) and id(table) not in located
|
|
265
|
+
for table in document.iter("table")
|
|
266
|
+
if table not in point_tables
|
|
267
|
+
):
|
|
160
268
|
raise LibrusError(ErrorKind.PARSE)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
269
|
+
fields, width = point_layout(point_tables[0])
|
|
270
|
+
if fields:
|
|
271
|
+
sources.append((point_tables[0], fields, width))
|
|
272
|
+
items: dict[str, SubjectGradeSummary] = {}
|
|
273
|
+
for table, fields, width in sources:
|
|
274
|
+
family_subjects: set[str] = set()
|
|
275
|
+
for cells in _subject_rows(table, width, points=table in point_tables):
|
|
276
|
+
item = _summary(cells, fields, width)
|
|
277
|
+
if item.subject in family_subjects:
|
|
278
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
279
|
+
family_subjects.add(item.subject)
|
|
280
|
+
previous = items.get(item.subject)
|
|
281
|
+
items[item.subject] = (
|
|
282
|
+
item
|
|
283
|
+
if previous is None
|
|
284
|
+
else SubjectGradeSummary(
|
|
285
|
+
item.subject,
|
|
286
|
+
merge_value(previous.midterm, item.midterm),
|
|
287
|
+
merge_value(previous.predicted_annual, item.predicted_annual),
|
|
288
|
+
merge_value(previous.annual, item.annual),
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
if len(items) > GRADE_MAX_SUBJECTS:
|
|
292
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
165
293
|
# No evidenced no-subject marker exists. Missing rows are not empty success;
|
|
166
294
|
# a valid all-unassigned page still lists its subjects with raw '-' values.
|
|
167
295
|
if not items:
|
|
168
296
|
raise LibrusError(ErrorKind.PARSE)
|
|
169
|
-
return tuple(items)
|
|
297
|
+
return tuple(items.values())
|
|
@@ -19,6 +19,8 @@ from librus_python_api.config import (
|
|
|
19
19
|
GRADE_MAX_SUBJECTS,
|
|
20
20
|
GRADE_MERGED_SUBJECTS,
|
|
21
21
|
GRADE_PERIOD_HEADERS,
|
|
22
|
+
GRADE_POINT_RESULT_HEADER,
|
|
23
|
+
GRADE_POINT_RESULT_PERIODS,
|
|
22
24
|
GRADE_PREDICTED_ANNUAL_HEADER,
|
|
23
25
|
GRADE_PREDICTED_PERIOD_HEADERS,
|
|
24
26
|
GRADE_PUBLICATION_DATE_LABEL,
|
|
@@ -27,7 +29,15 @@ from librus_python_api.config import (
|
|
|
27
29
|
)
|
|
28
30
|
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
29
31
|
from librus_python_api.formative_grades import is_formative_table, read_formative_table
|
|
30
|
-
from librus_python_api.grade_parsers import
|
|
32
|
+
from librus_python_api.grade_parsers import (
|
|
33
|
+
_locate,
|
|
34
|
+
_subject_columns,
|
|
35
|
+
_subject_rows,
|
|
36
|
+
_summary,
|
|
37
|
+
is_point_table,
|
|
38
|
+
point_layout,
|
|
39
|
+
title_label,
|
|
40
|
+
)
|
|
31
41
|
from librus_python_api.models import (
|
|
32
42
|
Availability,
|
|
33
43
|
DescriptiveGrade,
|
|
@@ -42,11 +52,13 @@ from librus_python_api.models import (
|
|
|
42
52
|
from librus_python_api.parsers import parse_page
|
|
43
53
|
|
|
44
54
|
|
|
45
|
-
def _label(title: str) -> str:
|
|
46
|
-
return
|
|
55
|
+
def _label(title: str, *, points: bool = False) -> str:
|
|
56
|
+
return title_label(title, points=points)
|
|
47
57
|
|
|
48
58
|
|
|
49
|
-
def _layout(
|
|
59
|
+
def _layout(
|
|
60
|
+
table: html.HtmlElement, *, points: bool = False
|
|
61
|
+
) -> tuple[list[int], dict[int, int]]:
|
|
50
62
|
current: list[int] = []
|
|
51
63
|
averages: dict[int, int] = {}
|
|
52
64
|
for row in markup.rows(table):
|
|
@@ -63,7 +75,12 @@ def _layout(table: html.HtmlElement) -> tuple[list[int], dict[int, int]]:
|
|
|
63
75
|
if markup.colspan(cell) != 1:
|
|
64
76
|
raise LibrusError(ErrorKind.PARSE)
|
|
65
77
|
current.append(position)
|
|
66
|
-
period = GRADE_AVERAGE_HEADERS.get(
|
|
78
|
+
period = GRADE_AVERAGE_HEADERS.get(
|
|
79
|
+
_label(cell.get("title", ""), points=points)
|
|
80
|
+
)
|
|
81
|
+
result = GRADE_POINT_RESULT_HEADER.fullmatch(markup.text(cell))
|
|
82
|
+
if points and period is None and result is not None:
|
|
83
|
+
period = GRADE_POINT_RESULT_PERIODS[result.group(1)]
|
|
67
84
|
if period is not None:
|
|
68
85
|
if period in averages or markup.colspan(cell) != 1:
|
|
69
86
|
raise LibrusError(ErrorKind.PARSE)
|
|
@@ -140,7 +157,9 @@ class _Collection:
|
|
|
140
157
|
default_factory=list, repr=False
|
|
141
158
|
)
|
|
142
159
|
averages: list[SchoolAverage] = field(default_factory=list, repr=False)
|
|
160
|
+
point_results: list[SchoolAverage] = field(default_factory=list, repr=False)
|
|
143
161
|
subjects: set[tuple[bool, str]] = field(default_factory=set, repr=False)
|
|
162
|
+
point_subjects: set[str] = field(default_factory=set, repr=False)
|
|
144
163
|
consumed: set[html.HtmlElement] = field(default_factory=set, repr=False)
|
|
145
164
|
publication_rows: set[html.HtmlElement] = field(default_factory=set, repr=False)
|
|
146
165
|
formative: int = 0
|
|
@@ -164,7 +183,15 @@ class _Collection:
|
|
|
164
183
|
if not value or key in self.subjects:
|
|
165
184
|
raise LibrusError(ErrorKind.PARSE)
|
|
166
185
|
self.subjects.add(key)
|
|
167
|
-
if len(self.subjects) > GRADE_MAX_SUBJECTS:
|
|
186
|
+
if len(self.subjects) + len(self.point_subjects) > GRADE_MAX_SUBJECTS:
|
|
187
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
188
|
+
|
|
189
|
+
def point_subject(self, value: str) -> None:
|
|
190
|
+
# A subject can appear in both the ordinary and the point table.
|
|
191
|
+
if not value or value in self.point_subjects:
|
|
192
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
193
|
+
self.point_subjects.add(value)
|
|
194
|
+
if len(self.subjects) + len(self.point_subjects) > GRADE_MAX_SUBJECTS:
|
|
168
195
|
raise LibrusError(ErrorKind.LIMIT)
|
|
169
196
|
|
|
170
197
|
|
|
@@ -309,7 +336,9 @@ def _read_descriptive_row(
|
|
|
309
336
|
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
310
337
|
|
|
311
338
|
|
|
312
|
-
def _period_columns(
|
|
339
|
+
def _period_columns(
|
|
340
|
+
table: html.HtmlElement, *, points: bool = False
|
|
341
|
+
) -> dict[tuple[int, GradeKind], int]:
|
|
313
342
|
result: dict[tuple[int, GradeKind], int] = {}
|
|
314
343
|
for row in markup.rows(table):
|
|
315
344
|
cells = markup.cells(row)
|
|
@@ -319,7 +348,7 @@ def _period_columns(table: html.HtmlElement) -> dict[tuple[int, GradeKind], int]
|
|
|
319
348
|
continue
|
|
320
349
|
position = GRADE_BODY_PREFIX_COLUMNS
|
|
321
350
|
for cell in cells:
|
|
322
|
-
label = _label(cell.get("title", ""))
|
|
351
|
+
label = _label(cell.get("title", ""), points=points)
|
|
323
352
|
period = GRADE_PERIOD_HEADERS.get(label)
|
|
324
353
|
kind = GradeKind.ANNUAL if period == 0 else GradeKind.PERIOD
|
|
325
354
|
if label == GRADE_PREDICTED_ANNUAL_HEADER:
|
|
@@ -337,19 +366,32 @@ def _period_columns(table: html.HtmlElement) -> dict[tuple[int, GradeKind], int]
|
|
|
337
366
|
return result
|
|
338
367
|
|
|
339
368
|
|
|
340
|
-
def _read_numeric_table(
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
369
|
+
def _read_numeric_table(
|
|
370
|
+
table: html.HtmlElement, collection: _Collection, *, points: bool = False
|
|
371
|
+
) -> None:
|
|
372
|
+
# Preserve the established full-row and summary alignment validation. A
|
|
373
|
+
# point table aligns on its current-grade header row and may lack summaries.
|
|
374
|
+
if points:
|
|
375
|
+
fields, width = point_layout(table)
|
|
376
|
+
else:
|
|
377
|
+
_, fields, width = _locate(table)
|
|
378
|
+
current, means = _layout(table, points=points)
|
|
379
|
+
periods = _period_columns(table, points=points)
|
|
380
|
+
averages = collection.point_results if points else collection.averages
|
|
381
|
+
# Period grades shown by both tables are one record, kept from the first.
|
|
382
|
+
prior = set(collection.numeric) if points else set()
|
|
383
|
+
for cells in _subject_rows(table, width, points=points):
|
|
346
384
|
if cells[0].getparent() in collection.publication_rows:
|
|
347
385
|
continue
|
|
348
|
-
if
|
|
386
|
+
if points:
|
|
387
|
+
subject = _subject_columns(cells, width)[0]
|
|
388
|
+
collection.point_subject(subject)
|
|
389
|
+
elif _descriptive_row(cells):
|
|
349
390
|
_read_descriptive_row(cells, collection)
|
|
350
391
|
continue
|
|
351
|
-
|
|
352
|
-
|
|
392
|
+
else:
|
|
393
|
+
subject = _summary(cells, fields, width).subject
|
|
394
|
+
collection.subject(subject)
|
|
353
395
|
columns = [cell for cell in cells for _ in range(markup.colspan(cell))]
|
|
354
396
|
for period in (1, 2, 0):
|
|
355
397
|
index = means.get(period)
|
|
@@ -360,7 +402,7 @@ def _read_numeric_table(table: html.HtmlElement, collection: _Collection) -> Non
|
|
|
360
402
|
Availability.AVAILABLE, markup.text(columns[index])
|
|
361
403
|
)
|
|
362
404
|
)
|
|
363
|
-
|
|
405
|
+
averages.append(SchoolAverage(subject, period, value))
|
|
364
406
|
if columns[current[0]] is columns[current[1]] and _boxes(columns[current[0]]):
|
|
365
407
|
raise LibrusError(ErrorKind.PARSE)
|
|
366
408
|
seen: set[html.HtmlElement] = set()
|
|
@@ -370,14 +412,24 @@ def _read_numeric_table(table: html.HtmlElement, collection: _Collection) -> Non
|
|
|
370
412
|
subject in GRADE_MERGED_SUBJECTS
|
|
371
413
|
and _behaviour_points(columns[index], collection)
|
|
372
414
|
):
|
|
415
|
+
start = len(collection.numeric)
|
|
373
416
|
_entries(
|
|
374
417
|
columns[index], subject, cast(Literal[1, 2], period), collection
|
|
375
418
|
)
|
|
419
|
+
# A point entry without its detail link would pass as a
|
|
420
|
+
# grade symbol; an unseen link layout fails instead. The
|
|
421
|
+
# observed point table also shows formative entries.
|
|
422
|
+
if points and any(
|
|
423
|
+
record.point_grade_id is None and record.formative_id is None
|
|
424
|
+
for record in collection.numeric[start:]
|
|
425
|
+
):
|
|
426
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
376
427
|
seen.add(columns[index])
|
|
377
428
|
for (period, kind), index in periods.items():
|
|
378
429
|
cell = columns[index]
|
|
379
430
|
if cell in seen and _boxes(cell):
|
|
380
431
|
raise LibrusError(ErrorKind.PARSE)
|
|
432
|
+
start = len(collection.numeric)
|
|
381
433
|
_entries(
|
|
382
434
|
cell,
|
|
383
435
|
subject,
|
|
@@ -386,6 +438,9 @@ def _read_numeric_table(table: html.HtmlElement, collection: _Collection) -> Non
|
|
|
386
438
|
kind=kind,
|
|
387
439
|
require_empty_marker=False,
|
|
388
440
|
)
|
|
441
|
+
collection.numeric[start:] = [
|
|
442
|
+
record for record in collection.numeric[start:] if record not in prior
|
|
443
|
+
]
|
|
389
444
|
|
|
390
445
|
|
|
391
446
|
def _read_publications(table: html.HtmlElement, collection: _Collection) -> None:
|
|
@@ -450,8 +505,13 @@ def parse_grade_records(body: bytes) -> GradeRecords:
|
|
|
450
505
|
collection = _Collection()
|
|
451
506
|
numeric_tables = []
|
|
452
507
|
tables = list(document.iter("table"))
|
|
508
|
+
point_tables = [table for table in tables if is_point_table(table)]
|
|
509
|
+
if len(point_tables) > 1:
|
|
510
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
453
511
|
for table in tables:
|
|
454
|
-
if not {"decorated", "stretch"} <= set(
|
|
512
|
+
if not {"decorated", "stretch"} <= set(
|
|
513
|
+
table.get("class", "").split()
|
|
514
|
+
) or is_point_table(table):
|
|
455
515
|
continue
|
|
456
516
|
if any(
|
|
457
517
|
next(row.iterancestors("thead"), None) is not None
|
|
@@ -472,6 +532,8 @@ def parse_grade_records(body: bytes) -> GradeRecords:
|
|
|
472
532
|
_read_publications(table, collection)
|
|
473
533
|
if table in numeric_tables:
|
|
474
534
|
_read_numeric_table(table, collection)
|
|
535
|
+
elif table in point_tables:
|
|
536
|
+
_read_numeric_table(table, collection, points=True)
|
|
475
537
|
else:
|
|
476
538
|
for row in markup.rows(table):
|
|
477
539
|
cells = markup.cells(row)
|
|
@@ -484,6 +546,7 @@ def parse_grade_records(body: bytes) -> GradeRecords:
|
|
|
484
546
|
<= set(cells[0].get("class", "").split())
|
|
485
547
|
):
|
|
486
548
|
raise LibrusError(ErrorKind.PARSE)
|
|
549
|
+
grade_tables = numeric_tables + point_tables
|
|
487
550
|
for element in document.iter():
|
|
488
551
|
if not isinstance(element.tag, str) or element in collection.consumed:
|
|
489
552
|
continue
|
|
@@ -491,27 +554,29 @@ def parse_grade_records(body: bytes) -> GradeRecords:
|
|
|
491
554
|
"Data:" in (node.get("title") or "") for node in element.iter()
|
|
492
555
|
)
|
|
493
556
|
if dated_box:
|
|
494
|
-
if any(
|
|
495
|
-
parent in numeric_tables for parent in element.iterancestors("table")
|
|
496
|
-
):
|
|
557
|
+
if any(parent in grade_tables for parent in element.iterancestors("table")):
|
|
497
558
|
# Expanded full-row detail mirrors were intentionally excluded.
|
|
498
559
|
cell = next(element.iterancestors("td", "th"), None)
|
|
499
560
|
if (
|
|
500
561
|
cell is not None
|
|
501
|
-
and next(cell.iterancestors("table"), None) not in
|
|
562
|
+
and next(cell.iterancestors("table"), None) not in grade_tables
|
|
502
563
|
):
|
|
503
564
|
continue
|
|
504
565
|
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
505
566
|
for anchor in document.iter("a"):
|
|
506
|
-
# A formative link outside
|
|
567
|
+
# A formative link outside the known tables is a new layout.
|
|
507
568
|
if markup.detail_id(
|
|
508
569
|
anchor.get("href"), FORMATIVE_DETAIL_PATH_PREFIX
|
|
509
570
|
) and not any(
|
|
510
|
-
parent in
|
|
571
|
+
parent in grade_tables or parent in formative_tables
|
|
511
572
|
for parent in anchor.iterancestors("table")
|
|
512
573
|
):
|
|
513
574
|
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
514
|
-
if
|
|
575
|
+
if (
|
|
576
|
+
not collection.subjects
|
|
577
|
+
and not collection.point_subjects
|
|
578
|
+
and not collection.descriptive
|
|
579
|
+
):
|
|
515
580
|
raise LibrusError(ErrorKind.PARSE)
|
|
516
581
|
for descriptive, subject in sorted(collection.subjects):
|
|
517
582
|
if descriptive and (False, subject) not in collection.subjects:
|
|
@@ -529,4 +594,5 @@ def parse_grade_records(body: bytes) -> GradeRecords:
|
|
|
529
594
|
tuple(collection.averages),
|
|
530
595
|
tuple(collection.descriptive_summaries),
|
|
531
596
|
formative,
|
|
597
|
+
tuple(collection.point_results),
|
|
532
598
|
)
|
librus_python_api/markup.py
CHANGED
|
@@ -13,6 +13,7 @@ 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_MULTILINE_LABELS,
|
|
16
17
|
UPSTREAM_ORIGINS,
|
|
17
18
|
)
|
|
18
19
|
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
@@ -81,11 +82,16 @@ def colspan(cell: html.HtmlElement) -> int:
|
|
|
81
82
|
|
|
82
83
|
|
|
83
84
|
def tooltip_fields(element: html.HtmlElement) -> dict[str, str]:
|
|
84
|
-
"""Parse a 'Label: value<br>' title attribute into unique labelled fields.
|
|
85
|
+
"""Parse a 'Label: value<br>' title attribute into unique labelled fields.
|
|
86
|
+
|
|
87
|
+
A free-text field may span lines; an unlabelled line after it continues
|
|
88
|
+
its value. Any other unlabelled line is an unknown layout.
|
|
89
|
+
"""
|
|
85
90
|
title = element.get("title", "")
|
|
86
91
|
if len(title) > GRADE_MAX_METADATA_LENGTH:
|
|
87
92
|
raise LibrusError(ErrorKind.LIMIT)
|
|
88
93
|
result: dict[str, str] = {}
|
|
94
|
+
last: str | None = None
|
|
89
95
|
chunks = re.split(r"<br\s*/?>|\n", title, flags=re.I)
|
|
90
96
|
if len(chunks) > GRADE_MAX_METADATA_FIELDS:
|
|
91
97
|
raise LibrusError(ErrorKind.LIMIT)
|
|
@@ -100,9 +106,13 @@ def tooltip_fields(element: html.HtmlElement) -> dict[str, str]:
|
|
|
100
106
|
)
|
|
101
107
|
key, separator, value = rendered.partition(":")
|
|
102
108
|
key, value = key.strip(), value.strip()
|
|
109
|
+
if not separator and last in TOOLTIP_MULTILINE_LABELS:
|
|
110
|
+
result[last] = f"{result[last]}\n{rendered}".strip()
|
|
111
|
+
continue
|
|
103
112
|
if not separator or not key or key in result:
|
|
104
113
|
raise LibrusError(ErrorKind.PARSE)
|
|
105
114
|
result[key] = value
|
|
115
|
+
last = key
|
|
106
116
|
return result
|
|
107
117
|
|
|
108
118
|
|
librus_python_api/models.py
CHANGED
|
@@ -1053,6 +1053,19 @@ class NumericGrade:
|
|
|
1053
1053
|
|
|
1054
1054
|
return detail_id(self.href, FORMATIVE_DETAIL_PATH_PREFIX)
|
|
1055
1055
|
|
|
1056
|
+
@property
|
|
1057
|
+
def point_grade_id(self) -> str | None:
|
|
1058
|
+
"""The point-grade ID when `href` is a point-grade detail link.
|
|
1059
|
+
|
|
1060
|
+
`raw` is then a score on the scale its category names, such as
|
|
1061
|
+
`(0-100)`, not a grade symbol. Computed for the same identity reason
|
|
1062
|
+
as `formative_id`.
|
|
1063
|
+
"""
|
|
1064
|
+
from librus_python_api.config import POINT_GRADE_DETAIL_PATH_PREFIX
|
|
1065
|
+
from librus_python_api.markup import detail_id
|
|
1066
|
+
|
|
1067
|
+
return detail_id(self.href, POINT_GRADE_DETAIL_PATH_PREFIX)
|
|
1068
|
+
|
|
1056
1069
|
|
|
1057
1070
|
@dataclass(frozen=True, slots=True)
|
|
1058
1071
|
class DescriptiveGrade:
|
|
@@ -1123,6 +1136,9 @@ class GradeRecords:
|
|
|
1123
1136
|
default=(), repr=False
|
|
1124
1137
|
)
|
|
1125
1138
|
formative: tuple[FormativeGrade, ...] = field(default=(), repr=False)
|
|
1139
|
+
# A point table's period results, on its score scale; never merged into
|
|
1140
|
+
# `averages`, whose values are the ordinary grade-scale means.
|
|
1141
|
+
point_results: tuple[SchoolAverage, ...] = field(default=(), repr=False)
|
|
1126
1142
|
|
|
1127
1143
|
|
|
1128
1144
|
@dataclass(frozen=True, slots=True)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: librus-python-api
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.7.1
|
|
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,9 +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.
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
**Status:** `1.7.1` reads point grades ("Oceny punktowe") as an affected
|
|
46
|
+
school's page shows them, and multi-line grade comments. It keeps the 1.6.4 linear-time message body
|
|
47
|
+
rendering and the 1.6.2 completed-lesson and grade layouts, including behaviour
|
|
48
|
+
points, which are validated and not reported as grades.
|
|
48
49
|
Class free days, typed capability outcomes and the live-check freshness watchdog
|
|
49
50
|
remain available. See [verification](VERIFICATION.md).
|
|
50
51
|
The stable 1.x API does not guarantee every school layout. School features depend
|
|
@@ -56,7 +57,7 @@ See [limitations](#supported-features-and-limitations) below.
|
|
|
56
57
|
Install the exact release from PyPI after its gated publication completes:
|
|
57
58
|
|
|
58
59
|
```sh
|
|
59
|
-
python -m pip install librus-python-api==1.
|
|
60
|
+
python -m pip install librus-python-api==1.7.1
|
|
60
61
|
```
|
|
61
62
|
|
|
62
63
|
Pin the exact version qualified by your application. Before publication, use a
|
|
@@ -72,7 +73,7 @@ python -m pip install .
|
|
|
72
73
|
To install a locally built wheel instead:
|
|
73
74
|
|
|
74
75
|
```sh
|
|
75
|
-
python -m pip install ./dist/librus_python_api-1.
|
|
76
|
+
python -m pip install ./dist/librus_python_api-1.7.1-py3-none-any.whl
|
|
76
77
|
```
|
|
77
78
|
|
|
78
79
|
No CLI or background process is installed: import the library in your own program.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
librus_python_api/__init__.py,sha256=
|
|
1
|
+
librus_python_api/__init__.py,sha256=zkRU9gygB0I0AX3S-OJ9FVin2M5p6JDcFJTIcFjKBM4,6513
|
|
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
|
|
@@ -12,20 +12,20 @@ librus_python_api/budget.py,sha256=8fvqT7eIxyVX2P2OBG27XsYHk_HbtSS4lzuecYpHtgs,2
|
|
|
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=5ArhWxsr_bp-n7ZOwLJ9MWm8FddN5gAI_zWlfpEWPlU,57024
|
|
16
16
|
librus_python_api/detail_fields.py,sha256=vDDi5sV-e30-42SF0hwAUoldiZ5wvYtb1UlLt03lBHw,1708
|
|
17
17
|
librus_python_api/diagnostics.py,sha256=2l7wq6RA3it-Gwj-JRLcw5qbI37fl3xII6GZA2mZj0o,856
|
|
18
18
|
librus_python_api/exceptions.py,sha256=adVcJuzfB8wGrUlF2SfiyIlE9ZZyTAvGdWRtQIjsA3I,4879
|
|
19
19
|
librus_python_api/files.py,sha256=pQBsIJyfTOZDz83ZB-5fcd-nPMcxR1HJ155h4zTHmsA,9223
|
|
20
|
-
librus_python_api/formative_grades.py,sha256=
|
|
21
|
-
librus_python_api/grade_parsers.py,sha256=
|
|
22
|
-
librus_python_api/grade_records.py,sha256=
|
|
20
|
+
librus_python_api/formative_grades.py,sha256=VykwtGvViZVOJMwPH3Re1RTDCHMr1_m9_xHjZyaXDIQ,6452
|
|
21
|
+
librus_python_api/grade_parsers.py,sha256=ROfffkCP8yj5Q708TZdpp0PDTyVmoh6X7m0y2fyJcwg,11202
|
|
22
|
+
librus_python_api/grade_records.py,sha256=5MDl8R7R6e4yLdIbvkIieWLgdD1QVCKXtNoW1rqAh2w,22571
|
|
23
23
|
librus_python_api/homework_range.py,sha256=erYfvS6PhHU5XhnR6LkiLNy677hGVflY3UAuHOs-YUw,1663
|
|
24
24
|
librus_python_api/lifecycle.py,sha256=XzA5CEdZFo7iL5IMUrawnRvf2m9BFe0OseP-fpQFaAE,856
|
|
25
|
-
librus_python_api/markup.py,sha256=
|
|
25
|
+
librus_python_api/markup.py,sha256=p97ZRPqhGYTEBbzJq_0j4j8AKihgQzJqTv__QUNm8Ws,6084
|
|
26
26
|
librus_python_api/message_content.py,sha256=v_qoEAoaYS0_9MQBaj57i5XnC7pzv_HHWefgTt4CNyM,9043
|
|
27
27
|
librus_python_api/messages.py,sha256=K9F8XVs1SojfFpdxxLV8gx9E0g81YxEHjigDzLHmDCg,10230
|
|
28
|
-
librus_python_api/models.py,sha256=
|
|
28
|
+
librus_python_api/models.py,sha256=mh6YkKaKs3IjvXlGnu31-7khRwm7AbrYXv9ulBp7c0U,41730
|
|
29
29
|
librus_python_api/modern_body.py,sha256=SocXJTyGm1Q_2owzh1PE1zcCB2kqZTz1KPJ2R4yMyoc,3895
|
|
30
30
|
librus_python_api/modern_mailbox.py,sha256=2jF9UnJZ5AF6dip-GrMcv_z2kSLpp87dIpVeXk9jMzA,20600
|
|
31
31
|
librus_python_api/modern_messages.py,sha256=qt2CXtrxCZQ-jTt0aQCD6ZaPAJQZXf0BxmvacPo9B0w,10653
|
|
@@ -45,7 +45,7 @@ librus_python_api/sending.py,sha256=Nvvk2r-6D6Us53a2fH5GW9PcA6bWEfuOvy6c3M5QVcE,
|
|
|
45
45
|
librus_python_api/service.py,sha256=7ioLx-8C-I0Qla8YUyu5UqhjBPlcUvEFpQSYH-9yjzs,96232
|
|
46
46
|
librus_python_api/timetable.py,sha256=tX1QK9fQABxzfLaHHqTe8e6rAgdOkCoTOfAHdPwNmLE,9836
|
|
47
47
|
librus_python_api/transport.py,sha256=VslHio0FrOiTkISfLNDlGVV_MeGAYidHDKZnoVUIwrI,34310
|
|
48
|
-
librus_python_api-1.
|
|
49
|
-
librus_python_api-1.
|
|
50
|
-
librus_python_api-1.
|
|
51
|
-
librus_python_api-1.
|
|
48
|
+
librus_python_api-1.7.1.dist-info/METADATA,sha256=lg5FT8iP0ZzkUbWPJdDL1_XjToQO4IGrDiIYg3pwDuc,12174
|
|
49
|
+
librus_python_api-1.7.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
50
|
+
librus_python_api-1.7.1.dist-info/licenses/LICENSE,sha256=IuerLb3cnZCsaB23znydjjvKqP-LmSJd3syKa9NGUts,1071
|
|
51
|
+
librus_python_api-1.7.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|