librus-python-api 1.6.1__py3-none-any.whl → 1.6.2__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/completed_lessons.py +85 -13
- librus_python_api/config.py +6 -1
- librus_python_api/detail_fields.py +0 -1
- librus_python_api/formative_grades.py +0 -1
- librus_python_api/grade_records.py +53 -3
- librus_python_api/notification_persistence.py +0 -1
- librus_python_api/persistence.py +1 -1
- librus_python_api/transport.py +91 -102
- {librus_python_api-1.6.1.dist-info → librus_python_api-1.6.2.dist-info}/METADATA +5 -4
- {librus_python_api-1.6.1.dist-info → librus_python_api-1.6.2.dist-info}/RECORD +13 -13
- {librus_python_api-1.6.1.dist-info → librus_python_api-1.6.2.dist-info}/WHEEL +0 -0
- {librus_python_api-1.6.1.dist-info → librus_python_api-1.6.2.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.6.
|
|
141
|
+
__version__ = "1.6.2"
|
|
142
142
|
|
|
143
143
|
__all__ = [
|
|
144
144
|
"ArchiveAbsences",
|
|
@@ -9,10 +9,12 @@ from lxml import html
|
|
|
9
9
|
|
|
10
10
|
from librus_python_api import markup
|
|
11
11
|
from librus_python_api.config import (
|
|
12
|
+
COMPLETED_LESSONS_COLUMNS,
|
|
12
13
|
COMPLETED_LESSONS_MAX_BATCH_ITEMS,
|
|
13
14
|
COMPLETED_LESSONS_MAX_BATCH_PAGES,
|
|
14
15
|
COMPLETED_LESSONS_MAX_PAGE_COUNT,
|
|
15
16
|
COMPLETED_LESSONS_MAX_PAGE_ITEMS,
|
|
17
|
+
COMPLETED_LESSONS_OPTIONAL_HEADERS,
|
|
16
18
|
SCHOOL_MAX_CONTENT_LENGTH,
|
|
17
19
|
SCHOOL_MAX_FIELD_LENGTH,
|
|
18
20
|
SCHOOL_MAX_TOTAL_TEXT_LENGTH,
|
|
@@ -65,7 +67,8 @@ def validate_selection(
|
|
|
65
67
|
completed_lessons_form(cursor.start, cursor.end, cursor.page)
|
|
66
68
|
|
|
67
69
|
|
|
68
|
-
def _pagination(document: html.HtmlElement, page: int) -> int:
|
|
70
|
+
def _pagination(document: html.HtmlElement, page: int) -> tuple[int, int | None]:
|
|
71
|
+
"""The page count and, when shown, the number of rows the page reports."""
|
|
69
72
|
containers = [
|
|
70
73
|
node
|
|
71
74
|
for node in document.iter("div")
|
|
@@ -74,14 +77,19 @@ def _pagination(document: html.HtmlElement, page: int) -> int:
|
|
|
74
77
|
if not containers:
|
|
75
78
|
if page != 0:
|
|
76
79
|
raise LibrusError(ErrorKind.PARSE)
|
|
77
|
-
return 1
|
|
80
|
+
return 1, None
|
|
78
81
|
if len(containers) != 1:
|
|
79
82
|
raise LibrusError(ErrorKind.PARSE)
|
|
80
83
|
spans = [node for node in containers[0] if node.tag == "span"]
|
|
81
84
|
if len(spans) != 1:
|
|
82
85
|
raise LibrusError(ErrorKind.PARSE)
|
|
83
86
|
value = markup.text(spans[0], SCHOOL_MAX_FIELD_LENGTH)
|
|
84
|
-
match = re.fullmatch(
|
|
87
|
+
match = re.fullmatch(
|
|
88
|
+
r"(?:Strona\s+)?([0-9]{1,4})\s+z\s+([0-9]{1,4})"
|
|
89
|
+
r"(?:\s+Pozycje\s+([0-9]{1,6})\.\.([0-9]{1,6})\s+z\s+([0-9]{1,6}))?",
|
|
90
|
+
value,
|
|
91
|
+
re.I,
|
|
92
|
+
)
|
|
85
93
|
if match is None:
|
|
86
94
|
raise LibrusError(ErrorKind.PARSE)
|
|
87
95
|
current, count = int(match[1]), int(match[2])
|
|
@@ -89,7 +97,53 @@ def _pagination(document: html.HtmlElement, page: int) -> int:
|
|
|
89
97
|
raise LibrusError(ErrorKind.LIMIT)
|
|
90
98
|
if not 1 <= current <= count or current != page + 1:
|
|
91
99
|
raise LibrusError(ErrorKind.PARSE)
|
|
92
|
-
|
|
100
|
+
if match[3] is None:
|
|
101
|
+
return count, None
|
|
102
|
+
# Some schools append the item range: "Pozycje first..last z total".
|
|
103
|
+
first, last, total = int(match[3]), int(match[4]), int(match[5])
|
|
104
|
+
if not 1 <= first <= last <= total:
|
|
105
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
106
|
+
return count, last - first + 1
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _optional_columns(table: html.HtmlElement) -> frozenset[int]:
|
|
110
|
+
"""Row positions of optional columns, located by their header labels."""
|
|
111
|
+
positions: set[int] = set()
|
|
112
|
+
for row in markup.rows(table):
|
|
113
|
+
if not markup.in_header(row):
|
|
114
|
+
continue
|
|
115
|
+
position = 0
|
|
116
|
+
labels = [
|
|
117
|
+
(cell, markup.text(cell, SCHOOL_MAX_FIELD_LENGTH))
|
|
118
|
+
for cell in markup.cells(row)
|
|
119
|
+
]
|
|
120
|
+
if not any(label in COMPLETED_LESSONS_OPTIONAL_HEADERS for _, label in labels):
|
|
121
|
+
continue
|
|
122
|
+
if positions:
|
|
123
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
124
|
+
for cell, label in labels:
|
|
125
|
+
span = markup.colspan(cell)
|
|
126
|
+
if label in COMPLETED_LESSONS_OPTIONAL_HEADERS:
|
|
127
|
+
if span != 1:
|
|
128
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
129
|
+
positions.add(position)
|
|
130
|
+
position += span
|
|
131
|
+
# A labelled header must describe exactly the row layout it extends.
|
|
132
|
+
if position != COMPLETED_LESSONS_COLUMNS + len(positions):
|
|
133
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
134
|
+
return frozenset(positions)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _closing_row(row: html.HtmlElement, width: int) -> bool:
|
|
138
|
+
"""A blank full-width row that closes some schools' tables, not a lesson."""
|
|
139
|
+
cells = markup.cells(row)
|
|
140
|
+
return (
|
|
141
|
+
len(cells) == 1
|
|
142
|
+
and cells[0].tag == "td"
|
|
143
|
+
and markup.colspan(cells[0]) == width
|
|
144
|
+
and not any(isinstance(node.tag, str) for node in cells[0])
|
|
145
|
+
and not markup.text(cells[0], SCHOOL_MAX_FIELD_LENGTH)
|
|
146
|
+
)
|
|
93
147
|
|
|
94
148
|
|
|
95
149
|
def _day(value: str) -> date:
|
|
@@ -109,15 +163,24 @@ def _day(value: str) -> date:
|
|
|
109
163
|
return result
|
|
110
164
|
|
|
111
165
|
|
|
112
|
-
def _lesson(
|
|
166
|
+
def _lesson(
|
|
167
|
+
row: html.HtmlElement, start: date, end: date, optional: frozenset[int]
|
|
168
|
+
) -> CompletedLesson:
|
|
113
169
|
cells = markup.cells(row)
|
|
114
|
-
if len(cells) !=
|
|
170
|
+
if len(cells) != COMPLETED_LESSONS_COLUMNS + len(optional) or any(
|
|
115
171
|
cell.tag != "td"
|
|
116
172
|
or cell.get("rowspan", "1") != "1"
|
|
117
173
|
or cell.get("colspan", "1") != "1"
|
|
118
174
|
for cell in cells
|
|
119
175
|
):
|
|
120
176
|
raise LibrusError(ErrorKind.PARSE)
|
|
177
|
+
for index in optional:
|
|
178
|
+
# Content in an optional column is not modelled; never drop it silently.
|
|
179
|
+
if any(isinstance(node.tag, str) for node in cells[index]) or markup.text(
|
|
180
|
+
cells[index], SCHOOL_MAX_FIELD_LENGTH
|
|
181
|
+
):
|
|
182
|
+
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
183
|
+
cells = [cell for index, cell in enumerate(cells) if index not in optional]
|
|
121
184
|
date_cells = [
|
|
122
185
|
c for c in cells if {"center", "small"} <= set(c.get("class", "").split())
|
|
123
186
|
]
|
|
@@ -140,7 +203,8 @@ def _lesson(row: html.HtmlElement, start: date, end: date) -> CompletedLesson:
|
|
|
140
203
|
raw_number, combined, topic, z_value, symbol = values
|
|
141
204
|
if raw_number not in {"", "-"} and re.fullmatch(r"[0-9]{1,2}", raw_number) is None:
|
|
142
205
|
raise LibrusError(ErrorKind.PARSE)
|
|
143
|
-
|
|
206
|
+
# Some schools render "<b>Subject</b>, <br>Teacher" or pad the comma.
|
|
207
|
+
names = [name.strip() for name in combined.split(",")]
|
|
144
208
|
subject, teacher = (names[0], names[1]) if len(names) == 2 else (combined, None)
|
|
145
209
|
if not subject or teacher == "":
|
|
146
210
|
raise LibrusError(ErrorKind.PARSE)
|
|
@@ -174,9 +238,13 @@ def parse_completed_lessons(
|
|
|
174
238
|
) -> tuple[tuple[CompletedLesson, ...], int, str]:
|
|
175
239
|
completed_lessons_form(start, end, page)
|
|
176
240
|
document = parse_page(body)
|
|
177
|
-
count = _pagination(document, page)
|
|
241
|
+
count, reported = _pagination(document, page)
|
|
242
|
+
# A date-filter form table shares the "decorated" class with the data table.
|
|
178
243
|
tables = [
|
|
179
|
-
t
|
|
244
|
+
t
|
|
245
|
+
for t in document.iter("table")
|
|
246
|
+
if "decorated" in (classes := t.get("class", "").split())
|
|
247
|
+
and "filters" not in classes
|
|
180
248
|
]
|
|
181
249
|
empty = [
|
|
182
250
|
n
|
|
@@ -190,10 +258,12 @@ def parse_completed_lessons(
|
|
|
190
258
|
if tables:
|
|
191
259
|
if list(tables[0].iterdescendants("table")):
|
|
192
260
|
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
261
|
+
optional = _optional_columns(tables[0])
|
|
262
|
+
lines = [row for row in markup.rows(tables[0]) if not markup.in_header(row)]
|
|
263
|
+
if lines and _closing_row(lines[-1], COMPLETED_LESSONS_COLUMNS + len(optional)):
|
|
264
|
+
lines.pop()
|
|
265
|
+
for row in lines:
|
|
266
|
+
item = _lesson(row, start, end, optional)
|
|
197
267
|
total += sum(len(v) for v in astuple(item) if isinstance(v, str))
|
|
198
268
|
items.append(item)
|
|
199
269
|
if (
|
|
@@ -201,6 +271,8 @@ def parse_completed_lessons(
|
|
|
201
271
|
or total > SCHOOL_MAX_TOTAL_TEXT_LENGTH
|
|
202
272
|
):
|
|
203
273
|
raise LibrusError(ErrorKind.LIMIT)
|
|
274
|
+
if reported is not None and reported != len(items):
|
|
275
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
204
276
|
if (items and empty) or (not items and (not empty or count != 1 or page != 0)):
|
|
205
277
|
raise LibrusError(ErrorKind.PARSE)
|
|
206
278
|
# An unrecognized notice next to an empty marker is not a valid empty page.
|
librus_python_api/config.py
CHANGED
|
@@ -731,6 +731,8 @@ ATTENDANCE_MAX_SUBJECTS = 1024
|
|
|
731
731
|
ATTENDANCE_DETAIL_PATH_PREFIX = "/przegladaj_nb/szczegoly/"
|
|
732
732
|
# Recognition only: formative detail links are classified, never requested.
|
|
733
733
|
FORMATIVE_DETAIL_PATH_PREFIX = "/przegladaj_oceny/szczegoly/ksztaltujace/"
|
|
734
|
+
# Recognition only: behaviour-point detail links are classified, never requested.
|
|
735
|
+
BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX = "/przegladaj_oceny/zachowanie_szczegoly/"
|
|
734
736
|
FORMATIVE_HEADERS = (
|
|
735
737
|
"przedmiot",
|
|
736
738
|
"ocena kształtująca",
|
|
@@ -882,6 +884,10 @@ COMPLETED_LESSONS_MAX_PAGE_COUNT = 1000
|
|
|
882
884
|
COMPLETED_LESSONS_MAX_PAGE_ITEMS = 256
|
|
883
885
|
COMPLETED_LESSONS_MAX_BATCH_PAGES = 8
|
|
884
886
|
COMPLETED_LESSONS_MAX_BATCH_ITEMS = 256
|
|
887
|
+
# Header labels of columns some schools add to the completed-lesson table. Their
|
|
888
|
+
# content is not modelled: such a column must be empty in every lesson row.
|
|
889
|
+
COMPLETED_LESSONS_OPTIONAL_HEADERS = frozenset({"e-Tablica"})
|
|
890
|
+
COMPLETED_LESSONS_COLUMNS = 7
|
|
885
891
|
AGENDA_DETAIL_PATH_PREFIX = "/terminarz/szczegoly/"
|
|
886
892
|
# Recognition only: closure details are not ordinary agenda-detail references.
|
|
887
893
|
AGENDA_CLOSURE_DETAIL_PATH_PREFIX = "/terminarz/szczegoly_wolne/"
|
|
@@ -931,7 +937,6 @@ MODERN_DIRECTORY_QUERIES = MappingProxyType(
|
|
|
931
937
|
"modern_recipients": (("receiverType", "parentsCouncil"),),
|
|
932
938
|
}
|
|
933
939
|
)
|
|
934
|
-
MODERN_SUPPORTED_RECIPIENT_TYPE = "parentsCouncil"
|
|
935
940
|
MODERN_RECIPIENT_OPERATIONS = MappingProxyType(
|
|
936
941
|
{
|
|
937
942
|
**{
|
|
@@ -34,7 +34,6 @@ def normalize_detail_fields(
|
|
|
34
34
|
fields: tuple[tuple[str, str], ...],
|
|
35
35
|
kind: Literal["agenda", "homework", "attendance"],
|
|
36
36
|
) -> tuple[DetailField, ...]:
|
|
37
|
-
"""Run after bounded HTML parsing, before publishing or caching a result."""
|
|
38
37
|
labels: set[str] = set()
|
|
39
38
|
keys: set[DetailFieldKey] = set()
|
|
40
39
|
result = []
|
|
@@ -31,7 +31,6 @@ def _fail(kind: ErrorKind) -> NoReturn:
|
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def is_formative_table(table: html.HtmlElement) -> bool:
|
|
34
|
-
# Cheap structural gates first: other tables' header text is never read.
|
|
35
34
|
if not {"stretch", "decorated"} <= set(table.get("class", "").split()):
|
|
36
35
|
return False
|
|
37
36
|
heads = [row for row in markup.rows(table) if markup.in_header(row)]
|
|
@@ -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_POINTS_DETAIL_PATH_PREFIX,
|
|
11
12
|
FORMATIVE_DETAIL_PATH_PREFIX,
|
|
12
13
|
GRADE_AVERAGE_HEADERS,
|
|
13
14
|
GRADE_BODY_PREFIX_COLUMNS,
|
|
@@ -16,6 +17,7 @@ from librus_python_api.config import (
|
|
|
16
17
|
GRADE_MAX_METADATA_LENGTH,
|
|
17
18
|
GRADE_MAX_RECORDS,
|
|
18
19
|
GRADE_MAX_SUBJECTS,
|
|
20
|
+
GRADE_MERGED_SUBJECTS,
|
|
19
21
|
GRADE_PERIOD_HEADERS,
|
|
20
22
|
GRADE_PREDICTED_ANNUAL_HEADER,
|
|
21
23
|
GRADE_PREDICTED_PERIOD_HEADERS,
|
|
@@ -174,6 +176,50 @@ def _boxes(cell: html.HtmlElement) -> list[html.HtmlElement]:
|
|
|
174
176
|
]
|
|
175
177
|
|
|
176
178
|
|
|
179
|
+
def _behaviour_points(cell: html.HtmlElement, collection: _Collection) -> bool:
|
|
180
|
+
"""Recognize and validate a behaviour-points cell; points are not grades.
|
|
181
|
+
|
|
182
|
+
The cell holds one points tooltip (starting points or a running total; its
|
|
183
|
+
title is not interpreted) and point entries linking to behaviour details,
|
|
184
|
+
each with its own unlabelled tooltip. Any other content next to the points
|
|
185
|
+
tooltip fails instead of being skipped.
|
|
186
|
+
"""
|
|
187
|
+
tallies = [
|
|
188
|
+
node
|
|
189
|
+
for node in cell
|
|
190
|
+
if node.tag == "span" and "tooltip" in node.get("class", "").split()
|
|
191
|
+
]
|
|
192
|
+
if not tallies:
|
|
193
|
+
return False
|
|
194
|
+
elements = [node for node in cell if isinstance(node.tag, str)]
|
|
195
|
+
boxes = [node for node in elements if node not in tallies]
|
|
196
|
+
if (
|
|
197
|
+
len(tallies) != 1
|
|
198
|
+
or len(tallies[0])
|
|
199
|
+
or not re.fullmatch(r"-?[0-9]{1,4}", markup.text(tallies[0]))
|
|
200
|
+
or (cell.text or "").strip()
|
|
201
|
+
or any((node.tail or "").strip() for node in cell)
|
|
202
|
+
or _boxes(cell) != boxes
|
|
203
|
+
):
|
|
204
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
205
|
+
for box in boxes:
|
|
206
|
+
anchors = [node for node in box if isinstance(node.tag, str)]
|
|
207
|
+
if (
|
|
208
|
+
box.getparent() is not cell
|
|
209
|
+
or len(anchors) != 1
|
|
210
|
+
or anchors[0].tag != "a"
|
|
211
|
+
or len(anchors[0])
|
|
212
|
+
or markup.detail_id(
|
|
213
|
+
anchors[0].get("href"), BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX
|
|
214
|
+
)
|
|
215
|
+
is None
|
|
216
|
+
or not re.fullmatch(r"[+-]?[0-9]{1,4}", markup.text(anchors[0]))
|
|
217
|
+
):
|
|
218
|
+
raise LibrusError(ErrorKind.PARSE)
|
|
219
|
+
collection.consumed.add(box)
|
|
220
|
+
return True
|
|
221
|
+
|
|
222
|
+
|
|
177
223
|
def _entries(
|
|
178
224
|
cell: html.HtmlElement,
|
|
179
225
|
subject: str,
|
|
@@ -320,9 +366,13 @@ def _read_numeric_table(table: html.HtmlElement, collection: _Collection) -> Non
|
|
|
320
366
|
seen: set[html.HtmlElement] = set()
|
|
321
367
|
for period, index in enumerate(current, start=1):
|
|
322
368
|
if columns[index] not in seen:
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
369
|
+
if not (
|
|
370
|
+
subject in GRADE_MERGED_SUBJECTS
|
|
371
|
+
and _behaviour_points(columns[index], collection)
|
|
372
|
+
):
|
|
373
|
+
_entries(
|
|
374
|
+
columns[index], subject, cast(Literal[1, 2], period), collection
|
|
375
|
+
)
|
|
326
376
|
seen.add(columns[index])
|
|
327
377
|
for (period, kind), index in periods.items():
|
|
328
378
|
cell = columns[index]
|
|
@@ -144,7 +144,6 @@ def _seen_after(
|
|
|
144
144
|
class NotificationStore(_SQLiteStore):
|
|
145
145
|
"""Private native recovery store. No MCP files, auto migration or live access.
|
|
146
146
|
|
|
147
|
-
Context locks require POSIX flock. Unsupported platforms fail explicitly.
|
|
148
147
|
Raw checkpoints contain private account/content metadata, never HTTP cookies.
|
|
149
148
|
"""
|
|
150
149
|
|
librus_python_api/persistence.py
CHANGED
librus_python_api/transport.py
CHANGED
|
@@ -210,51 +210,51 @@ class AiohttpTransport:
|
|
|
210
210
|
self._modern_session: aiohttp.ClientSession | None = None
|
|
211
211
|
self._closed = False
|
|
212
212
|
|
|
213
|
+
def _open_session(
|
|
214
|
+
self,
|
|
215
|
+
*,
|
|
216
|
+
cookie_jar: aiohttp.abc.AbstractCookieJar | None = None,
|
|
217
|
+
read_bufsize: int = 16 * 1024,
|
|
218
|
+
per_request_timeout: bool = False,
|
|
219
|
+
) -> aiohttp.ClientSession:
|
|
220
|
+
_require_no_connection_retry()
|
|
221
|
+
timeout = (
|
|
222
|
+
None
|
|
223
|
+
if per_request_timeout
|
|
224
|
+
else aiohttp.ClientTimeout(
|
|
225
|
+
total=self._limits.request_timeout_seconds,
|
|
226
|
+
connect=self._limits.connect_timeout_seconds,
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
session = aiohttp.ClientSession(
|
|
230
|
+
connector=aiohttp.TCPConnector(
|
|
231
|
+
limit=1,
|
|
232
|
+
ssl=self._connection.ssl_context or True,
|
|
233
|
+
),
|
|
234
|
+
cookie_jar=aiohttp.CookieJar() if cookie_jar is None else cookie_jar,
|
|
235
|
+
trust_env=False,
|
|
236
|
+
auto_decompress=False,
|
|
237
|
+
read_bufsize=read_bufsize,
|
|
238
|
+
timeout=timeout,
|
|
239
|
+
headers={"Accept-Encoding": "identity", "User-Agent": USER_AGENT},
|
|
240
|
+
)
|
|
241
|
+
# aiohttp otherwise silently replays GET on a stale keepalive
|
|
242
|
+
# connection. Some GETs mark read; every attempt must be budgeted.
|
|
243
|
+
session._retry_connection = False
|
|
244
|
+
return session
|
|
245
|
+
|
|
213
246
|
def _get_session(self) -> aiohttp.ClientSession:
|
|
214
247
|
if self._closed:
|
|
215
248
|
raise LibrusError(ErrorKind.CLOSED)
|
|
216
249
|
if self._session is None:
|
|
217
|
-
|
|
218
|
-
self._session = aiohttp.ClientSession(
|
|
219
|
-
connector=aiohttp.TCPConnector(
|
|
220
|
-
limit=1,
|
|
221
|
-
ssl=self._connection.ssl_context or True,
|
|
222
|
-
),
|
|
223
|
-
cookie_jar=aiohttp.CookieJar(),
|
|
224
|
-
trust_env=False,
|
|
225
|
-
auto_decompress=False,
|
|
226
|
-
read_bufsize=16 * 1024,
|
|
227
|
-
timeout=aiohttp.ClientTimeout(
|
|
228
|
-
total=self._limits.request_timeout_seconds,
|
|
229
|
-
connect=self._limits.connect_timeout_seconds,
|
|
230
|
-
),
|
|
231
|
-
headers={"Accept-Encoding": "identity", "User-Agent": USER_AGENT},
|
|
232
|
-
)
|
|
233
|
-
# aiohttp otherwise silently replays GET on a stale keepalive
|
|
234
|
-
# connection. Some GETs mark read; every attempt must be budgeted.
|
|
235
|
-
self._session._retry_connection = False
|
|
250
|
+
self._session = self._open_session()
|
|
236
251
|
return self._session
|
|
237
252
|
|
|
238
253
|
def _get_modern_session(self) -> aiohttp.ClientSession:
|
|
239
254
|
if self._closed:
|
|
240
255
|
raise LibrusError(ErrorKind.CLOSED)
|
|
241
256
|
if self._modern_session is None:
|
|
242
|
-
|
|
243
|
-
self._modern_session = aiohttp.ClientSession(
|
|
244
|
-
connector=aiohttp.TCPConnector(
|
|
245
|
-
limit=1, ssl=self._connection.ssl_context or True
|
|
246
|
-
),
|
|
247
|
-
cookie_jar=aiohttp.CookieJar(),
|
|
248
|
-
trust_env=False,
|
|
249
|
-
auto_decompress=False,
|
|
250
|
-
read_bufsize=16 * 1024,
|
|
251
|
-
timeout=aiohttp.ClientTimeout(
|
|
252
|
-
total=self._limits.request_timeout_seconds,
|
|
253
|
-
connect=self._limits.connect_timeout_seconds,
|
|
254
|
-
),
|
|
255
|
-
headers={"Accept-Encoding": "identity", "User-Agent": USER_AGENT},
|
|
256
|
-
)
|
|
257
|
-
self._modern_session._retry_connection = False
|
|
257
|
+
self._modern_session = self._open_session()
|
|
258
258
|
return self._modern_session
|
|
259
259
|
|
|
260
260
|
def _url(self, endpoint: Endpoint) -> str:
|
|
@@ -392,25 +392,12 @@ class AiohttpTransport:
|
|
|
392
392
|
budget: RequestBudget,
|
|
393
393
|
dispatched: Callable[[], None],
|
|
394
394
|
) -> TransportResponse:
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
self._account,
|
|
402
|
-
budget,
|
|
403
|
-
partial(self._send_exchange, payload, budget, dispatched, modern=True),
|
|
404
|
-
preserve_completed=True,
|
|
405
|
-
)
|
|
406
|
-
except aiohttp.ClientError:
|
|
407
|
-
kind = ErrorKind.CONNECTION
|
|
408
|
-
except TimeoutError:
|
|
409
|
-
kind = ErrorKind.TIMEOUT
|
|
410
|
-
except (ValueError, OverflowError, zlib.error):
|
|
411
|
-
kind = ErrorKind.PARSE
|
|
412
|
-
assert kind is not None
|
|
413
|
-
raise LibrusError(kind)
|
|
395
|
+
return await self._send_exchange(
|
|
396
|
+
encode_modern_send(submission, self._account),
|
|
397
|
+
budget,
|
|
398
|
+
dispatched,
|
|
399
|
+
modern=True,
|
|
400
|
+
)
|
|
414
401
|
|
|
415
402
|
async def send_message(
|
|
416
403
|
self,
|
|
@@ -418,15 +405,61 @@ class AiohttpTransport:
|
|
|
418
405
|
budget: RequestBudget,
|
|
419
406
|
dispatched: Callable[[], None],
|
|
420
407
|
) -> TransportResponse:
|
|
421
|
-
|
|
408
|
+
return await self._send_exchange(
|
|
409
|
+
encode_send_form(submission, self._account),
|
|
410
|
+
budget,
|
|
411
|
+
dispatched,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
async def _send_exchange(
|
|
415
|
+
self,
|
|
416
|
+
payload: bytes,
|
|
417
|
+
budget: RequestBudget,
|
|
418
|
+
dispatched: Callable[[], None],
|
|
419
|
+
*,
|
|
420
|
+
modern: bool = False,
|
|
421
|
+
) -> TransportResponse:
|
|
422
422
|
if self._closed:
|
|
423
423
|
raise LibrusError(ErrorKind.CLOSED)
|
|
424
|
+
|
|
425
|
+
async def exchange() -> TransportResponse:
|
|
426
|
+
endpoint = ENDPOINTS["modern_send_message" if modern else "send_message"]
|
|
427
|
+
session = self._get_modern_session() if modern else self._get_session()
|
|
428
|
+
proxy = self._connection.proxy_url
|
|
429
|
+
dispatched()
|
|
430
|
+
async with session.post(
|
|
431
|
+
self._url(endpoint),
|
|
432
|
+
data=payload,
|
|
433
|
+
headers={
|
|
434
|
+
"Content-Type": "application/json"
|
|
435
|
+
if modern
|
|
436
|
+
else "application/x-www-form-urlencoded; charset=utf-8"
|
|
437
|
+
},
|
|
438
|
+
allow_redirects=False,
|
|
439
|
+
proxy=proxy.get_secret_value() if proxy is not None else None,
|
|
440
|
+
) as response:
|
|
441
|
+
self._check_headers(response)
|
|
442
|
+
if len(session.cookie_jar) > self._limits.max_cookies:
|
|
443
|
+
session.cookie_jar.clear()
|
|
444
|
+
raise LibrusError(ErrorKind.LIMIT)
|
|
445
|
+
if not modern or response.status not in (400, 422):
|
|
446
|
+
self._check_status(response)
|
|
447
|
+
body = await self._read_body(response, budget)
|
|
448
|
+
return TransportResponse(
|
|
449
|
+
response.status,
|
|
450
|
+
body,
|
|
451
|
+
str(response.url),
|
|
452
|
+
MappingProxyType(
|
|
453
|
+
{k.lower(): v for k, v in response.headers.items()}
|
|
454
|
+
),
|
|
455
|
+
)
|
|
456
|
+
|
|
424
457
|
kind: ErrorKind | None = None
|
|
425
458
|
try:
|
|
426
459
|
return await self._scheduler.run(
|
|
427
460
|
self._account,
|
|
428
461
|
budget,
|
|
429
|
-
|
|
462
|
+
exchange,
|
|
430
463
|
preserve_completed=True,
|
|
431
464
|
)
|
|
432
465
|
except aiohttp.ClientError:
|
|
@@ -438,43 +471,6 @@ class AiohttpTransport:
|
|
|
438
471
|
assert kind is not None
|
|
439
472
|
raise LibrusError(kind)
|
|
440
473
|
|
|
441
|
-
async def _send_exchange(
|
|
442
|
-
self,
|
|
443
|
-
payload: bytes,
|
|
444
|
-
budget: RequestBudget,
|
|
445
|
-
dispatched: Callable[[], None],
|
|
446
|
-
*,
|
|
447
|
-
modern: bool = False,
|
|
448
|
-
) -> TransportResponse:
|
|
449
|
-
endpoint = ENDPOINTS["modern_send_message" if modern else "send_message"]
|
|
450
|
-
session = self._get_modern_session() if modern else self._get_session()
|
|
451
|
-
proxy = self._connection.proxy_url
|
|
452
|
-
dispatched()
|
|
453
|
-
async with session.post(
|
|
454
|
-
self._url(endpoint),
|
|
455
|
-
data=payload,
|
|
456
|
-
headers={
|
|
457
|
-
"Content-Type": "application/json"
|
|
458
|
-
if modern
|
|
459
|
-
else "application/x-www-form-urlencoded; charset=utf-8"
|
|
460
|
-
},
|
|
461
|
-
allow_redirects=False,
|
|
462
|
-
proxy=proxy.get_secret_value() if proxy is not None else None,
|
|
463
|
-
) as response:
|
|
464
|
-
self._check_headers(response)
|
|
465
|
-
if len(session.cookie_jar) > self._limits.max_cookies:
|
|
466
|
-
session.cookie_jar.clear()
|
|
467
|
-
raise LibrusError(ErrorKind.LIMIT)
|
|
468
|
-
if not modern or response.status not in (400, 422):
|
|
469
|
-
self._check_status(response)
|
|
470
|
-
body = await self._read_body(response, budget)
|
|
471
|
-
return TransportResponse(
|
|
472
|
-
response.status,
|
|
473
|
-
body,
|
|
474
|
-
str(response.url),
|
|
475
|
-
MappingProxyType({k.lower(): v for k, v in response.headers.items()}),
|
|
476
|
-
)
|
|
477
|
-
|
|
478
474
|
async def consume_schedule_events(
|
|
479
475
|
self,
|
|
480
476
|
budget: RequestBudget,
|
|
@@ -607,18 +603,11 @@ class AiohttpTransport:
|
|
|
607
603
|
if self._closed:
|
|
608
604
|
raise LibrusError(ErrorKind.CLOSED)
|
|
609
605
|
if self._download_session is None:
|
|
610
|
-
|
|
611
|
-
self._download_session = aiohttp.ClientSession(
|
|
612
|
-
connector=aiohttp.TCPConnector(
|
|
613
|
-
limit=1, ssl=self._connection.ssl_context or True
|
|
614
|
-
),
|
|
606
|
+
self._download_session = self._open_session(
|
|
615
607
|
cookie_jar=aiohttp.DummyCookieJar(),
|
|
616
|
-
trust_env=False,
|
|
617
|
-
auto_decompress=False,
|
|
618
608
|
read_bufsize=ATTACHMENT_CHUNK_BYTES,
|
|
619
|
-
|
|
609
|
+
per_request_timeout=True,
|
|
620
610
|
)
|
|
621
|
-
self._download_session._retry_connection = False
|
|
622
611
|
return self._download_session
|
|
623
612
|
|
|
624
613
|
async def stream_download(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: librus-python-api
|
|
3
|
-
Version: 1.6.
|
|
3
|
+
Version: 1.6.2
|
|
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,8 @@ 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.6.
|
|
45
|
+
**Status:** `1.6.2` accepts more completed-lesson and grade layouts, including
|
|
46
|
+
behaviour points, which are validated and not reported as grades.
|
|
46
47
|
Class free days, typed capability outcomes and the live-check freshness watchdog
|
|
47
48
|
remain available. See [verification](VERIFICATION.md).
|
|
48
49
|
The stable 1.x API does not guarantee every school layout. School features depend
|
|
@@ -54,7 +55,7 @@ See [limitations](#supported-features-and-limitations) below.
|
|
|
54
55
|
Install the exact release from PyPI after its gated publication completes:
|
|
55
56
|
|
|
56
57
|
```sh
|
|
57
|
-
python -m pip install librus-python-api==1.6.
|
|
58
|
+
python -m pip install librus-python-api==1.6.2
|
|
58
59
|
```
|
|
59
60
|
|
|
60
61
|
Pin the exact version qualified by your application. Before publication, use a
|
|
@@ -70,7 +71,7 @@ python -m pip install .
|
|
|
70
71
|
To install a locally built wheel instead:
|
|
71
72
|
|
|
72
73
|
```sh
|
|
73
|
-
python -m pip install ./dist/librus_python_api-1.6.
|
|
74
|
+
python -m pip install ./dist/librus_python_api-1.6.2-py3-none-any.whl
|
|
74
75
|
```
|
|
75
76
|
|
|
76
77
|
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=ZLWweFJqQFf-SdkQ7pHclXOKI0b-bfqNbNN1u14333w,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
|
|
@@ -11,15 +11,15 @@ librus_python_api/attendance_frequency.py,sha256=SBhED5zuyRIHWdw4urFyTJYykb6EYB7
|
|
|
11
11
|
librus_python_api/budget.py,sha256=8fvqT7eIxyVX2P2OBG27XsYHk_HbtSS4lzuecYpHtgs,2878
|
|
12
12
|
librus_python_api/checkpoint.py,sha256=QBmG9GtpyaGRaFq-FXuGA3yR1BjuMFN9V3RTvvi_FNQ,1930
|
|
13
13
|
librus_python_api/class_free_days.py,sha256=IwoCgr-9ZgEYYQ6717s4cDpyFzbosHMzESMabXJLGqA,2422
|
|
14
|
-
librus_python_api/completed_lessons.py,sha256=
|
|
15
|
-
librus_python_api/config.py,sha256=
|
|
16
|
-
librus_python_api/detail_fields.py,sha256=
|
|
14
|
+
librus_python_api/completed_lessons.py,sha256=ngGfBD6K4B6dR4elPYvNRvp40Q-GlrhUsUKqBbYY1KE,10727
|
|
15
|
+
librus_python_api/config.py,sha256=KEDe6KgS6XMjMViUexgUqlQnBtFVskVLd8NrVADD7BU,56109
|
|
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
|
|
20
|
+
librus_python_api/formative_grades.py,sha256=K_Qj13XMowYsIR10PjQqZ0MMta5eXIzLgQKCPL6yFJM,6150
|
|
21
21
|
librus_python_api/grade_parsers.py,sha256=91Kr1-TArzqO-b94ipeIR0a98OV7tRCZQdBWsd9QLZ0,6141
|
|
22
|
-
librus_python_api/grade_records.py,sha256=
|
|
22
|
+
librus_python_api/grade_records.py,sha256=tGi1037deTacvfDYmiKf-N7McyKcyos6NPuBk_H_M3U,19794
|
|
23
23
|
librus_python_api/homework_range.py,sha256=erYfvS6PhHU5XhnR6LkiLNy677hGVflY3UAuHOs-YUw,1663
|
|
24
24
|
librus_python_api/lifecycle.py,sha256=XzA5CEdZFo7iL5IMUrawnRvf2m9BFe0OseP-fpQFaAE,856
|
|
25
25
|
librus_python_api/markup.py,sha256=vd483NSUQbVYvSV2IaUmsyGcRhpWtbyDHMeEhbc7siA,5713
|
|
@@ -30,12 +30,12 @@ librus_python_api/modern_body.py,sha256=3L4P_cIrHdIxZqiinFSQrDMhG6iklT1GoA-VEWMJ
|
|
|
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
|
|
32
32
|
librus_python_api/notification_models.py,sha256=4eLTAE9JzHhveoaWtZQwGBolmb87QUP5kOSW7Jjt7J0,4199
|
|
33
|
-
librus_python_api/notification_persistence.py,sha256=
|
|
33
|
+
librus_python_api/notification_persistence.py,sha256=8GYPbCVD0AecrC4IUXFkHkhNS-uGXkXzsuKxreTIk9I,49454
|
|
34
34
|
librus_python_api/notification_workflow.py,sha256=MsBJdwn6J1UUGlz3Ldzi7WEBz5Qsama7eGmZvUQj25g,12924
|
|
35
35
|
librus_python_api/notifications.py,sha256=VbA8pqNG_19yYo9y7IqpGRmfEb4qDcvA5gBb1syGJKs,7806
|
|
36
36
|
librus_python_api/parsers.py,sha256=OFztbUMDzCywq17BgfFT485bVjyOqLksFHQJthZCx5Q,8120
|
|
37
37
|
librus_python_api/parsing.py,sha256=H71srw5yx-RFrAKZFgj-QFX-DIXZmhnGrDTmJhazCB8,1838
|
|
38
|
-
librus_python_api/persistence.py,sha256=
|
|
38
|
+
librus_python_api/persistence.py,sha256=F35yBRu6D8aYgdwKTpzVdqVTyiUaLQludUmyyskb3Vo,15529
|
|
39
39
|
librus_python_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
40
40
|
librus_python_api/recipients.py,sha256=6GQCKo-Zjg3VFSg1J8YXcDsy_U3r6-MUS4VCvz0xdaQ,10033
|
|
41
41
|
librus_python_api/scheduler.py,sha256=3JplYxqvQdFz3aKXcoQUJHE9s3FPw_xRhQ2CPNLuQmA,10729
|
|
@@ -44,8 +44,8 @@ librus_python_api/school_year_archive.py,sha256=D0A1ywMKgoYgvi-d70__SFreMGvMb_72
|
|
|
44
44
|
librus_python_api/sending.py,sha256=Nvvk2r-6D6Us53a2fH5GW9PcA6bWEfuOvy6c3M5QVcE,4549
|
|
45
45
|
librus_python_api/service.py,sha256=7ioLx-8C-I0Qla8YUyu5UqhjBPlcUvEFpQSYH-9yjzs,96232
|
|
46
46
|
librus_python_api/timetable.py,sha256=tX1QK9fQABxzfLaHHqTe8e6rAgdOkCoTOfAHdPwNmLE,9836
|
|
47
|
-
librus_python_api/transport.py,sha256=
|
|
48
|
-
librus_python_api-1.6.
|
|
49
|
-
librus_python_api-1.6.
|
|
50
|
-
librus_python_api-1.6.
|
|
51
|
-
librus_python_api-1.6.
|
|
47
|
+
librus_python_api/transport.py,sha256=VslHio0FrOiTkISfLNDlGVV_MeGAYidHDKZnoVUIwrI,34310
|
|
48
|
+
librus_python_api-1.6.2.dist-info/METADATA,sha256=l317hh6HLcjPf2_EHZN2pVOMuoJvBNqaU8RcBtdhaaU,12070
|
|
49
|
+
librus_python_api-1.6.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
50
|
+
librus_python_api-1.6.2.dist-info/licenses/LICENSE,sha256=IuerLb3cnZCsaB23znydjjvKqP-LmSJd3syKa9NGUts,1071
|
|
51
|
+
librus_python_api-1.6.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|