librus-python-api 1.8.0__py3-none-any.whl → 1.8.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 +3 -2
- librus_python_api/attachments.py +5 -4
- librus_python_api/config.py +21 -3
- librus_python_api/diagnostics.py +105 -3
- librus_python_api/exceptions.py +16 -1
- librus_python_api/gateway_grades.py +3 -2
- librus_python_api/inspect_page.py +18 -0
- librus_python_api/models.py +2 -0
- librus_python_api/parsers.py +35 -5
- librus_python_api/service.py +106 -26
- librus_python_api/transport.py +16 -10
- {librus_python_api-1.8.0.dist-info → librus_python_api-1.8.2.dist-info}/METADATA +14 -7
- {librus_python_api-1.8.0.dist-info → librus_python_api-1.8.2.dist-info}/RECORD +15 -15
- {librus_python_api-1.8.0.dist-info → librus_python_api-1.8.2.dist-info}/WHEEL +0 -0
- {librus_python_api-1.8.0.dist-info → librus_python_api-1.8.2.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.2"
|
|
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.0"
|
|
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/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",
|
|
@@ -688,6 +689,21 @@ PROFILE_LABELS = MappingProxyType(
|
|
|
688
689
|
"Szkoła": "school",
|
|
689
690
|
}
|
|
690
691
|
)
|
|
692
|
+
# Rows the information page lists for the login's own user after the
|
|
693
|
+
# student's rows (observed); never read, named by failure diagnostics.
|
|
694
|
+
PROFILE_OWNER_LABELS = ("Imię i nazwisko użytkownika", "Login", "Hasło")
|
|
695
|
+
# The page header shows the lucky number as `span.luckyNumber > b` (observed
|
|
696
|
+
# 2026-10-29). "?" there is reported where no number is drawn, for a school
|
|
697
|
+
# without lucky numbers and possibly on days without a draw; neither it nor
|
|
698
|
+
# an empty marker has been observed.
|
|
699
|
+
PROFILE_LUCKY_NUMBER_CLASS = "luckyNumber"
|
|
700
|
+
# For a session it no longer knows, Synergia answers every page read with 200
|
|
701
|
+
# and a page whose only heading is "Brak dostępu" (`h2.inside`), without the
|
|
702
|
+
# signed-in menu, not with a login redirect (observed 2026-10-29). A signed-in
|
|
703
|
+
# user's timetable page has no menu either, so the heading is required too.
|
|
704
|
+
LOGGED_IN_PAGE_MARKER = "graphic-menu"
|
|
705
|
+
LOGGED_OUT_HEADING = "Brak dostępu"
|
|
706
|
+
PROFILE_LUCKY_NUMBER_DISABLED = "?"
|
|
691
707
|
|
|
692
708
|
# HTML summary headers omit the two leading body cells: expander and subject.
|
|
693
709
|
# These labels/layout rules are source-informed requirements, not live evidence.
|
|
@@ -847,9 +863,11 @@ FORMATIVE_TEMPLATE_ID = "000000"
|
|
|
847
863
|
FORMATIVE_MAX_TEXT_LENGTH = 65536
|
|
848
864
|
ATTENDANCE_DETAIL_MAX_FIELDS = 32
|
|
849
865
|
ATTENDANCE_METADATA_CACHE_SIZE = 256
|
|
850
|
-
ATTENDANCE_RESULT_CACHE_SIZE = 64
|
|
851
866
|
ATTENDANCE_METADATA_TTL_SECONDS = 3600
|
|
852
867
|
|
|
868
|
+
# Entries of every cached read of one account, oldest first out.
|
|
869
|
+
ACCOUNT_RESULT_CACHE_SIZE = 64
|
|
870
|
+
|
|
853
871
|
ANNOUNCEMENT_MAX_ITEMS = 256
|
|
854
872
|
ANNOUNCEMENT_MAX_FIELD_LENGTH = 1024
|
|
855
873
|
ANNOUNCEMENT_MAX_CONTENT_LENGTH = 65536
|
librus_python_api/diagnostics.py
CHANGED
|
@@ -30,6 +30,16 @@ _ELEMENT_LOCALS = 4
|
|
|
30
30
|
_DEPTH = 6
|
|
31
31
|
_NODES = 120
|
|
32
32
|
_ELEMENT_BYTES = 8 * 1024
|
|
33
|
+
_PAGE_TABLES = 12
|
|
34
|
+
_PAGE_ROWS = 40
|
|
35
|
+
_PAGE_CELLS = 8
|
|
36
|
+
_PAGE_NOTICES = 4
|
|
37
|
+
# Input types a login, search or settings form uses; others are shown as "?".
|
|
38
|
+
_INPUT_TYPES = frozenset(
|
|
39
|
+
{"button", "checkbox", "hidden", "password", "radio", "submit", "text"}
|
|
40
|
+
)
|
|
41
|
+
# Element IDs whose presence tells a Synergia page from a login or error page.
|
|
42
|
+
_PAGE_MARKERS = ("graphic-menu", "user-section")
|
|
33
43
|
_REPORT_BYTES = 32 * 1024
|
|
34
44
|
# Markup observed on Synergia pages. Other class tokens, tags and attribute
|
|
35
45
|
# names are shown only as lengths, since they could carry data.
|
|
@@ -45,6 +55,7 @@ _CLASSES = frozenset(
|
|
|
45
55
|
"decorated",
|
|
46
56
|
"detail-grades",
|
|
47
57
|
"double",
|
|
58
|
+
"form",
|
|
48
59
|
"gradesCell",
|
|
49
60
|
"grade-box",
|
|
50
61
|
"grade-box-mapping",
|
|
@@ -53,6 +64,7 @@ _CLASSES = frozenset(
|
|
|
53
64
|
"legend",
|
|
54
65
|
"line0",
|
|
55
66
|
"line1",
|
|
67
|
+
"luckyNumber",
|
|
56
68
|
"micro",
|
|
57
69
|
"newSchemaGradesByStudentTable",
|
|
58
70
|
"no-border-left",
|
|
@@ -65,6 +77,7 @@ _CLASSES = frozenset(
|
|
|
65
77
|
"stretch",
|
|
66
78
|
"studentRow",
|
|
67
79
|
"tooltip",
|
|
80
|
+
"warning-content",
|
|
68
81
|
}
|
|
69
82
|
)
|
|
70
83
|
_TAGS = frozenset(
|
|
@@ -164,6 +177,7 @@ def logging_sink(event: DiagnosticEvent) -> None:
|
|
|
164
177
|
"budget_requests_dispatched": event.budget_requests_dispatched,
|
|
165
178
|
"budget_response_bytes": event.budget_response_bytes,
|
|
166
179
|
"frames": event.frames,
|
|
180
|
+
"http_status": event.http_status,
|
|
167
181
|
},
|
|
168
182
|
)
|
|
169
183
|
|
|
@@ -242,6 +256,9 @@ def _redaction_vocabulary() -> tuple[
|
|
|
242
256
|
*config.GRADE_AVERAGE_HEADERS,
|
|
243
257
|
}
|
|
244
258
|
texts |= {label.casefold() for label in config.FORMATIVE_HEADERS}
|
|
259
|
+
profile = (*config.PROFILE_LABELS, *config.PROFILE_OWNER_LABELS)
|
|
260
|
+
texts |= set(profile) | {f"{label}:" for label in profile}
|
|
261
|
+
texts |= set(config.PAGE_NOTICES) | {config.LOGGED_OUT_HEADING}
|
|
245
262
|
prefixes = tuple(
|
|
246
263
|
sorted(
|
|
247
264
|
(
|
|
@@ -398,13 +415,91 @@ def _path(element: html.HtmlElement, redactor: _Redactor) -> str:
|
|
|
398
415
|
return " > ".join(reversed(steps))
|
|
399
416
|
|
|
400
417
|
|
|
418
|
+
def _cell(cell: html.HtmlElement, redactor: _Redactor) -> str:
|
|
419
|
+
spans = "".join(
|
|
420
|
+
f" {redactor.attribute(name, str(cell.get(name)))}"
|
|
421
|
+
for name in ("colspan", "rowspan")
|
|
422
|
+
if cell.get(name, "1") != "1"
|
|
423
|
+
)
|
|
424
|
+
text = redactor.text(cell.text_content()) or "EMPTY"
|
|
425
|
+
return f"{_tag(str(cell.tag))}{spans}[{text}]"
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _shown(label: str, texts: list[str]) -> str:
|
|
429
|
+
return " ".join([f" {label}: {len(texts)}", *texts[:_PAGE_NOTICES]])
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _page_summary(document: html.HtmlElement, redactor: _Redactor) -> list[str]:
|
|
433
|
+
"""A whole page as its markers, forms, notices and tables, row by row.
|
|
434
|
+
|
|
435
|
+
A page that fails as a whole (no expected table, an unknown notice, a
|
|
436
|
+
login or error page) is told apart by these, not by its first elements.
|
|
437
|
+
Only the first tables, rows and cells are shown.
|
|
438
|
+
"""
|
|
439
|
+
body = document.find("body")
|
|
440
|
+
text = " ".join((body if body is not None else document).text_content().split())
|
|
441
|
+
present = {
|
|
442
|
+
marker: document.get_element_by_id(marker, None) is not None
|
|
443
|
+
for marker in _PAGE_MARKERS
|
|
444
|
+
}
|
|
445
|
+
markers = " ".join(
|
|
446
|
+
f"{marker}={'yes' if found else 'no'}" for marker, found in present.items()
|
|
447
|
+
)
|
|
448
|
+
lines = [f" body text len={len(text)} {markers}"]
|
|
449
|
+
title = document.find(".//title")
|
|
450
|
+
titles = [] if title is None else [redactor.text(title.text_content()) or "EMPTY"]
|
|
451
|
+
lines.append(_shown("title", titles))
|
|
452
|
+
headings = [
|
|
453
|
+
redactor.text(heading.text_content()) or "EMPTY"
|
|
454
|
+
for heading in document.iter("h1", "h2", "h3")
|
|
455
|
+
]
|
|
456
|
+
lines.append(_shown("headings", headings))
|
|
457
|
+
notices = document.xpath(
|
|
458
|
+
'//div[contains(concat(" ",normalize-space(@class)," ")," warning-content ")]'
|
|
459
|
+
)
|
|
460
|
+
shown = [redactor.text(notice.text_content()) or "EMPTY" for notice in notices]
|
|
461
|
+
lines.append(_shown("notices", shown))
|
|
462
|
+
forms = [redactor.href(form.get("action", "")) for form in document.iter("form")]
|
|
463
|
+
inputs: dict[str, int] = {}
|
|
464
|
+
for field in document.iter("input"):
|
|
465
|
+
kind = field.get("type", "text").casefold()
|
|
466
|
+
kind = kind if kind in _INPUT_TYPES else "?"
|
|
467
|
+
inputs[kind] = inputs.get(kind, 0) + 1
|
|
468
|
+
counted = " ".join(f"{kind}={count}" for kind, count in sorted(inputs.items()))
|
|
469
|
+
lines.append(_shown("forms", forms) + (f"; inputs {counted}" if counted else ""))
|
|
470
|
+
tables = list(document.iter("table"))
|
|
471
|
+
lines.append(f" tables: {len(tables)}")
|
|
472
|
+
for table in tables[:_PAGE_TABLES]:
|
|
473
|
+
rows = [
|
|
474
|
+
row
|
|
475
|
+
for row in table.iter("tr")
|
|
476
|
+
if next(row.iterancestors("table"), None) is table
|
|
477
|
+
]
|
|
478
|
+
lines.append(f" {_path(table, redactor)}: {len(rows)} rows")
|
|
479
|
+
for row in rows[:_PAGE_ROWS]:
|
|
480
|
+
cells = [cell for cell in row if cell.tag in ("td", "th")]
|
|
481
|
+
more = " ..." if len(cells) > _PAGE_CELLS else ""
|
|
482
|
+
rendered = " ".join(_cell(cell, redactor) for cell in cells[:_PAGE_CELLS])
|
|
483
|
+
lines.append(f" {rendered}{more}")
|
|
484
|
+
if len(rows) > _PAGE_ROWS:
|
|
485
|
+
lines.append(" ...")
|
|
486
|
+
if len(tables) > _PAGE_TABLES:
|
|
487
|
+
lines.append(" ...")
|
|
488
|
+
return lines
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _is_page(element: html.HtmlElement) -> bool:
|
|
492
|
+
return element.getparent() is None and element.tag == "html"
|
|
493
|
+
|
|
494
|
+
|
|
401
495
|
def describe_failure(error: BaseException) -> str | None:
|
|
402
496
|
"""The page structure around a failure, for a bug report.
|
|
403
497
|
|
|
404
498
|
Shows the HTML elements held by the innermost library frame that holds
|
|
405
499
|
any: tags, safe class tokens, known tooltip labels, known link families
|
|
406
|
-
and lengths.
|
|
407
|
-
|
|
500
|
+
and lengths. A whole page is summarized last (see _page_summary). Text
|
|
501
|
+
and attribute values are masked unless they equal a parser constant.
|
|
502
|
+
Returns None when there is nothing to show; never raises.
|
|
408
503
|
"""
|
|
409
504
|
try:
|
|
410
505
|
redactor = _Redactor()
|
|
@@ -425,8 +520,15 @@ def describe_failure(error: BaseException) -> str | None:
|
|
|
425
520
|
if not elements and not sequences:
|
|
426
521
|
continue
|
|
427
522
|
lines = [f"at {_site(frame, line)}"]
|
|
428
|
-
|
|
523
|
+
# The elements nearest the failure first; a page summary last.
|
|
524
|
+
for name, value in sorted(elements, key=lambda item: _is_page(item[1])):
|
|
429
525
|
redactor.nodes = 0
|
|
526
|
+
if _is_page(value):
|
|
527
|
+
summary = "\n".join(_page_summary(value, redactor))
|
|
528
|
+
if len(summary) > _ELEMENT_BYTES:
|
|
529
|
+
summary = summary[:_ELEMENT_BYTES] + "...(truncated)"
|
|
530
|
+
lines += [f"{name}: page", summary]
|
|
531
|
+
continue
|
|
430
532
|
lines.append(f"{name}: {_path(value, redactor)}")
|
|
431
533
|
rendered = redactor.element(value)
|
|
432
534
|
if len(rendered) > _ELEMENT_BYTES:
|
librus_python_api/exceptions.py
CHANGED
|
@@ -7,6 +7,7 @@ __all__ = [
|
|
|
7
7
|
"AccountActionRequiredError",
|
|
8
8
|
"ClosedError",
|
|
9
9
|
"CheckpointError",
|
|
10
|
+
"ConsentRequiredError",
|
|
10
11
|
"ConnectionError",
|
|
11
12
|
"CredentialsRejectedError",
|
|
12
13
|
"ErrorKind",
|
|
@@ -31,6 +32,7 @@ __all__ = [
|
|
|
31
32
|
class ErrorKind(StrEnum):
|
|
32
33
|
CHECKPOINT = "checkpoint"
|
|
33
34
|
INVALID_INPUT = "invalid_input"
|
|
35
|
+
CONSENT_REQUIRED = "consent_required"
|
|
34
36
|
CREDENTIALS_REJECTED = "credentials_rejected"
|
|
35
37
|
ACCOUNT_ACTION_REQUIRED = "account_action_required"
|
|
36
38
|
SESSION_EXPIRED = "session_expired"
|
|
@@ -78,6 +80,13 @@ class InvalidInputError(LibrusError):
|
|
|
78
80
|
pass
|
|
79
81
|
|
|
80
82
|
|
|
83
|
+
class ConsentRequiredError(InvalidInputError):
|
|
84
|
+
"""A call with an effect the user must agree to was made without consent.
|
|
85
|
+
|
|
86
|
+
A subclass of InvalidInputError, which these calls raised before 1.8.1.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
|
|
81
90
|
class CredentialsRejectedError(LibrusError):
|
|
82
91
|
pass
|
|
83
92
|
|
|
@@ -89,6 +98,9 @@ class AccountActionRequiredError(LibrusError):
|
|
|
89
98
|
class SessionExpiredError(LibrusError):
|
|
90
99
|
# Library-private provenance only; never attach a URL, body or credentials.
|
|
91
100
|
_messages_origin = False
|
|
101
|
+
# Inferred from a page without the logged-in menu, not from a 401 or a
|
|
102
|
+
# login redirect; a fresh session getting the same page is a layout.
|
|
103
|
+
_logged_out_page = False
|
|
92
104
|
|
|
93
105
|
|
|
94
106
|
class AccessDeniedError(LibrusError):
|
|
@@ -124,7 +136,9 @@ class OperationTimeoutError(LibrusError):
|
|
|
124
136
|
|
|
125
137
|
|
|
126
138
|
class LimitError(LibrusError):
|
|
127
|
-
|
|
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
|
|
128
142
|
|
|
129
143
|
|
|
130
144
|
class ParseError(LibrusError):
|
|
@@ -154,6 +168,7 @@ class StorageError(LibrusError):
|
|
|
154
168
|
_ERROR_TYPES: dict[ErrorKind, type[LibrusError]] = {
|
|
155
169
|
ErrorKind.CHECKPOINT: CheckpointError,
|
|
156
170
|
ErrorKind.INVALID_INPUT: InvalidInputError,
|
|
171
|
+
ErrorKind.CONSENT_REQUIRED: ConsentRequiredError,
|
|
157
172
|
ErrorKind.CREDENTIALS_REJECTED: CredentialsRejectedError,
|
|
158
173
|
ErrorKind.ACCOUNT_ACTION_REQUIRED: AccountActionRequiredError,
|
|
159
174
|
ErrorKind.SESSION_EXPIRED: SessionExpiredError,
|
|
@@ -3,8 +3,9 @@
|
|
|
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.
|
|
8
9
|
"""
|
|
9
10
|
|
|
10
11
|
import re
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Run one parser on a saved Synergia page and report the outcome offline.
|
|
2
2
|
|
|
3
3
|
python -m librus_python_api.inspect_page grades page.html
|
|
4
|
+
python -m librus_python_api.inspect_page profile informacja.html
|
|
4
5
|
|
|
5
6
|
The report holds counts, code locations and page structure built from
|
|
6
7
|
allowlists (see diagnostics.describe_failure), never page text or values.
|
|
@@ -20,6 +21,8 @@ from librus_python_api.diagnostics import describe_failure, failure_frames
|
|
|
20
21
|
from librus_python_api.exceptions import LibrusError
|
|
21
22
|
from librus_python_api.grade_parsers import parse_final_grades
|
|
22
23
|
from librus_python_api.grade_records import parse_grade_records
|
|
24
|
+
from librus_python_api.models import Availability
|
|
25
|
+
from librus_python_api.parsers import parse_profile
|
|
23
26
|
|
|
24
27
|
NOTICE = (
|
|
25
28
|
"This report holds counts, code locations and page structure only, no page "
|
|
@@ -45,10 +48,19 @@ def _final_grades(body: bytes) -> dict[str, int]:
|
|
|
45
48
|
return {"subjects": len(parse_final_grades(body))}
|
|
46
49
|
|
|
47
50
|
|
|
51
|
+
def _profile(body: bytes) -> dict[str, int]:
|
|
52
|
+
lucky = parse_profile(body).lucky_number.availability
|
|
53
|
+
return {"lucky_number": int(lucky == Availability.AVAILABLE)}
|
|
54
|
+
|
|
55
|
+
|
|
48
56
|
PARSERS: dict[str, Callable[[bytes], dict[str, int]]] = {
|
|
49
57
|
"grades": _grades,
|
|
50
58
|
"final-grades": _final_grades,
|
|
59
|
+
"profile": _profile,
|
|
51
60
|
}
|
|
61
|
+
# macOS TextEdit saves a viewed page source as a new document holding the
|
|
62
|
+
# source as text, not the page itself.
|
|
63
|
+
_TEXT_EXPORT = re.compile(rb'<meta name="Generator" content="Cocoa HTML Writer"', re.I)
|
|
52
64
|
|
|
53
65
|
|
|
54
66
|
def inspect(parser: str, body: bytes) -> tuple[bool, list[str]]:
|
|
@@ -60,6 +72,12 @@ def inspect(parser: str, body: bytes) -> tuple[bool, list[str]]:
|
|
|
60
72
|
# Synergia serves UTF-8; another encoding is a saving problem, not a layout.
|
|
61
73
|
lines.append("FAILED input: the file is not UTF-8; save the page as UTF-8")
|
|
62
74
|
return False, lines
|
|
75
|
+
if _TEXT_EXPORT.search(body[:4096]):
|
|
76
|
+
lines.append(
|
|
77
|
+
"FAILED input: the file is page source saved as a text document; "
|
|
78
|
+
"save the page itself (Safari: File > Save As, format Page Source)"
|
|
79
|
+
)
|
|
80
|
+
return False, lines
|
|
63
81
|
if not re.search(rb"<html[\s>]", body[:4096], re.I):
|
|
64
82
|
# A copied table (outerHTML) is a fragment; parsers expect a page.
|
|
65
83
|
body = b"<!doctype html><html><body>" + body + b"</body></html>"
|
librus_python_api/models.py
CHANGED
|
@@ -90,6 +90,8 @@ class DiagnosticEvent:
|
|
|
90
90
|
budget_response_bytes: int
|
|
91
91
|
# Code locations of a failure (see diagnostics.failure_frames), else empty.
|
|
92
92
|
frames: tuple[str, ...] = ()
|
|
93
|
+
# HTTP status of the operation's last checked read response, if any.
|
|
94
|
+
http_status: int | None = None
|
|
93
95
|
|
|
94
96
|
|
|
95
97
|
@dataclass(frozen=True, slots=True)
|
librus_python_api/parsers.py
CHANGED
|
@@ -8,7 +8,14 @@ from typing import Any
|
|
|
8
8
|
from lxml import etree, html
|
|
9
9
|
from pydantic import ValidationError
|
|
10
10
|
|
|
11
|
-
from librus_python_api.config import
|
|
11
|
+
from librus_python_api.config import (
|
|
12
|
+
LOGGED_IN_PAGE_MARKER,
|
|
13
|
+
LOGGED_OUT_HEADING,
|
|
14
|
+
PAGE_NOTICES,
|
|
15
|
+
PROFILE_LABELS,
|
|
16
|
+
PROFILE_LUCKY_NUMBER_CLASS,
|
|
17
|
+
PROFILE_LUCKY_NUMBER_DISABLED,
|
|
18
|
+
)
|
|
12
19
|
from librus_python_api.exceptions import ErrorKind, LibrusError
|
|
13
20
|
from librus_python_api.models import (
|
|
14
21
|
Availability,
|
|
@@ -178,6 +185,23 @@ def parse_page(body: bytes, *, repeatable_id: str | None = None) -> html.HtmlEle
|
|
|
178
185
|
return document
|
|
179
186
|
|
|
180
187
|
|
|
188
|
+
def is_logged_out_page(body: bytes) -> bool:
|
|
189
|
+
"""Synergia's page for a session it no longer knows; never raises."""
|
|
190
|
+
if LOGGED_OUT_HEADING.encode() not in body:
|
|
191
|
+
return False
|
|
192
|
+
try:
|
|
193
|
+
parser = html.HTMLParser(no_network=True, recover=True, huge_tree=False)
|
|
194
|
+
document = html.document_fromstring(body.decode("utf-8"), parser=parser)
|
|
195
|
+
except (UnicodeError, etree.LxmlError, ValueError):
|
|
196
|
+
return False
|
|
197
|
+
if document.get_element_by_id(LOGGED_IN_PAGE_MARKER, None) is not None:
|
|
198
|
+
return False
|
|
199
|
+
headings = [
|
|
200
|
+
" ".join(h.text_content().split()) for h in document.iter("h1", "h2", "h3")
|
|
201
|
+
]
|
|
202
|
+
return headings == [LOGGED_OUT_HEADING]
|
|
203
|
+
|
|
204
|
+
|
|
181
205
|
def parse_profile(body: bytes) -> ProfileFields:
|
|
182
206
|
document = parse_page(body)
|
|
183
207
|
required = {"name", "class_name", "register_number", "tutor", "school"}
|
|
@@ -210,15 +234,21 @@ def parse_profile(body: bytes) -> ProfileFields:
|
|
|
210
234
|
raise LibrusError(ErrorKind.PARSE)
|
|
211
235
|
if not re.fullmatch(r"[0-9]{1,4}", candidates[0]["register_number"]):
|
|
212
236
|
raise LibrusError(ErrorKind.PARSE)
|
|
213
|
-
markers = document.xpath(
|
|
237
|
+
markers = document.xpath(
|
|
238
|
+
'//span[contains(concat(" ",normalize-space(@class)," "),$token)]',
|
|
239
|
+
token=f" {PROFILE_LUCKY_NUMBER_CLASS} ",
|
|
240
|
+
)
|
|
214
241
|
if not markers:
|
|
215
242
|
lucky = LuckyNumber(Availability.UNAVAILABLE)
|
|
216
243
|
elif len(markers) == 1:
|
|
217
244
|
value = " ".join(markers[0].text_content().split())
|
|
218
|
-
if
|
|
245
|
+
if value in ("", PROFILE_LUCKY_NUMBER_DISABLED):
|
|
246
|
+
lucky = LuckyNumber(Availability.UNAVAILABLE)
|
|
247
|
+
elif re.fullmatch(r"[0-9]{1,4}", value):
|
|
248
|
+
# No evidenced civil date in this marker. Never fabricate today's date.
|
|
249
|
+
lucky = LuckyNumber(Availability.AVAILABLE, int(value))
|
|
250
|
+
else:
|
|
219
251
|
raise LibrusError(ErrorKind.PARSE)
|
|
220
|
-
# No evidenced civil date in this marker. Never fabricate today's date.
|
|
221
|
-
lucky = LuckyNumber(Availability.AVAILABLE, int(value))
|
|
222
252
|
else:
|
|
223
253
|
raise LibrusError(ErrorKind.PARSE)
|
|
224
254
|
fields = candidates[0]
|
librus_python_api/service.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Public login-scoped account service under one shared traffic boundary."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import contextvars
|
|
4
5
|
import hashlib
|
|
5
6
|
import hmac
|
|
6
7
|
import json
|
|
@@ -33,11 +34,11 @@ from librus_python_api.completed_lessons import (
|
|
|
33
34
|
validate_selection,
|
|
34
35
|
)
|
|
35
36
|
from librus_python_api.config import (
|
|
37
|
+
ACCOUNT_RESULT_CACHE_SIZE,
|
|
36
38
|
ATTACHMENT_MAX_BYTES,
|
|
37
39
|
ATTENDANCE_MAX_WINDOW_DAYS,
|
|
38
40
|
ATTENDANCE_METADATA_CACHE_SIZE,
|
|
39
41
|
ATTENDANCE_METADATA_TTL_SECONDS,
|
|
40
|
-
ATTENDANCE_RESULT_CACHE_SIZE,
|
|
41
42
|
CHECKPOINT_TIMEOUT_SECONDS,
|
|
42
43
|
ENDPOINTS,
|
|
43
44
|
GRADE_MAX_RECORDS,
|
|
@@ -52,6 +53,7 @@ from librus_python_api.config import (
|
|
|
52
53
|
ConnectionSettings,
|
|
53
54
|
OperationLimits,
|
|
54
55
|
SchedulerLimits,
|
|
56
|
+
SideEffect,
|
|
55
57
|
TransportLimits,
|
|
56
58
|
agenda_form,
|
|
57
59
|
attendance_view_form,
|
|
@@ -69,7 +71,13 @@ from librus_python_api.config import (
|
|
|
69
71
|
)
|
|
70
72
|
from librus_python_api.detail_fields import normalize_detail_fields
|
|
71
73
|
from librus_python_api.diagnostics import DiagnosticSink, failure_frames
|
|
72
|
-
from librus_python_api.exceptions import
|
|
74
|
+
from librus_python_api.exceptions import (
|
|
75
|
+
ErrorKind,
|
|
76
|
+
LibrusError,
|
|
77
|
+
LimitError,
|
|
78
|
+
ParseError,
|
|
79
|
+
SessionExpiredError,
|
|
80
|
+
)
|
|
73
81
|
from librus_python_api.gateway_grades import (
|
|
74
82
|
DescriptiveTable,
|
|
75
83
|
descriptive_grades,
|
|
@@ -198,7 +206,12 @@ from librus_python_api.notifications import (
|
|
|
198
206
|
parse_schedule_events,
|
|
199
207
|
validate_response,
|
|
200
208
|
)
|
|
201
|
-
from librus_python_api.parsers import
|
|
209
|
+
from librus_python_api.parsers import (
|
|
210
|
+
is_logged_out_page,
|
|
211
|
+
parse_identity,
|
|
212
|
+
parse_login,
|
|
213
|
+
parse_profile,
|
|
214
|
+
)
|
|
202
215
|
from librus_python_api.parsing import ParserPool
|
|
203
216
|
from librus_python_api.recipients import (
|
|
204
217
|
parse_recipient_group_choices,
|
|
@@ -381,6 +394,24 @@ def _require_dates(*days: date | None, max_days: int | None = None) -> None:
|
|
|
381
394
|
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
382
395
|
|
|
383
396
|
|
|
397
|
+
def _cooldown_due(error: LibrusError) -> bool:
|
|
398
|
+
"""A denial, an unrecovered expiry, or a logged-out page that a session
|
|
399
|
+
just signed in also got: signing in again at once cannot help."""
|
|
400
|
+
if error.kind in (ErrorKind.ACCESS_DENIED, ErrorKind.SESSION_EXPIRED):
|
|
401
|
+
return True
|
|
402
|
+
cause = error.__cause__
|
|
403
|
+
return (
|
|
404
|
+
error.kind is ErrorKind.PARSE
|
|
405
|
+
and isinstance(cause, SessionExpiredError)
|
|
406
|
+
and cause._logged_out_page
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# The running operation's last checked read status, for its diagnostic event.
|
|
411
|
+
# A holder per operation, so concurrent operations do not share one.
|
|
412
|
+
_READ_STATUS: contextvars.ContextVar[list[int | None] | None] = contextvars.ContextVar(
|
|
413
|
+
"librus_read_status", default=None
|
|
414
|
+
)
|
|
384
415
|
_METADATA_MAP_FALLBACK = frozenset(
|
|
385
416
|
{ErrorKind.ACCESS_DENIED, ErrorKind.CONNECTION, ErrorKind.LIMIT, ErrorKind.PARSE}
|
|
386
417
|
)
|
|
@@ -894,10 +925,10 @@ class AccountClient:
|
|
|
894
925
|
max_age_seconds: float = 0.0,
|
|
895
926
|
) -> ModernMessageContent:
|
|
896
927
|
validate_modern_message_reference(reference, self._alias)
|
|
897
|
-
if type(allow_mark_read) is not bool
|
|
898
|
-
reference.folder is MessageFolder.RECEIVED and not allow_mark_read
|
|
899
|
-
):
|
|
928
|
+
if type(allow_mark_read) is not bool:
|
|
900
929
|
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
930
|
+
if reference.folder is MessageFolder.RECEIVED and not allow_mark_read:
|
|
931
|
+
raise LibrusError(ErrorKind.CONSENT_REQUIRED)
|
|
901
932
|
if reference.archived:
|
|
902
933
|
# No archived content route or mark-read effect is established yet.
|
|
903
934
|
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
@@ -1178,6 +1209,8 @@ class AccountClient:
|
|
|
1178
1209
|
started = time.monotonic()
|
|
1179
1210
|
outcome: ErrorKind | Literal["ok", "cancelled"] = "ok"
|
|
1180
1211
|
frames: tuple[str, ...] = ()
|
|
1212
|
+
status: list[int | None] = [None]
|
|
1213
|
+
status_token = _READ_STATUS.set(status)
|
|
1181
1214
|
try:
|
|
1182
1215
|
if deadline is None:
|
|
1183
1216
|
deadline = asyncio.timeout(None)
|
|
@@ -1195,10 +1228,7 @@ class AccountClient:
|
|
|
1195
1228
|
outcome = error.kind
|
|
1196
1229
|
if self._service._diagnostic_sink is not None:
|
|
1197
1230
|
frames = failure_frames(error)
|
|
1198
|
-
if authenticate and error
|
|
1199
|
-
ErrorKind.ACCESS_DENIED,
|
|
1200
|
-
ErrorKind.SESSION_EXPIRED,
|
|
1201
|
-
):
|
|
1231
|
+
if authenticate and _cooldown_due(error):
|
|
1202
1232
|
self._cooldowns[operation] = (
|
|
1203
1233
|
time.monotonic() + self._service._transport_limits.cooldown_seconds,
|
|
1204
1234
|
error.kind,
|
|
@@ -1215,6 +1245,7 @@ class AccountClient:
|
|
|
1215
1245
|
# produce a misleading success diagnostic at this owner boundary.
|
|
1216
1246
|
outcome = ErrorKind.CONNECTION
|
|
1217
1247
|
finally:
|
|
1248
|
+
_READ_STATUS.reset(status_token)
|
|
1218
1249
|
self._emit(
|
|
1219
1250
|
DiagnosticEvent(
|
|
1220
1251
|
operation,
|
|
@@ -1223,6 +1254,7 @@ class AccountClient:
|
|
|
1223
1254
|
budget.requests_dispatched,
|
|
1224
1255
|
budget.response_bytes,
|
|
1225
1256
|
frames,
|
|
1257
|
+
status[0],
|
|
1226
1258
|
)
|
|
1227
1259
|
)
|
|
1228
1260
|
|
|
@@ -1277,7 +1309,7 @@ class AccountClient:
|
|
|
1277
1309
|
budget: RequestBudget | None = None,
|
|
1278
1310
|
max_age_seconds: float = 0.0,
|
|
1279
1311
|
) -> Grades:
|
|
1280
|
-
"""Select a grade view with one
|
|
1312
|
+
"""Select a grade view with one view-changing POST.
|
|
1281
1313
|
|
|
1282
1314
|
Changes the selected grade filter, not school records. A cache hit makes
|
|
1283
1315
|
no POST. Missing inline weight/count data remains unknown. In the all
|
|
@@ -1370,7 +1402,7 @@ class AccountClient:
|
|
|
1370
1402
|
budget: RequestBudget | None = None,
|
|
1371
1403
|
max_age_seconds: float = 0.0,
|
|
1372
1404
|
) -> Attendance:
|
|
1373
|
-
"""Read a fixed upstream attendance view
|
|
1405
|
+
"""Read a fixed upstream attendance view with one view-changing POST."""
|
|
1374
1406
|
form = attendance_view_form(view)
|
|
1375
1407
|
|
|
1376
1408
|
async def fetch(budget: RequestBudget, _: bool) -> Attendance:
|
|
@@ -1509,7 +1541,7 @@ class AccountClient:
|
|
|
1509
1541
|
budget: RequestBudget | None = None,
|
|
1510
1542
|
max_age_seconds: float = 0.0,
|
|
1511
1543
|
) -> Timetable:
|
|
1512
|
-
"""Select an explicit school civil week
|
|
1544
|
+
"""Select an explicit school civil week with one view-changing POST."""
|
|
1513
1545
|
form = timetable_form(monday)
|
|
1514
1546
|
|
|
1515
1547
|
async def fetch(budget: RequestBudget, _: bool) -> Timetable:
|
|
@@ -1761,10 +1793,10 @@ class AccountClient:
|
|
|
1761
1793
|
) -> MessageContent:
|
|
1762
1794
|
"""Open one message; received opens require explicit read-effect consent."""
|
|
1763
1795
|
validate_reference(reference, self._alias)
|
|
1764
|
-
if type(allow_mark_read) is not bool
|
|
1765
|
-
reference.folder is MessageFolder.RECEIVED and not allow_mark_read
|
|
1766
|
-
):
|
|
1796
|
+
if type(allow_mark_read) is not bool:
|
|
1767
1797
|
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
1798
|
+
if reference.folder is MessageFolder.RECEIVED and not allow_mark_read:
|
|
1799
|
+
raise LibrusError(ErrorKind.CONSENT_REQUIRED)
|
|
1768
1800
|
operation: Literal["message_content_received", "message_content_sent"] = (
|
|
1769
1801
|
"message_content_received"
|
|
1770
1802
|
if reference.folder is MessageFolder.RECEIVED
|
|
@@ -2044,6 +2076,8 @@ class AccountClient:
|
|
|
2044
2076
|
started = time.monotonic()
|
|
2045
2077
|
outcome: ErrorKind | Literal["ok", "cancelled"] = "ok"
|
|
2046
2078
|
frames: tuple[str, ...] = ()
|
|
2079
|
+
status: list[int | None] = [None]
|
|
2080
|
+
status_token = _READ_STATUS.set(status)
|
|
2047
2081
|
try:
|
|
2048
2082
|
async with asyncio.timeout(budget.remaining_seconds()):
|
|
2049
2083
|
async with self._lock:
|
|
@@ -2062,6 +2096,7 @@ class AccountClient:
|
|
|
2062
2096
|
outcome = ErrorKind.TIMEOUT
|
|
2063
2097
|
raise LibrusError(ErrorKind.TIMEOUT) from None
|
|
2064
2098
|
finally:
|
|
2099
|
+
_READ_STATUS.reset(status_token)
|
|
2065
2100
|
self._emit(
|
|
2066
2101
|
DiagnosticEvent(
|
|
2067
2102
|
operation,
|
|
@@ -2070,6 +2105,7 @@ class AccountClient:
|
|
|
2070
2105
|
budget.requests_dispatched,
|
|
2071
2106
|
budget.response_bytes,
|
|
2072
2107
|
frames,
|
|
2108
|
+
status[0],
|
|
2073
2109
|
)
|
|
2074
2110
|
)
|
|
2075
2111
|
|
|
@@ -2101,25 +2137,29 @@ class AccountClient:
|
|
|
2101
2137
|
try:
|
|
2102
2138
|
result = await self._authenticated(fetch, budget, retry_safe)
|
|
2103
2139
|
except LibrusError as error:
|
|
2104
|
-
if error
|
|
2140
|
+
if _cooldown_due(error):
|
|
2105
2141
|
self._cooldowns[operation] = (
|
|
2106
2142
|
time.monotonic() + self._service._transport_limits.cooldown_seconds,
|
|
2107
2143
|
error.kind,
|
|
2108
2144
|
)
|
|
2109
2145
|
raise
|
|
2110
2146
|
self._cache[key] = (time.monotonic(), result)
|
|
2111
|
-
if len(self._cache) >
|
|
2147
|
+
if len(self._cache) > ACCOUNT_RESULT_CACHE_SIZE:
|
|
2112
2148
|
del self._cache[next(iter(self._cache))]
|
|
2113
2149
|
return result
|
|
2114
2150
|
|
|
2115
2151
|
async def _authenticated[T](
|
|
2116
2152
|
self, fetch: Fetch[T], budget: RequestBudget, retry_safe: bool
|
|
2117
2153
|
) -> T:
|
|
2118
|
-
"""Run a read in a session; recover
|
|
2154
|
+
"""Run a read in a session; recover expiry once where a repeat is safe.
|
|
2119
2155
|
|
|
2120
2156
|
Initial login is never retried: a failed credential submission must not
|
|
2121
|
-
be replayed. A
|
|
2122
|
-
|
|
2157
|
+
be replayed. A retry-safe read is repeated once after a new login, and
|
|
2158
|
+
so is a read without an account effect (`NONE` or `SELECT_VIEW`, never
|
|
2159
|
+
mark-read) answered with a logged-out page, which proves the request
|
|
2160
|
+
was not applied. Other expiry only clears the session so a later
|
|
2161
|
+
explicit call can log in again. A session just signed in that gets the
|
|
2162
|
+
logged-out page fails as PARSE.
|
|
2123
2163
|
"""
|
|
2124
2164
|
fresh_login = self._identity is None
|
|
2125
2165
|
if fresh_login:
|
|
@@ -2127,18 +2167,22 @@ class AccountClient:
|
|
|
2127
2167
|
try:
|
|
2128
2168
|
return await fetch(budget, fresh_login)
|
|
2129
2169
|
except SessionExpiredError as error:
|
|
2170
|
+
if error._logged_out_page and fresh_login:
|
|
2171
|
+
raise LibrusError(ErrorKind.PARSE) from error
|
|
2130
2172
|
modern_expired = error._messages_origin
|
|
2131
2173
|
if modern_expired:
|
|
2132
2174
|
self._invalidate_modern()
|
|
2133
2175
|
else:
|
|
2134
2176
|
self._invalidate()
|
|
2135
|
-
if not retry_safe:
|
|
2177
|
+
if not retry_safe and not error._logged_out_page:
|
|
2136
2178
|
raise
|
|
2137
2179
|
if not modern_expired:
|
|
2138
2180
|
await self._authenticate(budget)
|
|
2139
2181
|
try:
|
|
2140
2182
|
return await fetch(budget, not modern_expired)
|
|
2141
2183
|
except SessionExpiredError as error:
|
|
2184
|
+
if error._logged_out_page:
|
|
2185
|
+
raise LibrusError(ErrorKind.PARSE) from error
|
|
2142
2186
|
if error._messages_origin:
|
|
2143
2187
|
self._invalidate_modern()
|
|
2144
2188
|
else:
|
|
@@ -2167,12 +2211,29 @@ class AccountClient:
|
|
|
2167
2211
|
endpoint, budget, form=form, reference_id=reference, query=query
|
|
2168
2212
|
)
|
|
2169
2213
|
self._validate_read_response(response, content_type)
|
|
2170
|
-
|
|
2214
|
+
try:
|
|
2215
|
+
return await self._service._parsers.run(parse, response.body, budget)
|
|
2216
|
+
except ParseError as error:
|
|
2217
|
+
if (
|
|
2218
|
+
content_type != HTML
|
|
2219
|
+
or ENDPOINTS[endpoint].origin != "synergia"
|
|
2220
|
+
or not await self._service._parsers.run(
|
|
2221
|
+
is_logged_out_page, response.body, budget
|
|
2222
|
+
)
|
|
2223
|
+
):
|
|
2224
|
+
raise
|
|
2225
|
+
# Synergia's answer to a session it no longer knows. A read
|
|
2226
|
+
# with an account effect (mark-read) is not repeated: it fails
|
|
2227
|
+
# as an unrecovered expiry and the next call signs in.
|
|
2228
|
+
expired = SessionExpiredError(ErrorKind.SESSION_EXPIRED)
|
|
2229
|
+
expired._logged_out_page = ENDPOINTS[endpoint].side_effect in (
|
|
2230
|
+
SideEffect.NONE,
|
|
2231
|
+
SideEffect.SELECT_VIEW,
|
|
2232
|
+
)
|
|
2233
|
+
raise expired from error
|
|
2171
2234
|
except BaseException as error:
|
|
2172
2235
|
if ENDPOINTS[endpoint].origin == "messages":
|
|
2173
|
-
self.
|
|
2174
|
-
if isinstance(error, SessionExpiredError):
|
|
2175
|
-
error._messages_origin = True
|
|
2236
|
+
self._modern_read_failed(error)
|
|
2176
2237
|
raise
|
|
2177
2238
|
|
|
2178
2239
|
def _check_cooldown(self, key: str) -> None:
|
|
@@ -2188,6 +2249,22 @@ class AccountClient:
|
|
|
2188
2249
|
self._metadata_maps.clear()
|
|
2189
2250
|
self._transport.clear_auth()
|
|
2190
2251
|
|
|
2252
|
+
def _modern_read_failed(self, error: BaseException) -> None:
|
|
2253
|
+
"""Drop the modern session unless the failure says nothing about it.
|
|
2254
|
+
|
|
2255
|
+
A cancel or a local bound keeps it. Every other failure drops it, an
|
|
2256
|
+
unrecognized body included: a session the upstream no longer knows may
|
|
2257
|
+
be answered with one, and keeping it would fail every later read, while
|
|
2258
|
+
dropping it costs one launch.
|
|
2259
|
+
"""
|
|
2260
|
+
if isinstance(error, asyncio.CancelledError) or (
|
|
2261
|
+
isinstance(error, LimitError) and not error._cookies_cleared
|
|
2262
|
+
):
|
|
2263
|
+
return
|
|
2264
|
+
self._invalidate_modern()
|
|
2265
|
+
if isinstance(error, SessionExpiredError):
|
|
2266
|
+
error._messages_origin = True
|
|
2267
|
+
|
|
2191
2268
|
def _invalidate_modern(self) -> None:
|
|
2192
2269
|
self._modern_account = None
|
|
2193
2270
|
for key in tuple(self._cache):
|
|
@@ -2548,6 +2625,9 @@ class AccountClient:
|
|
|
2548
2625
|
def _validate_read_response(
|
|
2549
2626
|
self, response: TransportResponse, content_type: str
|
|
2550
2627
|
) -> None:
|
|
2628
|
+
holder = _READ_STATUS.get()
|
|
2629
|
+
if holder is not None:
|
|
2630
|
+
holder[0] = response.status
|
|
2551
2631
|
if 300 <= response.status < 400:
|
|
2552
2632
|
if self._is_module_unavailable_redirect(response):
|
|
2553
2633
|
raise LibrusError(ErrorKind.MODULE_UNAVAILABLE)
|
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.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,9 +42,13 @@ 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.
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
**Status:** `1.8.2` keeps the modern mailbox session across a cancelled read
|
|
46
|
+
or a reached limit and sends its own version in the `User-Agent`. `1.8.1` recovers a session Synergia no longer knows (it answers
|
|
47
|
+
with a "Brak dostępu" page, not a login redirect), reads the lucky number
|
|
48
|
+
from the page header and summarizes a page that fails as a whole in
|
|
49
|
+
diagnostics. `1.8.0` reports where a failure
|
|
50
|
+
happened (code locations and a redacted page structure, see
|
|
51
|
+
[failure diagnostics](API.md#failure-diagnostics-180)), reads gateway descriptive grades for schools on the new descriptive schema
|
|
48
52
|
(source-informed, first populated read live on one school) and keeps the 1.7.1 point
|
|
49
53
|
grades and multi-line grade comments. It keeps the 1.6.4 linear-time message body
|
|
50
54
|
rendering and the 1.6.2 completed-lesson and grade layouts, including behaviour
|
|
@@ -60,7 +64,7 @@ See [limitations](#supported-features-and-limitations) below.
|
|
|
60
64
|
Install the exact release from PyPI after its gated publication completes:
|
|
61
65
|
|
|
62
66
|
```sh
|
|
63
|
-
python -m pip install librus-python-api==1.8.
|
|
67
|
+
python -m pip install librus-python-api==1.8.2
|
|
64
68
|
```
|
|
65
69
|
|
|
66
70
|
Pin the exact version qualified by your application. Before publication, use a
|
|
@@ -76,7 +80,7 @@ python -m pip install .
|
|
|
76
80
|
To install a locally built wheel instead:
|
|
77
81
|
|
|
78
82
|
```sh
|
|
79
|
-
python -m pip install ./dist/librus_python_api-1.8.
|
|
83
|
+
python -m pip install ./dist/librus_python_api-1.8.2-py3-none-any.whl
|
|
80
84
|
```
|
|
81
85
|
|
|
82
86
|
No console script or background process is installed: import the library in your
|
|
@@ -280,7 +284,10 @@ except LibrusError as error:
|
|
|
280
284
|
|
|
281
285
|
To check a saved page offline (a page source saved with Ctrl+U, or a copied
|
|
282
286
|
table), run `python -m librus_python_api.inspect_page grades page.html` (or
|
|
283
|
-
`final-grades
|
|
287
|
+
`final-grades`, or `profile` for the information page `/informacja`). Save the
|
|
288
|
+
page source itself: in Safari use File > Save As with format Page Source; in
|
|
289
|
+
Chrome on macOS open it with Option+Cmd+U, then Cmd+S. A source view copied
|
|
290
|
+
into TextEdit is a text document, which `inspect_page` rejects. It prints counts, or the failure's code locations and masked
|
|
284
291
|
structure, and makes no request. Pass a `diagnostic_sink` to `LibrusService`
|
|
285
292
|
to receive one `DiagnosticEvent` per operation, with `frames` on failure.
|
|
286
293
|
None of these print page text, values, logins or exception messages.
|
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
librus_python_api/__init__.py,sha256=
|
|
1
|
+
librus_python_api/__init__.py,sha256=CDi6pTko967g3RViVcZCIWhE-Lzof5VN6E4NkQJ4aUU,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=
|
|
8
|
+
librus_python_api/attachments.py,sha256=Iq-ERZWc3gYXu6Lcv6womT2EXCc-c1Q7r_Q6VyaCm2E,10818
|
|
9
9
|
librus_python_api/attendance.py,sha256=HeFAfnIUn4N__yp1zr635Dve55S2jmwtkeIy_6mV5B4,6924
|
|
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=CBMwuuACfrB7TzTVkTP8QAuPz5hv43YOsbto81-uEJc,61248
|
|
16
16
|
librus_python_api/detail_fields.py,sha256=vDDi5sV-e30-42SF0hwAUoldiZ5wvYtb1UlLt03lBHw,1708
|
|
17
|
-
librus_python_api/diagnostics.py,sha256=
|
|
18
|
-
librus_python_api/exceptions.py,sha256=
|
|
17
|
+
librus_python_api/diagnostics.py,sha256=5YbJB8J8RXMqMTmulcn5mJRF1vpi0Pvx00ETrXn9hUY,19036
|
|
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=T-G5KN0kpROi6xSGwAUba2qWDGNhg8TeGmQN140iLxc,16163
|
|
22
22
|
librus_python_api/grade_parsers.py,sha256=ROfffkCP8yj5Q708TZdpp0PDTyVmoh6X7m0y2fyJcwg,11202
|
|
23
23
|
librus_python_api/grade_records.py,sha256=YltZ_OQLsfxj0nvK8_xTuv8ifMOu2jJBnVn6TTQKzaY,22722
|
|
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=AUm-n2Ewe4cQUnlFnhttcGK5B9shvFde_7t1JF7mEm0,5285
|
|
26
26
|
librus_python_api/lifecycle.py,sha256=XzA5CEdZFo7iL5IMUrawnRvf2m9BFe0OseP-fpQFaAE,856
|
|
27
27
|
librus_python_api/markup.py,sha256=p97ZRPqhGYTEBbzJq_0j4j8AKihgQzJqTv__QUNm8Ws,6084
|
|
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
|
-
librus_python_api/models.py,sha256=
|
|
30
|
+
librus_python_api/models.py,sha256=IO-SYpqpQeuTej6kqNjBRXDbAbKITRZ1njRUyoEZ8t0,41951
|
|
31
31
|
librus_python_api/modern_body.py,sha256=SocXJTyGm1Q_2owzh1PE1zcCB2kqZTz1KPJ2R4yMyoc,3895
|
|
32
32
|
librus_python_api/modern_mailbox.py,sha256=2jF9UnJZ5AF6dip-GrMcv_z2kSLpp87dIpVeXk9jMzA,20600
|
|
33
33
|
librus_python_api/modern_messages.py,sha256=qt2CXtrxCZQ-jTt0aQCD6ZaPAJQZXf0BxmvacPo9B0w,10653
|
|
@@ -35,7 +35,7 @@ librus_python_api/notification_models.py,sha256=4eLTAE9JzHhveoaWtZQwGBolmb87QUP5
|
|
|
35
35
|
librus_python_api/notification_persistence.py,sha256=8GYPbCVD0AecrC4IUXFkHkhNS-uGXkXzsuKxreTIk9I,49454
|
|
36
36
|
librus_python_api/notification_workflow.py,sha256=MsBJdwn6J1UUGlz3Ldzi7WEBz5Qsama7eGmZvUQj25g,12924
|
|
37
37
|
librus_python_api/notifications.py,sha256=VbA8pqNG_19yYo9y7IqpGRmfEb4qDcvA5gBb1syGJKs,7806
|
|
38
|
-
librus_python_api/parsers.py,sha256=
|
|
38
|
+
librus_python_api/parsers.py,sha256=8sdfPsOZYHNYVZ4rwX_de5Y5rPl0umoQPon3uflQFlY,9178
|
|
39
39
|
librus_python_api/parsing.py,sha256=H71srw5yx-RFrAKZFgj-QFX-DIXZmhnGrDTmJhazCB8,1838
|
|
40
40
|
librus_python_api/persistence.py,sha256=F35yBRu6D8aYgdwKTpzVdqVTyiUaLQludUmyyskb3Vo,15529
|
|
41
41
|
librus_python_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -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=34o-EQSuERhSHZAme-RDhVznhd_y1-GT0SPfduJ5LoM,102964
|
|
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.2.dist-info/METADATA,sha256=78cqj_uSkFU-4NhnUV0FCj3U8-QmJRK51bvIzIRVask,14202
|
|
51
|
+
librus_python_api-1.8.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
52
|
+
librus_python_api-1.8.2.dist-info/licenses/LICENSE,sha256=IuerLb3cnZCsaB23znydjjvKqP-LmSJd3syKa9NGUts,1071
|
|
53
|
+
librus_python_api-1.8.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|