librus-python-api 1.8.0__tar.gz → 1.8.2__tar.gz
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-1.8.0 → librus_python_api-1.8.2}/API.md +49 -16
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/CHANGELOG.md +51 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/PKG-INFO +14 -7
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/README.md +13 -6
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/__init__.py +3 -2
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/attachments.py +5 -4
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/config.py +21 -3
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/diagnostics.py +105 -3
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/exceptions.py +16 -1
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/gateway_grades.py +3 -2
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/inspect_page.py +18 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/models.py +2 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/parsers.py +35 -5
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/service.py +106 -26
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/transport.py +16 -10
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/.gitignore +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/LICENSE +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/SECURITY.md +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/pyproject.toml +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/_notification_bootstrap.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/_notification_codec.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/_storage.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/_windows_filesystem.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/announcements.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/attachment_routes.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/attendance.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/attendance_frequency.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/budget.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/checkpoint.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/class_free_days.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/completed_lessons.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/detail_fields.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/files.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/formative_grades.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/grade_parsers.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/grade_records.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/homework_range.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/lifecycle.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/markup.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/message_content.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/messages.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/modern_body.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/modern_mailbox.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/modern_messages.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/notification_models.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/notification_persistence.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/notification_workflow.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/notifications.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/parsing.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/persistence.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/py.typed +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/recipients.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/scheduler.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/school_reads.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/school_year_archive.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/sending.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/timetable.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.2}/uv.lock +0 -0
|
@@ -148,7 +148,10 @@ refunded. Exhaustion raises `LimitError`; deadlines raise `OperationTimeoutError
|
|
|
148
148
|
allowlisted modern directory and mailbox selections only. Arbitrary query keys,
|
|
149
149
|
routes and modern-to-legacy reference reuse are rejected before dispatch.
|
|
150
150
|
- `diagnostic_sink`: receives `DiagnosticEvent(operation, outcome,
|
|
151
|
-
elapsed_seconds, budget_requests_dispatched, budget_response_bytes, frames
|
|
151
|
+
elapsed_seconds, budget_requests_dispatched, budget_response_bytes, frames,
|
|
152
|
+
http_status)`. `http_status` (1.8.1) is the HTTP status of the operation's
|
|
153
|
+
last checked read response, or None; statuses the transport turns into
|
|
154
|
+
errors (4xx, 5xx) are not checked reads, and attachment streams carry none.
|
|
152
155
|
`frames` (1.8.0) holds the code locations of a `LibrusError` outcome, as below;
|
|
153
156
|
it is empty on success and for timeouts, cancellation and replaced
|
|
154
157
|
connection errors. `librus_python_api.diagnostics.logging_sink` emits allowlisted fields
|
|
@@ -173,11 +176,17 @@ from the exception's own traceback. Neither raises.
|
|
|
173
176
|
known tooltip labels (`config.TOOLTIP_LABELS`), known detail-link families and
|
|
174
177
|
lengths. Text and attribute values are masked unless they equal a parser
|
|
175
178
|
constant, so a failing tooltip reads like `[[Data], [Waga], NO-COLON(len=17)]`.
|
|
176
|
-
|
|
179
|
+
A whole page held by the failing frame (1.8.1) is summarized after the other
|
|
180
|
+
elements: body text length, whether the Synergia menu and header are
|
|
181
|
+
present, title, headings, notices, forms and input types, then the first
|
|
182
|
+
tables row by row, with known profile labels and notices by name. Output is
|
|
183
|
+
bounded. It is meant for a human to review before sharing, not for
|
|
177
184
|
automatic upload.
|
|
178
|
-
- `python -m librus_python_api.inspect_page {grades,final-grades} FILE`
|
|
179
|
-
saved page offline (a whole page or a copied table) and prints
|
|
180
|
-
failure kind, frames and structure.
|
|
185
|
+
- `python -m librus_python_api.inspect_page {grades,final-grades,profile} FILE`
|
|
186
|
+
parses a saved page offline (a whole page or a copied table) and prints
|
|
187
|
+
counts, or the failure kind, frames and structure. `profile` (1.8.1) reads the
|
|
188
|
+
information page. A source view saved as a text document (macOS TextEdit) is
|
|
189
|
+
named as such instead of being parsed. It makes no request and applies the
|
|
181
190
|
library's parse size limit. Exit status: 0 parsed, 1 failed, 2 unreadable
|
|
182
191
|
file.
|
|
183
192
|
|
|
@@ -192,7 +201,8 @@ arbitrary-URL method.
|
|
|
192
201
|
reads it, so the first call costs no extra request.
|
|
193
202
|
- `student_information()` returns `StudentInformation` with name, class, register
|
|
194
203
|
number, tutor, school and `lucky_number`. `LuckyNumber.availability` is
|
|
195
|
-
`UNAVAILABLE` when the page has
|
|
204
|
+
`UNAVAILABLE` when the page has no lucky number, an empty marker or "?" in
|
|
205
|
+
it; no date is invented. Up to 1.8.0 it was always `UNAVAILABLE`.
|
|
196
206
|
|
|
197
207
|
Each result carries an `Observation(account, observed_at, session_generation,
|
|
198
208
|
source)` with an aware UTC timestamp.
|
|
@@ -205,7 +215,8 @@ source)` with an aware UTC timestamp.
|
|
|
205
215
|
`UNAVAILABLE` means the column is absent.
|
|
206
216
|
- `grades(view=GradeView.ALL)` returns `Grades(identity, records, observation,
|
|
207
217
|
view)`. Selecting a view (`ALL`, `WEEK`, `LAST_LOGIN`) is one POST that changes
|
|
208
|
-
the filter shown in that login's session; it is never replayed
|
|
218
|
+
the filter shown in that login's session; it is never replayed
|
|
219
|
+
(except once after Synergia's logged-out page, see [Errors](#errors)). `records` has:
|
|
209
220
|
- `numeric`: `NumericGrade` with subject, raw symbol, civil `day`, semester,
|
|
210
221
|
`kind` (`GradeKind`), and the optional count flag, weight, category, teacher,
|
|
211
222
|
comment and tooltip `metadata`. Unknown weight or count is `None`, not zero.
|
|
@@ -301,7 +312,7 @@ school's login. See [gateway descriptive grades](contracts/grades.md#gateway-des
|
|
|
301
312
|
- `attendance(view=AttendanceView.ALL)` returns `Attendance` with
|
|
302
313
|
`AttendanceRecord`s (symbol, day, semester, type, teacher, period, excursion
|
|
303
314
|
flag, topic, subject, numeric `detail_id`, tooltip metadata) and the semesters
|
|
304
|
-
shown. The view POST is never replayed.
|
|
315
|
+
shown. The view POST is never replayed (except once after Synergia's logged-out page, see [Errors](#errors)).
|
|
305
316
|
- `attendance_window(start=None, end=None, view=AttendanceView.ALL)` filters the
|
|
306
317
|
selected collection, with the same optional-date/370-day-difference rules.
|
|
307
318
|
`AttendanceWindow.view` records the selection.
|
|
@@ -334,7 +345,8 @@ seven `TimetableDay`s. Each `TimetablePeriod` has a number, a local-time
|
|
|
334
345
|
`changes` and the reported `next_recess`. A `TimetableChange` keeps the notice
|
|
335
346
|
label (for example "zastępstwo") and its tooltip fields in order. The tooltip
|
|
336
347
|
anchor may wrap the notice or sit inside it. No status is inferred from the
|
|
337
|
-
label. The week selection is a POST that is never replayed
|
|
348
|
+
label. The week selection is a POST that is never replayed
|
|
349
|
+
(except once after Synergia's logged-out page, see [Errors](#errors)).
|
|
338
350
|
|
|
339
351
|
## Announcements
|
|
340
352
|
|
|
@@ -442,7 +454,8 @@ Each `MessageSummary` carries:
|
|
|
442
454
|
Each text field is bounded to 4,096 characters, total page text to 256 KiB,
|
|
443
455
|
in addition to shared body/parser/request limits. No body or attachment fetch,
|
|
444
456
|
mark-read, delete, send or read-once operation occurs. Pagination changes the
|
|
445
|
-
selected mailbox view, so its POST is never replayed, including after expiry
|
|
457
|
+
selected mailbox view, so its POST is never replayed, including after expiry
|
|
458
|
+
(except once after Synergia's logged-out page, see [Errors](#errors)).
|
|
446
459
|
Page and batch caches are separate and fresh by default.
|
|
447
460
|
|
|
448
461
|
In 0.4.11 a full 50-row page without pagination metadata is unsupported, not
|
|
@@ -457,7 +470,9 @@ The initial handoff already includes that GET; subsequent sends cost one GET plu
|
|
|
457
470
|
one POST. Expiry before dispatch raises `SessionExpiredError` and leaves
|
|
458
471
|
NOT_DISPATCHED. An exact launch-to-native-login redirect is expiry, not denial.
|
|
459
472
|
Messages-origin failures clear only modern binding/cookies/cache; valid legacy
|
|
460
|
-
sessions stay usable. A
|
|
473
|
+
sessions stay usable. A cancelled mailbox read, or one stopped by a reached limit,
|
|
474
|
+
keeps them, so the next call needs no new launch; a limit that emptied the cookie
|
|
475
|
+
jar does not, and neither does a transport timeout. A later explicit modern call can rebind, but no modern
|
|
461
476
|
read/send is automatically retried. A race after preflight can still yield UNKNOWN.
|
|
462
477
|
|
|
463
478
|
- `modern_identity(*, budget=None, max_age_seconds=0)` returns `ModernIdentity`:
|
|
@@ -939,7 +954,8 @@ be a `MessageReference` for the same login with a numeric ID and typed folder.
|
|
|
939
954
|
Foreign accounts, arbitrary URLs and injected IDs fail before authentication.
|
|
940
955
|
|
|
941
956
|
- Received opens require `allow_mark_read=True`, even for an already-read
|
|
942
|
-
selection or warm cache
|
|
957
|
+
selection or warm cache; without it they raise `ConsentRequiredError`
|
|
958
|
+
(`InvalidInputError` before 1.8.1) before any request. Opening may mark read before any response or parse
|
|
943
959
|
failure. Consent does not assert that upstream actually changed a read flag.
|
|
944
960
|
- `may_mark_read` describes the route, not delivery or mutation confirmation.
|
|
945
961
|
Sent opens do not require the opt-in. Neither folder is automatically replayed,
|
|
@@ -1072,7 +1088,7 @@ Limits: 32 group selectors, 2,048 recipients, 1,024 characters per label and
|
|
|
1072
1088
|
128 KiB total recipient text, plus shared transport/parser/body limits. Each
|
|
1073
1089
|
lookup has its own account/type/selection cache key and accepts the common budget and
|
|
1074
1090
|
freshness parameters. Fresh reads are default. Both selection operations are
|
|
1075
|
-
never replayed, even though group discovery uses GET. No send route, message
|
|
1091
|
+
never replayed (except once after Synergia's logged-out page, see [Errors](#errors)), even though group discovery uses GET. No send route, message
|
|
1076
1092
|
content, mark-read, attachment or read-once access occurs in recipient discovery.
|
|
1077
1093
|
|
|
1078
1094
|
The empty group-option selector is observed. Populated choices, nonzero dispatch,
|
|
@@ -1088,7 +1104,8 @@ grades, attendance, messages, announcements, agenda and homework. A shown catego
|
|
|
1088
1104
|
without a counter has zero; missing categories are not fabricated. Invalid or
|
|
1089
1105
|
duplicate counters fail. The menu may be a token-scoped snapshot, not a fresh poll
|
|
1090
1106
|
or the input for seen-state updates. This ordinary read accepts the common
|
|
1091
|
-
budget/freshness arguments and conservatively does not replay on expiry
|
|
1107
|
+
budget/freshness arguments and conservatively does not replay on expiry
|
|
1108
|
+
(except once after Synergia's logged-out page, see [Errors](#errors)).
|
|
1092
1109
|
|
|
1093
1110
|
```python
|
|
1094
1111
|
batch = await client.consume_schedule_events(
|
|
@@ -1145,6 +1162,7 @@ text. `error_for(kind)` builds one.
|
|
|
1145
1162
|
| Error | Meaning |
|
|
1146
1163
|
| --- | --- |
|
|
1147
1164
|
| `InvalidInputError` | Rejected before any request, or upstream rejected the selection |
|
|
1165
|
+
| `ConsentRequiredError` | A subclass of `InvalidInputError` (1.8.1): a call with an effect the user must agree to, such as opening a received message, was made without that consent; nothing was requested |
|
|
1148
1166
|
| `CredentialsRejectedError` | Login and password refused |
|
|
1149
1167
|
| `AccountActionRequiredError` | CAPTCHA, 2FA or another interactive step is required |
|
|
1150
1168
|
| `SessionExpiredError` | Proven expiry that could not be recovered |
|
|
@@ -1162,9 +1180,24 @@ text. `error_for(kind)` builds one.
|
|
|
1162
1180
|
|
|
1163
1181
|
Recovery policy:
|
|
1164
1182
|
|
|
1165
|
-
-
|
|
1183
|
+
- A 401 or a redirect to an approved login route proves expiry. So does
|
|
1184
|
+
(1.8.1) a Synergia page that fails to parse, has no signed-in menu and
|
|
1185
|
+
whose only heading is "Brak dostępu", unless a session that has just signed
|
|
1186
|
+
in gets it. Synergia answers every page read of a session
|
|
1187
|
+
it no longer knows that way, with status 200 (observed 2026-10-29); up to
|
|
1188
|
+
1.8.0 this failed as `ParseError` on every call until the client was
|
|
1189
|
+
recreated.
|
|
1166
1190
|
- Safe GET reads then log in once more and retry once, within the same budget.
|
|
1167
|
-
- View-selection POSTs are never replayed.
|
|
1191
|
+
- View-selection POSTs are never replayed after a 401 or login redirect. A
|
|
1192
|
+
read without an account effect answered with the logged-out page, POST
|
|
1193
|
+
included, is repeated once in a fresh session: that page shows the request
|
|
1194
|
+
was not applied to the account. The whole read is repeated, so a
|
|
1195
|
+
multi-page read posts its earlier view selections again (the same views,
|
|
1196
|
+
within the same budget). Opening a received message, which marks it read,
|
|
1197
|
+
is not repeated: it fails as `SessionExpiredError` and the next call signs
|
|
1198
|
+
in. If the fresh session gets the same page, or a session that has just
|
|
1199
|
+
signed in gets it, the read fails as `ParseError` and that operation cools
|
|
1200
|
+
down for 60 seconds, so a page that stays does not sign in on every call.
|
|
1168
1201
|
- A denial or unrecovered expiry starts a 60-second cooldown for that operation
|
|
1169
1202
|
on that login; a failed login starts one for the whole login.
|
|
1170
1203
|
- 429 and 503 pause the whole scheduler, honouring `Retry-After` up to 24 hours.
|
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.8.2 (2026-11-01) - Modern session kept across cancels, honest User-Agent
|
|
4
|
+
|
|
5
|
+
- A cancelled modern mailbox read or a reached limit no longer drops the modern
|
|
6
|
+
session, so the next call needs no new launch. Other failures still drop it,
|
|
7
|
+
an unrecognized body and a cookie limit, which empties the jar, included.
|
|
8
|
+
- The `User-Agent` carries the package version; it said 0.4 since that release.
|
|
9
|
+
- `config.ATTENDANCE_RESULT_CACHE_SIZE` is renamed `ACCOUNT_RESULT_CACHE_SIZE`:
|
|
10
|
+
it caps every cached read of an account.
|
|
11
|
+
|
|
12
|
+
## 1.8.1 (2026-10-29) - Session recovery, lucky number, consent error and diagnostics
|
|
13
|
+
|
|
14
|
+
Signatures are unchanged; one error kind is added.
|
|
15
|
+
|
|
16
|
+
- Session recovery: for a session it no longer knows, Synergia answers every
|
|
17
|
+
page read with status 200 and a "Brak dostępu" page instead of a login
|
|
18
|
+
redirect (observed 2026-10-29). Up to 1.8.0 every read then failed as
|
|
19
|
+
`ParseError`, and kept failing until the client was recreated: the
|
|
20
|
+
profile's `parse_profile` failure a tester reported. Such a page now counts
|
|
21
|
+
as expiry: a read without an account effect signs in again and is repeated
|
|
22
|
+
once, view-selection POSTs included, since the page shows the request was
|
|
23
|
+
not applied. Opening a received message (mark-read) is not repeated; it
|
|
24
|
+
fails as `SessionExpiredError` and the next call signs in. A session that
|
|
25
|
+
has just signed in and gets the page, or a repeat that gets it again, fails
|
|
26
|
+
as `ParseError` and cools down for 60 seconds, so a page that stays does
|
|
27
|
+
not sign in on every call. Verified live by invalidating the session
|
|
28
|
+
cookies: student information, notification counts, grades, timetable,
|
|
29
|
+
attendance, homework and legacy messages each recovered with one login.
|
|
30
|
+
- Student information: the lucky number is read from the page header
|
|
31
|
+
(`span.luckyNumber`). Up to 1.8.0 the parser looked for an element ID the
|
|
32
|
+
page does not use, so `lucky_number` was always `UNAVAILABLE`. An empty
|
|
33
|
+
marker or "?" (reported where no number is drawn; neither observed) stays
|
|
34
|
+
`UNAVAILABLE` instead of failing the read.
|
|
35
|
+
- `describe_failure` summarizes a whole page held by the failing frame, after
|
|
36
|
+
the elements nearest the failure: body text length, Synergia menu and
|
|
37
|
+
header presence, title, headings, notices, forms and input types, and the
|
|
38
|
+
first tables row by row, instead of the start of the page head. Profile
|
|
39
|
+
labels, the login owner's row labels and known page notices are shown by
|
|
40
|
+
name, so a login or error page is told apart from a changed layout.
|
|
41
|
+
- `ErrorKind.CONSENT_REQUIRED` and `ConsentRequiredError`, a subclass of
|
|
42
|
+
`InvalidInputError`: opening a received message without
|
|
43
|
+
`allow_mark_read=True` raises it instead of a plain invalid input, so a
|
|
44
|
+
caller can ask the user rather than treat the message as unreadable.
|
|
45
|
+
`except InvalidInputError` still catches it; code comparing `kind` with
|
|
46
|
+
`INVALID_INPUT` sees the new kind.
|
|
47
|
+
- `DiagnosticEvent.http_status`: the HTTP status of the operation's last
|
|
48
|
+
checked read response, also logged by `logging_sink`. A read that fails as
|
|
49
|
+
`PARSE` at the response check now says whether it was a 2xx other than 200
|
|
50
|
+
or a 200 of another media type.
|
|
51
|
+
- `inspect_page profile FILE` checks a saved information page. A page source
|
|
52
|
+
saved as a macOS TextEdit document is named as such.
|
|
53
|
+
|
|
3
54
|
## 1.8.0 (2026-10-28) - Failure diagnostics and gateway descriptive grades
|
|
4
55
|
|
|
5
56
|
Errors still carry only their kind; new helpers say where a failure happened
|
|
@@ -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.
|
|
@@ -16,9 +16,13 @@ private ACLs. Windows installations include `pywin32` and `tzdata` automatically
|
|
|
16
16
|
Network paths, reparse-point paths and unsafe storage permissions fail closed.
|
|
17
17
|
See [platform requirements](contracts/persistence.md#windows-disk-boundary-25).
|
|
18
18
|
|
|
19
|
-
**Status:** `1.8.
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
**Status:** `1.8.2` keeps the modern mailbox session across a cancelled read
|
|
20
|
+
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
|
|
21
|
+
with a "Brak dostępu" page, not a login redirect), reads the lucky number
|
|
22
|
+
from the page header and summarizes a page that fails as a whole in
|
|
23
|
+
diagnostics. `1.8.0` reports where a failure
|
|
24
|
+
happened (code locations and a redacted page structure, see
|
|
25
|
+
[failure diagnostics](API.md#failure-diagnostics-180)), reads gateway descriptive grades for schools on the new descriptive schema
|
|
22
26
|
(source-informed, first populated read live on one school) and keeps the 1.7.1 point
|
|
23
27
|
grades and multi-line grade comments. It keeps the 1.6.4 linear-time message body
|
|
24
28
|
rendering and the 1.6.2 completed-lesson and grade layouts, including behaviour
|
|
@@ -34,7 +38,7 @@ See [limitations](#supported-features-and-limitations) below.
|
|
|
34
38
|
Install the exact release from PyPI after its gated publication completes:
|
|
35
39
|
|
|
36
40
|
```sh
|
|
37
|
-
python -m pip install librus-python-api==1.8.
|
|
41
|
+
python -m pip install librus-python-api==1.8.2
|
|
38
42
|
```
|
|
39
43
|
|
|
40
44
|
Pin the exact version qualified by your application. Before publication, use a
|
|
@@ -50,7 +54,7 @@ python -m pip install .
|
|
|
50
54
|
To install a locally built wheel instead:
|
|
51
55
|
|
|
52
56
|
```sh
|
|
53
|
-
python -m pip install ./dist/librus_python_api-1.8.
|
|
57
|
+
python -m pip install ./dist/librus_python_api-1.8.2-py3-none-any.whl
|
|
54
58
|
```
|
|
55
59
|
|
|
56
60
|
No console script or background process is installed: import the library in your
|
|
@@ -254,7 +258,10 @@ except LibrusError as error:
|
|
|
254
258
|
|
|
255
259
|
To check a saved page offline (a page source saved with Ctrl+U, or a copied
|
|
256
260
|
table), run `python -m librus_python_api.inspect_page grades page.html` (or
|
|
257
|
-
`final-grades
|
|
261
|
+
`final-grades`, or `profile` for the information page `/informacja`). Save the
|
|
262
|
+
page source itself: in Safari use File > Save As with format Page Source; in
|
|
263
|
+
Chrome on macOS open it with Option+Cmd+U, then Cmd+S. A source view copied
|
|
264
|
+
into TextEdit is a text document, which `inspect_page` rejects. It prints counts, or the failure's code locations and masked
|
|
258
265
|
structure, and makes no request. Pass a `diagnostic_sink` to `LibrusService`
|
|
259
266
|
to receive one `DiagnosticEvent` per operation, with `frames` on failure.
|
|
260
267
|
None of these print page text, values, logins or exception messages.
|
|
@@ -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",
|
|
@@ -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,
|
|
@@ -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
|
|
@@ -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:
|
|
@@ -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
|