librus-python-api 1.8.0__tar.gz → 1.8.1__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.1}/API.md +46 -15
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/CHANGELOG.md +42 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/PKG-INFO +13 -7
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/README.md +12 -6
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/__init__.py +1 -1
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/config.py +15 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/diagnostics.py +105 -3
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/exceptions.py +13 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/inspect_page.py +18 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/models.py +2 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/parsers.py +35 -5
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/service.py +80 -16
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/.gitignore +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/LICENSE +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/SECURITY.md +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/pyproject.toml +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/_notification_bootstrap.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/_notification_codec.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/_storage.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/_windows_filesystem.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/announcements.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/attachment_routes.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/attachments.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/attendance.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/attendance_frequency.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/budget.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/checkpoint.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/class_free_days.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/completed_lessons.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/detail_fields.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/files.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/formative_grades.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/gateway_grades.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/grade_parsers.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/grade_records.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/homework_range.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/lifecycle.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/markup.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/message_content.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/messages.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/modern_body.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/modern_mailbox.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/modern_messages.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/notification_models.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/notification_persistence.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/notification_workflow.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/notifications.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/parsing.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/persistence.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/py.typed +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/recipients.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/scheduler.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/school_reads.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/school_year_archive.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/sending.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/timetable.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/transport.py +0 -0
- {librus_python_api-1.8.0 → librus_python_api-1.8.1}/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
|
|
@@ -939,7 +952,8 @@ be a `MessageReference` for the same login with a numeric ID and typed folder.
|
|
|
939
952
|
Foreign accounts, arbitrary URLs and injected IDs fail before authentication.
|
|
940
953
|
|
|
941
954
|
- Received opens require `allow_mark_read=True`, even for an already-read
|
|
942
|
-
selection or warm cache
|
|
955
|
+
selection or warm cache; without it they raise `ConsentRequiredError`
|
|
956
|
+
(`InvalidInputError` before 1.8.1) before any request. Opening may mark read before any response or parse
|
|
943
957
|
failure. Consent does not assert that upstream actually changed a read flag.
|
|
944
958
|
- `may_mark_read` describes the route, not delivery or mutation confirmation.
|
|
945
959
|
Sent opens do not require the opt-in. Neither folder is automatically replayed,
|
|
@@ -1072,7 +1086,7 @@ Limits: 32 group selectors, 2,048 recipients, 1,024 characters per label and
|
|
|
1072
1086
|
128 KiB total recipient text, plus shared transport/parser/body limits. Each
|
|
1073
1087
|
lookup has its own account/type/selection cache key and accepts the common budget and
|
|
1074
1088
|
freshness parameters. Fresh reads are default. Both selection operations are
|
|
1075
|
-
never replayed, even though group discovery uses GET. No send route, message
|
|
1089
|
+
never replayed (except once after Synergia's logged-out page, see [Errors](#errors)), even though group discovery uses GET. No send route, message
|
|
1076
1090
|
content, mark-read, attachment or read-once access occurs in recipient discovery.
|
|
1077
1091
|
|
|
1078
1092
|
The empty group-option selector is observed. Populated choices, nonzero dispatch,
|
|
@@ -1088,7 +1102,8 @@ grades, attendance, messages, announcements, agenda and homework. A shown catego
|
|
|
1088
1102
|
without a counter has zero; missing categories are not fabricated. Invalid or
|
|
1089
1103
|
duplicate counters fail. The menu may be a token-scoped snapshot, not a fresh poll
|
|
1090
1104
|
or the input for seen-state updates. This ordinary read accepts the common
|
|
1091
|
-
budget/freshness arguments and conservatively does not replay on expiry
|
|
1105
|
+
budget/freshness arguments and conservatively does not replay on expiry
|
|
1106
|
+
(except once after Synergia's logged-out page, see [Errors](#errors)).
|
|
1092
1107
|
|
|
1093
1108
|
```python
|
|
1094
1109
|
batch = await client.consume_schedule_events(
|
|
@@ -1145,6 +1160,7 @@ text. `error_for(kind)` builds one.
|
|
|
1145
1160
|
| Error | Meaning |
|
|
1146
1161
|
| --- | --- |
|
|
1147
1162
|
| `InvalidInputError` | Rejected before any request, or upstream rejected the selection |
|
|
1163
|
+
| `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
1164
|
| `CredentialsRejectedError` | Login and password refused |
|
|
1149
1165
|
| `AccountActionRequiredError` | CAPTCHA, 2FA or another interactive step is required |
|
|
1150
1166
|
| `SessionExpiredError` | Proven expiry that could not be recovered |
|
|
@@ -1162,9 +1178,24 @@ text. `error_for(kind)` builds one.
|
|
|
1162
1178
|
|
|
1163
1179
|
Recovery policy:
|
|
1164
1180
|
|
|
1165
|
-
-
|
|
1181
|
+
- A 401 or a redirect to an approved login route proves expiry. So does
|
|
1182
|
+
(1.8.1) a Synergia page that fails to parse, has no signed-in menu and
|
|
1183
|
+
whose only heading is "Brak dostępu", unless a session that has just signed
|
|
1184
|
+
in gets it. Synergia answers every page read of a session
|
|
1185
|
+
it no longer knows that way, with status 200 (observed 2026-10-29); up to
|
|
1186
|
+
1.8.0 this failed as `ParseError` on every call until the client was
|
|
1187
|
+
recreated.
|
|
1166
1188
|
- Safe GET reads then log in once more and retry once, within the same budget.
|
|
1167
|
-
- View-selection POSTs are never replayed.
|
|
1189
|
+
- View-selection POSTs are never replayed after a 401 or login redirect. A
|
|
1190
|
+
read without an account effect answered with the logged-out page, POST
|
|
1191
|
+
included, is repeated once in a fresh session: that page shows the request
|
|
1192
|
+
was not applied to the account. The whole read is repeated, so a
|
|
1193
|
+
multi-page read posts its earlier view selections again (the same views,
|
|
1194
|
+
within the same budget). Opening a received message, which marks it read,
|
|
1195
|
+
is not repeated: it fails as `SessionExpiredError` and the next call signs
|
|
1196
|
+
in. If the fresh session gets the same page, or a session that has just
|
|
1197
|
+
signed in gets it, the read fails as `ParseError` and that operation cools
|
|
1198
|
+
down for 60 seconds, so a page that stays does not sign in on every call.
|
|
1168
1199
|
- A denial or unrecovered expiry starts a 60-second cooldown for that operation
|
|
1169
1200
|
on that login; a failed login starts one for the whole login.
|
|
1170
1201
|
- 429 and 503 pause the whole scheduler, honouring `Retry-After` up to 24 hours.
|
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.8.1 (2026-10-29) - Session recovery, lucky number, consent error and diagnostics
|
|
4
|
+
|
|
5
|
+
Signatures are unchanged; one error kind is added.
|
|
6
|
+
|
|
7
|
+
- Session recovery: for a session it no longer knows, Synergia answers every
|
|
8
|
+
page read with status 200 and a "Brak dostępu" page instead of a login
|
|
9
|
+
redirect (observed 2026-10-29). Up to 1.8.0 every read then failed as
|
|
10
|
+
`ParseError`, and kept failing until the client was recreated: the
|
|
11
|
+
profile's `parse_profile` failure a tester reported. Such a page now counts
|
|
12
|
+
as expiry: a read without an account effect signs in again and is repeated
|
|
13
|
+
once, view-selection POSTs included, since the page shows the request was
|
|
14
|
+
not applied. Opening a received message (mark-read) is not repeated; it
|
|
15
|
+
fails as `SessionExpiredError` and the next call signs in. A session that
|
|
16
|
+
has just signed in and gets the page, or a repeat that gets it again, fails
|
|
17
|
+
as `ParseError` and cools down for 60 seconds, so a page that stays does
|
|
18
|
+
not sign in on every call. Verified live by invalidating the session
|
|
19
|
+
cookies: student information, notification counts, grades, timetable,
|
|
20
|
+
attendance, homework and legacy messages each recovered with one login.
|
|
21
|
+
- Student information: the lucky number is read from the page header
|
|
22
|
+
(`span.luckyNumber`). Up to 1.8.0 the parser looked for an element ID the
|
|
23
|
+
page does not use, so `lucky_number` was always `UNAVAILABLE`. An empty
|
|
24
|
+
marker or "?" (reported where no number is drawn; neither observed) stays
|
|
25
|
+
`UNAVAILABLE` instead of failing the read.
|
|
26
|
+
- `describe_failure` summarizes a whole page held by the failing frame, after
|
|
27
|
+
the elements nearest the failure: body text length, Synergia menu and
|
|
28
|
+
header presence, title, headings, notices, forms and input types, and the
|
|
29
|
+
first tables row by row, instead of the start of the page head. Profile
|
|
30
|
+
labels, the login owner's row labels and known page notices are shown by
|
|
31
|
+
name, so a login or error page is told apart from a changed layout.
|
|
32
|
+
- `ErrorKind.CONSENT_REQUIRED` and `ConsentRequiredError`, a subclass of
|
|
33
|
+
`InvalidInputError`: opening a received message without
|
|
34
|
+
`allow_mark_read=True` raises it instead of a plain invalid input, so a
|
|
35
|
+
caller can ask the user rather than treat the message as unreadable.
|
|
36
|
+
`except InvalidInputError` still catches it; code comparing `kind` with
|
|
37
|
+
`INVALID_INPUT` sees the new kind.
|
|
38
|
+
- `DiagnosticEvent.http_status`: the HTTP status of the operation's last
|
|
39
|
+
checked read response, also logged by `logging_sink`. A read that fails as
|
|
40
|
+
`PARSE` at the response check now says whether it was a 2xx other than 200
|
|
41
|
+
or a 200 of another media type.
|
|
42
|
+
- `inspect_page profile FILE` checks a saved information page. A page source
|
|
43
|
+
saved as a macOS TextEdit document is named as such.
|
|
44
|
+
|
|
3
45
|
## 1.8.0 (2026-10-28) - Failure diagnostics and gateway descriptive grades
|
|
4
46
|
|
|
5
47
|
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.1
|
|
4
4
|
Summary: Independent bounded async Librus Synergia client
|
|
5
5
|
Project-URL: Repository, https://github.com/krzysztofbury/librus-python-api
|
|
6
6
|
Project-URL: Issues, https://github.com/krzysztofbury/librus-python-api/issues
|
|
@@ -42,9 +42,12 @@ 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.1` recovers a session Synergia no longer knows (it answers
|
|
46
|
+
with a "Brak dostępu" page, not a login redirect), reads the lucky number
|
|
47
|
+
from the page header and summarizes a page that fails as a whole in
|
|
48
|
+
diagnostics. `1.8.0` reports where a failure
|
|
49
|
+
happened (code locations and a redacted page structure, see
|
|
50
|
+
[failure diagnostics](API.md#failure-diagnostics-180)), reads gateway descriptive grades for schools on the new descriptive schema
|
|
48
51
|
(source-informed, first populated read live on one school) and keeps the 1.7.1 point
|
|
49
52
|
grades and multi-line grade comments. It keeps the 1.6.4 linear-time message body
|
|
50
53
|
rendering and the 1.6.2 completed-lesson and grade layouts, including behaviour
|
|
@@ -60,7 +63,7 @@ See [limitations](#supported-features-and-limitations) below.
|
|
|
60
63
|
Install the exact release from PyPI after its gated publication completes:
|
|
61
64
|
|
|
62
65
|
```sh
|
|
63
|
-
python -m pip install librus-python-api==1.8.
|
|
66
|
+
python -m pip install librus-python-api==1.8.1
|
|
64
67
|
```
|
|
65
68
|
|
|
66
69
|
Pin the exact version qualified by your application. Before publication, use a
|
|
@@ -76,7 +79,7 @@ python -m pip install .
|
|
|
76
79
|
To install a locally built wheel instead:
|
|
77
80
|
|
|
78
81
|
```sh
|
|
79
|
-
python -m pip install ./dist/librus_python_api-1.8.
|
|
82
|
+
python -m pip install ./dist/librus_python_api-1.8.1-py3-none-any.whl
|
|
80
83
|
```
|
|
81
84
|
|
|
82
85
|
No console script or background process is installed: import the library in your
|
|
@@ -280,7 +283,10 @@ except LibrusError as error:
|
|
|
280
283
|
|
|
281
284
|
To check a saved page offline (a page source saved with Ctrl+U, or a copied
|
|
282
285
|
table), run `python -m librus_python_api.inspect_page grades page.html` (or
|
|
283
|
-
`final-grades
|
|
286
|
+
`final-grades`, or `profile` for the information page `/informacja`). Save the
|
|
287
|
+
page source itself: in Safari use File > Save As with format Page Source; in
|
|
288
|
+
Chrome on macOS open it with Option+Cmd+U, then Cmd+S. A source view copied
|
|
289
|
+
into TextEdit is a text document, which `inspect_page` rejects. It prints counts, or the failure's code locations and masked
|
|
284
290
|
structure, and makes no request. Pass a `diagnostic_sink` to `LibrusService`
|
|
285
291
|
to receive one `DiagnosticEvent` per operation, with `frames` on failure.
|
|
286
292
|
None of these print page text, values, logins or exception messages.
|
|
@@ -16,9 +16,12 @@ 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.1` recovers a session Synergia no longer knows (it answers
|
|
20
|
+
with a "Brak dostępu" page, not a login redirect), reads the lucky number
|
|
21
|
+
from the page header and summarizes a page that fails as a whole in
|
|
22
|
+
diagnostics. `1.8.0` reports where a failure
|
|
23
|
+
happened (code locations and a redacted page structure, see
|
|
24
|
+
[failure diagnostics](API.md#failure-diagnostics-180)), reads gateway descriptive grades for schools on the new descriptive schema
|
|
22
25
|
(source-informed, first populated read live on one school) and keeps the 1.7.1 point
|
|
23
26
|
grades and multi-line grade comments. It keeps the 1.6.4 linear-time message body
|
|
24
27
|
rendering and the 1.6.2 completed-lesson and grade layouts, including behaviour
|
|
@@ -34,7 +37,7 @@ See [limitations](#supported-features-and-limitations) below.
|
|
|
34
37
|
Install the exact release from PyPI after its gated publication completes:
|
|
35
38
|
|
|
36
39
|
```sh
|
|
37
|
-
python -m pip install librus-python-api==1.8.
|
|
40
|
+
python -m pip install librus-python-api==1.8.1
|
|
38
41
|
```
|
|
39
42
|
|
|
40
43
|
Pin the exact version qualified by your application. Before publication, use a
|
|
@@ -50,7 +53,7 @@ python -m pip install .
|
|
|
50
53
|
To install a locally built wheel instead:
|
|
51
54
|
|
|
52
55
|
```sh
|
|
53
|
-
python -m pip install ./dist/librus_python_api-1.8.
|
|
56
|
+
python -m pip install ./dist/librus_python_api-1.8.1-py3-none-any.whl
|
|
54
57
|
```
|
|
55
58
|
|
|
56
59
|
No console script or background process is installed: import the library in your
|
|
@@ -254,7 +257,10 @@ except LibrusError as error:
|
|
|
254
257
|
|
|
255
258
|
To check a saved page offline (a page source saved with Ctrl+U, or a copied
|
|
256
259
|
table), run `python -m librus_python_api.inspect_page grades page.html` (or
|
|
257
|
-
`final-grades
|
|
260
|
+
`final-grades`, or `profile` for the information page `/informacja`). Save the
|
|
261
|
+
page source itself: in Safari use File > Save As with format Page Source; in
|
|
262
|
+
Chrome on macOS open it with Option+Cmd+U, then Cmd+S. A source view copied
|
|
263
|
+
into TextEdit is a text document, which `inspect_page` rejects. It prints counts, or the failure's code locations and masked
|
|
258
264
|
structure, and makes no request. Pass a `diagnostic_sink` to `LibrusService`
|
|
259
265
|
to receive one `DiagnosticEvent` per operation, with `frames` on failure.
|
|
260
266
|
None of these print page text, values, logins or exception messages.
|
|
@@ -138,7 +138,7 @@ from librus_python_api.models import (
|
|
|
138
138
|
from librus_python_api.sending import SendAttempt
|
|
139
139
|
from librus_python_api.service import AccountClient, LibrusService
|
|
140
140
|
|
|
141
|
-
__version__ = "1.8.
|
|
141
|
+
__version__ = "1.8.1"
|
|
142
142
|
|
|
143
143
|
__all__ = [
|
|
144
144
|
"ArchiveAbsences",
|
|
@@ -688,6 +688,21 @@ PROFILE_LABELS = MappingProxyType(
|
|
|
688
688
|
"Szkoła": "school",
|
|
689
689
|
}
|
|
690
690
|
)
|
|
691
|
+
# Rows the information page lists for the login's own user after the
|
|
692
|
+
# student's rows (observed); never read, named by failure diagnostics.
|
|
693
|
+
PROFILE_OWNER_LABELS = ("Imię i nazwisko użytkownika", "Login", "Hasło")
|
|
694
|
+
# The page header shows the lucky number as `span.luckyNumber > b` (observed
|
|
695
|
+
# 2026-10-29). "?" there is reported where no number is drawn, for a school
|
|
696
|
+
# without lucky numbers and possibly on days without a draw; neither it nor
|
|
697
|
+
# an empty marker has been observed.
|
|
698
|
+
PROFILE_LUCKY_NUMBER_CLASS = "luckyNumber"
|
|
699
|
+
# For a session it no longer knows, Synergia answers every page read with 200
|
|
700
|
+
# and a page whose only heading is "Brak dostępu" (`h2.inside`), without the
|
|
701
|
+
# signed-in menu, not with a login redirect (observed 2026-10-29). A signed-in
|
|
702
|
+
# user's timetable page has no menu either, so the heading is required too.
|
|
703
|
+
LOGGED_IN_PAGE_MARKER = "graphic-menu"
|
|
704
|
+
LOGGED_OUT_HEADING = "Brak dostępu"
|
|
705
|
+
PROFILE_LUCKY_NUMBER_DISABLED = "?"
|
|
691
706
|
|
|
692
707
|
# HTML summary headers omit the two leading body cells: expander and subject.
|
|
693
708
|
# These labels/layout rules are source-informed requirements, not live evidence.
|
|
@@ -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):
|
|
@@ -154,6 +166,7 @@ class StorageError(LibrusError):
|
|
|
154
166
|
_ERROR_TYPES: dict[ErrorKind, type[LibrusError]] = {
|
|
155
167
|
ErrorKind.CHECKPOINT: CheckpointError,
|
|
156
168
|
ErrorKind.INVALID_INPUT: InvalidInputError,
|
|
169
|
+
ErrorKind.CONSENT_REQUIRED: ConsentRequiredError,
|
|
157
170
|
ErrorKind.CREDENTIALS_REJECTED: CredentialsRejectedError,
|
|
158
171
|
ErrorKind.ACCOUNT_ACTION_REQUIRED: AccountActionRequiredError,
|
|
159
172
|
ErrorKind.SESSION_EXPIRED: SessionExpiredError,
|
|
@@ -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>"
|
|
@@ -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)
|
|
@@ -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]
|
|
@@ -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
|
|
@@ -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,12 @@ 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
|
+
ParseError,
|
|
78
|
+
SessionExpiredError,
|
|
79
|
+
)
|
|
73
80
|
from librus_python_api.gateway_grades import (
|
|
74
81
|
DescriptiveTable,
|
|
75
82
|
descriptive_grades,
|
|
@@ -198,7 +205,12 @@ from librus_python_api.notifications import (
|
|
|
198
205
|
parse_schedule_events,
|
|
199
206
|
validate_response,
|
|
200
207
|
)
|
|
201
|
-
from librus_python_api.parsers import
|
|
208
|
+
from librus_python_api.parsers import (
|
|
209
|
+
is_logged_out_page,
|
|
210
|
+
parse_identity,
|
|
211
|
+
parse_login,
|
|
212
|
+
parse_profile,
|
|
213
|
+
)
|
|
202
214
|
from librus_python_api.parsing import ParserPool
|
|
203
215
|
from librus_python_api.recipients import (
|
|
204
216
|
parse_recipient_group_choices,
|
|
@@ -381,6 +393,24 @@ def _require_dates(*days: date | None, max_days: int | None = None) -> None:
|
|
|
381
393
|
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
382
394
|
|
|
383
395
|
|
|
396
|
+
def _cooldown_due(error: LibrusError) -> bool:
|
|
397
|
+
"""A denial, an unrecovered expiry, or a logged-out page that a session
|
|
398
|
+
just signed in also got: signing in again at once cannot help."""
|
|
399
|
+
if error.kind in (ErrorKind.ACCESS_DENIED, ErrorKind.SESSION_EXPIRED):
|
|
400
|
+
return True
|
|
401
|
+
cause = error.__cause__
|
|
402
|
+
return (
|
|
403
|
+
error.kind is ErrorKind.PARSE
|
|
404
|
+
and isinstance(cause, SessionExpiredError)
|
|
405
|
+
and cause._logged_out_page
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# The running operation's last checked read status, for its diagnostic event.
|
|
410
|
+
# A holder per operation, so concurrent operations do not share one.
|
|
411
|
+
_READ_STATUS: contextvars.ContextVar[list[int | None] | None] = contextvars.ContextVar(
|
|
412
|
+
"librus_read_status", default=None
|
|
413
|
+
)
|
|
384
414
|
_METADATA_MAP_FALLBACK = frozenset(
|
|
385
415
|
{ErrorKind.ACCESS_DENIED, ErrorKind.CONNECTION, ErrorKind.LIMIT, ErrorKind.PARSE}
|
|
386
416
|
)
|
|
@@ -894,10 +924,10 @@ class AccountClient:
|
|
|
894
924
|
max_age_seconds: float = 0.0,
|
|
895
925
|
) -> ModernMessageContent:
|
|
896
926
|
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
|
-
):
|
|
927
|
+
if type(allow_mark_read) is not bool:
|
|
900
928
|
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
929
|
+
if reference.folder is MessageFolder.RECEIVED and not allow_mark_read:
|
|
930
|
+
raise LibrusError(ErrorKind.CONSENT_REQUIRED)
|
|
901
931
|
if reference.archived:
|
|
902
932
|
# No archived content route or mark-read effect is established yet.
|
|
903
933
|
raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
|
|
@@ -1178,6 +1208,8 @@ class AccountClient:
|
|
|
1178
1208
|
started = time.monotonic()
|
|
1179
1209
|
outcome: ErrorKind | Literal["ok", "cancelled"] = "ok"
|
|
1180
1210
|
frames: tuple[str, ...] = ()
|
|
1211
|
+
status: list[int | None] = [None]
|
|
1212
|
+
status_token = _READ_STATUS.set(status)
|
|
1181
1213
|
try:
|
|
1182
1214
|
if deadline is None:
|
|
1183
1215
|
deadline = asyncio.timeout(None)
|
|
@@ -1195,10 +1227,7 @@ class AccountClient:
|
|
|
1195
1227
|
outcome = error.kind
|
|
1196
1228
|
if self._service._diagnostic_sink is not None:
|
|
1197
1229
|
frames = failure_frames(error)
|
|
1198
|
-
if authenticate and error
|
|
1199
|
-
ErrorKind.ACCESS_DENIED,
|
|
1200
|
-
ErrorKind.SESSION_EXPIRED,
|
|
1201
|
-
):
|
|
1230
|
+
if authenticate and _cooldown_due(error):
|
|
1202
1231
|
self._cooldowns[operation] = (
|
|
1203
1232
|
time.monotonic() + self._service._transport_limits.cooldown_seconds,
|
|
1204
1233
|
error.kind,
|
|
@@ -1215,6 +1244,7 @@ class AccountClient:
|
|
|
1215
1244
|
# produce a misleading success diagnostic at this owner boundary.
|
|
1216
1245
|
outcome = ErrorKind.CONNECTION
|
|
1217
1246
|
finally:
|
|
1247
|
+
_READ_STATUS.reset(status_token)
|
|
1218
1248
|
self._emit(
|
|
1219
1249
|
DiagnosticEvent(
|
|
1220
1250
|
operation,
|
|
@@ -1223,6 +1253,7 @@ class AccountClient:
|
|
|
1223
1253
|
budget.requests_dispatched,
|
|
1224
1254
|
budget.response_bytes,
|
|
1225
1255
|
frames,
|
|
1256
|
+
status[0],
|
|
1226
1257
|
)
|
|
1227
1258
|
)
|
|
1228
1259
|
|
|
@@ -1761,10 +1792,10 @@ class AccountClient:
|
|
|
1761
1792
|
) -> MessageContent:
|
|
1762
1793
|
"""Open one message; received opens require explicit read-effect consent."""
|
|
1763
1794
|
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
|
-
):
|
|
1795
|
+
if type(allow_mark_read) is not bool:
|
|
1767
1796
|
raise LibrusError(ErrorKind.INVALID_INPUT)
|
|
1797
|
+
if reference.folder is MessageFolder.RECEIVED and not allow_mark_read:
|
|
1798
|
+
raise LibrusError(ErrorKind.CONSENT_REQUIRED)
|
|
1768
1799
|
operation: Literal["message_content_received", "message_content_sent"] = (
|
|
1769
1800
|
"message_content_received"
|
|
1770
1801
|
if reference.folder is MessageFolder.RECEIVED
|
|
@@ -2044,6 +2075,8 @@ class AccountClient:
|
|
|
2044
2075
|
started = time.monotonic()
|
|
2045
2076
|
outcome: ErrorKind | Literal["ok", "cancelled"] = "ok"
|
|
2046
2077
|
frames: tuple[str, ...] = ()
|
|
2078
|
+
status: list[int | None] = [None]
|
|
2079
|
+
status_token = _READ_STATUS.set(status)
|
|
2047
2080
|
try:
|
|
2048
2081
|
async with asyncio.timeout(budget.remaining_seconds()):
|
|
2049
2082
|
async with self._lock:
|
|
@@ -2062,6 +2095,7 @@ class AccountClient:
|
|
|
2062
2095
|
outcome = ErrorKind.TIMEOUT
|
|
2063
2096
|
raise LibrusError(ErrorKind.TIMEOUT) from None
|
|
2064
2097
|
finally:
|
|
2098
|
+
_READ_STATUS.reset(status_token)
|
|
2065
2099
|
self._emit(
|
|
2066
2100
|
DiagnosticEvent(
|
|
2067
2101
|
operation,
|
|
@@ -2070,6 +2104,7 @@ class AccountClient:
|
|
|
2070
2104
|
budget.requests_dispatched,
|
|
2071
2105
|
budget.response_bytes,
|
|
2072
2106
|
frames,
|
|
2107
|
+
status[0],
|
|
2073
2108
|
)
|
|
2074
2109
|
)
|
|
2075
2110
|
|
|
@@ -2101,7 +2136,7 @@ class AccountClient:
|
|
|
2101
2136
|
try:
|
|
2102
2137
|
result = await self._authenticated(fetch, budget, retry_safe)
|
|
2103
2138
|
except LibrusError as error:
|
|
2104
|
-
if error
|
|
2139
|
+
if _cooldown_due(error):
|
|
2105
2140
|
self._cooldowns[operation] = (
|
|
2106
2141
|
time.monotonic() + self._service._transport_limits.cooldown_seconds,
|
|
2107
2142
|
error.kind,
|
|
@@ -2119,7 +2154,10 @@ class AccountClient:
|
|
|
2119
2154
|
|
|
2120
2155
|
Initial login is never retried: a failed credential submission must not
|
|
2121
2156
|
be replayed. A view-changing POST is never replayed either; expiry only
|
|
2122
|
-
clears the session so a later explicit call can log in again.
|
|
2157
|
+
clears the session so a later explicit call can log in again. A
|
|
2158
|
+
logged-out page proves the request was not applied to the account, so
|
|
2159
|
+
a read without an account effect answered with one is repeated once in
|
|
2160
|
+
a fresh session; a session just signed in that gets it fails as PARSE.
|
|
2123
2161
|
"""
|
|
2124
2162
|
fresh_login = self._identity is None
|
|
2125
2163
|
if fresh_login:
|
|
@@ -2127,18 +2165,22 @@ class AccountClient:
|
|
|
2127
2165
|
try:
|
|
2128
2166
|
return await fetch(budget, fresh_login)
|
|
2129
2167
|
except SessionExpiredError as error:
|
|
2168
|
+
if error._logged_out_page and fresh_login:
|
|
2169
|
+
raise LibrusError(ErrorKind.PARSE) from error
|
|
2130
2170
|
modern_expired = error._messages_origin
|
|
2131
2171
|
if modern_expired:
|
|
2132
2172
|
self._invalidate_modern()
|
|
2133
2173
|
else:
|
|
2134
2174
|
self._invalidate()
|
|
2135
|
-
if not retry_safe:
|
|
2175
|
+
if not retry_safe and not error._logged_out_page:
|
|
2136
2176
|
raise
|
|
2137
2177
|
if not modern_expired:
|
|
2138
2178
|
await self._authenticate(budget)
|
|
2139
2179
|
try:
|
|
2140
2180
|
return await fetch(budget, not modern_expired)
|
|
2141
2181
|
except SessionExpiredError as error:
|
|
2182
|
+
if error._logged_out_page:
|
|
2183
|
+
raise LibrusError(ErrorKind.PARSE) from error
|
|
2142
2184
|
if error._messages_origin:
|
|
2143
2185
|
self._invalidate_modern()
|
|
2144
2186
|
else:
|
|
@@ -2167,7 +2209,26 @@ class AccountClient:
|
|
|
2167
2209
|
endpoint, budget, form=form, reference_id=reference, query=query
|
|
2168
2210
|
)
|
|
2169
2211
|
self._validate_read_response(response, content_type)
|
|
2170
|
-
|
|
2212
|
+
try:
|
|
2213
|
+
return await self._service._parsers.run(parse, response.body, budget)
|
|
2214
|
+
except ParseError as error:
|
|
2215
|
+
if (
|
|
2216
|
+
content_type != HTML
|
|
2217
|
+
or ENDPOINTS[endpoint].origin != "synergia"
|
|
2218
|
+
or not await self._service._parsers.run(
|
|
2219
|
+
is_logged_out_page, response.body, budget
|
|
2220
|
+
)
|
|
2221
|
+
):
|
|
2222
|
+
raise
|
|
2223
|
+
# Synergia's answer to a session it no longer knows. A read
|
|
2224
|
+
# with an account effect (mark-read) is not repeated: it fails
|
|
2225
|
+
# as an unrecovered expiry and the next call signs in.
|
|
2226
|
+
expired = SessionExpiredError(ErrorKind.SESSION_EXPIRED)
|
|
2227
|
+
expired._logged_out_page = ENDPOINTS[endpoint].side_effect in (
|
|
2228
|
+
SideEffect.NONE,
|
|
2229
|
+
SideEffect.SELECT_VIEW,
|
|
2230
|
+
)
|
|
2231
|
+
raise expired from error
|
|
2171
2232
|
except BaseException as error:
|
|
2172
2233
|
if ENDPOINTS[endpoint].origin == "messages":
|
|
2173
2234
|
self._invalidate_modern()
|
|
@@ -2548,6 +2609,9 @@ class AccountClient:
|
|
|
2548
2609
|
def _validate_read_response(
|
|
2549
2610
|
self, response: TransportResponse, content_type: str
|
|
2550
2611
|
) -> None:
|
|
2612
|
+
holder = _READ_STATUS.get()
|
|
2613
|
+
if holder is not None:
|
|
2614
|
+
holder[0] = response.status
|
|
2551
2615
|
if 300 <= response.status < 400:
|
|
2552
2616
|
if self._is_module_unavailable_redirect(response):
|
|
2553
2617
|
raise LibrusError(ErrorKind.MODULE_UNAVAILABLE)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/_notification_bootstrap.py
RENAMED
|
File without changes
|
{librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/_notification_codec.py
RENAMED
|
File without changes
|
|
File without changes
|
{librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/_windows_filesystem.py
RENAMED
|
File without changes
|
|
File without changes
|
{librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/attachment_routes.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/attendance_frequency.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/class_free_days.py
RENAMED
|
File without changes
|
{librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/completed_lessons.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/formative_grades.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/message_content.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/modern_messages.py
RENAMED
|
File without changes
|
{librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/notification_models.py
RENAMED
|
File without changes
|
|
File without changes
|
{librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/notification_workflow.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/school_year_archive.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|