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.
Files changed (58) hide show
  1. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/API.md +46 -15
  2. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/CHANGELOG.md +42 -0
  3. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/PKG-INFO +13 -7
  4. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/README.md +12 -6
  5. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/__init__.py +1 -1
  6. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/config.py +15 -0
  7. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/diagnostics.py +105 -3
  8. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/exceptions.py +13 -0
  9. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/inspect_page.py +18 -0
  10. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/models.py +2 -0
  11. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/parsers.py +35 -5
  12. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/service.py +80 -16
  13. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/.gitignore +0 -0
  14. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/LICENSE +0 -0
  15. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/SECURITY.md +0 -0
  16. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/pyproject.toml +0 -0
  17. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/_notification_bootstrap.py +0 -0
  18. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/_notification_codec.py +0 -0
  19. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/_storage.py +0 -0
  20. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/_windows_filesystem.py +0 -0
  21. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/announcements.py +0 -0
  22. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/attachment_routes.py +0 -0
  23. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/attachments.py +0 -0
  24. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/attendance.py +0 -0
  25. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/attendance_frequency.py +0 -0
  26. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/budget.py +0 -0
  27. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/checkpoint.py +0 -0
  28. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/class_free_days.py +0 -0
  29. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/completed_lessons.py +0 -0
  30. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/detail_fields.py +0 -0
  31. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/files.py +0 -0
  32. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/formative_grades.py +0 -0
  33. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/gateway_grades.py +0 -0
  34. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/grade_parsers.py +0 -0
  35. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/grade_records.py +0 -0
  36. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/homework_range.py +0 -0
  37. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/lifecycle.py +0 -0
  38. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/markup.py +0 -0
  39. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/message_content.py +0 -0
  40. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/messages.py +0 -0
  41. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/modern_body.py +0 -0
  42. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/modern_mailbox.py +0 -0
  43. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/modern_messages.py +0 -0
  44. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/notification_models.py +0 -0
  45. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/notification_persistence.py +0 -0
  46. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/notification_workflow.py +0 -0
  47. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/notifications.py +0 -0
  48. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/parsing.py +0 -0
  49. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/persistence.py +0 -0
  50. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/py.typed +0 -0
  51. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/recipients.py +0 -0
  52. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/scheduler.py +0 -0
  53. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/school_reads.py +0 -0
  54. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/school_year_archive.py +0 -0
  55. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/sending.py +0 -0
  56. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/timetable.py +0 -0
  57. {librus_python_api-1.8.0 → librus_python_api-1.8.1}/src/librus_python_api/transport.py +0 -0
  58. {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
- Output is bounded. It is meant for a human to review before sharing, not for
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` parses a
179
- saved page offline (a whole page or a copied table) and prints counts, or the
180
- failure kind, frames and structure. It makes no request and applies the
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 none; no date is invented.
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. `records` has:
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. Opening may mark read before any response or parse
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
- - Only a 401, or a redirect to an approved login route, proves expiry.
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.0
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.0` reports where a failure happened (code locations and a
46
- redacted page structure, see [failure diagnostics](API.md#failure-diagnostics-180)),
47
- reads gateway descriptive grades for schools on the new descriptive schema
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.0
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.0-py3-none-any.whl
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`). It prints counts, or the failure's code locations and masked
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.0` reports where a failure happened (code locations and a
20
- redacted page structure, see [failure diagnostics](API.md#failure-diagnostics-180)),
21
- reads gateway descriptive grades for schools on the new descriptive schema
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.0
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.0-py3-none-any.whl
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`). It prints counts, or the failure's code locations and masked
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.0"
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. Text and attribute values are masked unless they equal a
407
- parser constant. Returns None when there is nothing to show; never raises.
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
- for name, value in elements:
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 PAGE_NOTICES, PROFILE_LABELS
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('//*[@id="luckyNumber"]')
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 not re.fullmatch(r"[0-9]{1,4}", value):
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 ErrorKind, LibrusError, SessionExpiredError
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 parse_identity, parse_login, parse_profile
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 or (
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.kind in (
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 or (
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.kind in (ErrorKind.ACCESS_DENIED, ErrorKind.SESSION_EXPIRED):
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
- return await self._service._parsers.run(parse, response.body, budget)
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)