librus-python-api 1.8.0__tar.gz → 1.8.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/API.md +49 -16
  2. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/CHANGELOG.md +51 -0
  3. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/PKG-INFO +14 -7
  4. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/README.md +13 -6
  5. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/__init__.py +3 -2
  6. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/attachments.py +5 -4
  7. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/config.py +21 -3
  8. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/diagnostics.py +105 -3
  9. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/exceptions.py +16 -1
  10. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/gateway_grades.py +3 -2
  11. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/inspect_page.py +18 -0
  12. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/models.py +2 -0
  13. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/parsers.py +35 -5
  14. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/service.py +106 -26
  15. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/transport.py +16 -10
  16. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/.gitignore +0 -0
  17. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/LICENSE +0 -0
  18. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/SECURITY.md +0 -0
  19. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/pyproject.toml +0 -0
  20. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/_notification_bootstrap.py +0 -0
  21. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/_notification_codec.py +0 -0
  22. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/_storage.py +0 -0
  23. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/_windows_filesystem.py +0 -0
  24. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/announcements.py +0 -0
  25. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/attachment_routes.py +0 -0
  26. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/attendance.py +0 -0
  27. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/attendance_frequency.py +0 -0
  28. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/budget.py +0 -0
  29. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/checkpoint.py +0 -0
  30. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/class_free_days.py +0 -0
  31. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/completed_lessons.py +0 -0
  32. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/detail_fields.py +0 -0
  33. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/files.py +0 -0
  34. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/formative_grades.py +0 -0
  35. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/grade_parsers.py +0 -0
  36. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/grade_records.py +0 -0
  37. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/homework_range.py +0 -0
  38. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/lifecycle.py +0 -0
  39. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/markup.py +0 -0
  40. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/message_content.py +0 -0
  41. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/messages.py +0 -0
  42. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/modern_body.py +0 -0
  43. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/modern_mailbox.py +0 -0
  44. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/modern_messages.py +0 -0
  45. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/notification_models.py +0 -0
  46. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/notification_persistence.py +0 -0
  47. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/notification_workflow.py +0 -0
  48. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/notifications.py +0 -0
  49. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/parsing.py +0 -0
  50. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/persistence.py +0 -0
  51. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/py.typed +0 -0
  52. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/recipients.py +0 -0
  53. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/scheduler.py +0 -0
  54. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/school_reads.py +0 -0
  55. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/school_year_archive.py +0 -0
  56. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/sending.py +0 -0
  57. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/src/librus_python_api/timetable.py +0 -0
  58. {librus_python_api-1.8.0 → librus_python_api-1.8.2}/uv.lock +0 -0
@@ -148,7 +148,10 @@ refunded. Exhaustion raises `LimitError`; deadlines raise `OperationTimeoutError
148
148
  allowlisted modern directory and mailbox selections only. Arbitrary query keys,
149
149
  routes and modern-to-legacy reference reuse are rejected before dispatch.
150
150
  - `diagnostic_sink`: receives `DiagnosticEvent(operation, outcome,
151
- elapsed_seconds, budget_requests_dispatched, budget_response_bytes, frames)`.
151
+ elapsed_seconds, budget_requests_dispatched, budget_response_bytes, frames,
152
+ http_status)`. `http_status` (1.8.1) is the HTTP status of the operation's
153
+ last checked read response, or None; statuses the transport turns into
154
+ errors (4xx, 5xx) are not checked reads, and attachment streams carry none.
152
155
  `frames` (1.8.0) holds the code locations of a `LibrusError` outcome, as below;
153
156
  it is empty on success and for timeouts, cancellation and replaced
154
157
  connection errors. `librus_python_api.diagnostics.logging_sink` emits allowlisted fields
@@ -173,11 +176,17 @@ from the exception's own traceback. Neither raises.
173
176
  known tooltip labels (`config.TOOLTIP_LABELS`), known detail-link families and
174
177
  lengths. Text and attribute values are masked unless they equal a parser
175
178
  constant, so a failing tooltip reads like `[[Data], [Waga], NO-COLON(len=17)]`.
176
- 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
@@ -457,7 +470,9 @@ The initial handoff already includes that GET; subsequent sends cost one GET plu
457
470
  one POST. Expiry before dispatch raises `SessionExpiredError` and leaves
458
471
  NOT_DISPATCHED. An exact launch-to-native-login redirect is expiry, not denial.
459
472
  Messages-origin failures clear only modern binding/cookies/cache; valid legacy
460
- sessions stay usable. A later explicit modern call can rebind, but no modern
473
+ sessions stay usable. A cancelled mailbox read, or one stopped by a reached limit,
474
+ keeps them, so the next call needs no new launch; a limit that emptied the cookie
475
+ jar does not, and neither does a transport timeout. A later explicit modern call can rebind, but no modern
461
476
  read/send is automatically retried. A race after preflight can still yield UNKNOWN.
462
477
 
463
478
  - `modern_identity(*, budget=None, max_age_seconds=0)` returns `ModernIdentity`:
@@ -939,7 +954,8 @@ be a `MessageReference` for the same login with a numeric ID and typed folder.
939
954
  Foreign accounts, arbitrary URLs and injected IDs fail before authentication.
940
955
 
941
956
  - Received opens require `allow_mark_read=True`, even for an already-read
942
- selection or warm cache. Opening may mark read before any response or parse
957
+ selection or warm cache; without it they raise `ConsentRequiredError`
958
+ (`InvalidInputError` before 1.8.1) before any request. Opening may mark read before any response or parse
943
959
  failure. Consent does not assert that upstream actually changed a read flag.
944
960
  - `may_mark_read` describes the route, not delivery or mutation confirmation.
945
961
  Sent opens do not require the opt-in. Neither folder is automatically replayed,
@@ -1072,7 +1088,7 @@ Limits: 32 group selectors, 2,048 recipients, 1,024 characters per label and
1072
1088
  128 KiB total recipient text, plus shared transport/parser/body limits. Each
1073
1089
  lookup has its own account/type/selection cache key and accepts the common budget and
1074
1090
  freshness parameters. Fresh reads are default. Both selection operations are
1075
- never replayed, even though group discovery uses GET. No send route, message
1091
+ never replayed (except once after Synergia's logged-out page, see [Errors](#errors)), even though group discovery uses GET. No send route, message
1076
1092
  content, mark-read, attachment or read-once access occurs in recipient discovery.
1077
1093
 
1078
1094
  The empty group-option selector is observed. Populated choices, nonzero dispatch,
@@ -1088,7 +1104,8 @@ grades, attendance, messages, announcements, agenda and homework. A shown catego
1088
1104
  without a counter has zero; missing categories are not fabricated. Invalid or
1089
1105
  duplicate counters fail. The menu may be a token-scoped snapshot, not a fresh poll
1090
1106
  or the input for seen-state updates. This ordinary read accepts the common
1091
- budget/freshness arguments and conservatively does not replay on expiry.
1107
+ budget/freshness arguments and conservatively does not replay on expiry
1108
+ (except once after Synergia's logged-out page, see [Errors](#errors)).
1092
1109
 
1093
1110
  ```python
1094
1111
  batch = await client.consume_schedule_events(
@@ -1145,6 +1162,7 @@ text. `error_for(kind)` builds one.
1145
1162
  | Error | Meaning |
1146
1163
  | --- | --- |
1147
1164
  | `InvalidInputError` | Rejected before any request, or upstream rejected the selection |
1165
+ | `ConsentRequiredError` | A subclass of `InvalidInputError` (1.8.1): a call with an effect the user must agree to, such as opening a received message, was made without that consent; nothing was requested |
1148
1166
  | `CredentialsRejectedError` | Login and password refused |
1149
1167
  | `AccountActionRequiredError` | CAPTCHA, 2FA or another interactive step is required |
1150
1168
  | `SessionExpiredError` | Proven expiry that could not be recovered |
@@ -1162,9 +1180,24 @@ text. `error_for(kind)` builds one.
1162
1180
 
1163
1181
  Recovery policy:
1164
1182
 
1165
- - Only a 401, or a redirect to an approved login route, proves expiry.
1183
+ - A 401 or a redirect to an approved login route proves expiry. So does
1184
+ (1.8.1) a Synergia page that fails to parse, has no signed-in menu and
1185
+ whose only heading is "Brak dostępu", unless a session that has just signed
1186
+ in gets it. Synergia answers every page read of a session
1187
+ it no longer knows that way, with status 200 (observed 2026-10-29); up to
1188
+ 1.8.0 this failed as `ParseError` on every call until the client was
1189
+ recreated.
1166
1190
  - Safe GET reads then log in once more and retry once, within the same budget.
1167
- - View-selection POSTs are never replayed.
1191
+ - View-selection POSTs are never replayed after a 401 or login redirect. A
1192
+ read without an account effect answered with the logged-out page, POST
1193
+ included, is repeated once in a fresh session: that page shows the request
1194
+ was not applied to the account. The whole read is repeated, so a
1195
+ multi-page read posts its earlier view selections again (the same views,
1196
+ within the same budget). Opening a received message, which marks it read,
1197
+ is not repeated: it fails as `SessionExpiredError` and the next call signs
1198
+ in. If the fresh session gets the same page, or a session that has just
1199
+ signed in gets it, the read fails as `ParseError` and that operation cools
1200
+ down for 60 seconds, so a page that stays does not sign in on every call.
1168
1201
  - A denial or unrecovered expiry starts a 60-second cooldown for that operation
1169
1202
  on that login; a failed login starts one for the whole login.
1170
1203
  - 429 and 503 pause the whole scheduler, honouring `Retry-After` up to 24 hours.
@@ -1,5 +1,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.8.2 (2026-11-01) - Modern session kept across cancels, honest User-Agent
4
+
5
+ - A cancelled modern mailbox read or a reached limit no longer drops the modern
6
+ session, so the next call needs no new launch. Other failures still drop it,
7
+ an unrecognized body and a cookie limit, which empties the jar, included.
8
+ - The `User-Agent` carries the package version; it said 0.4 since that release.
9
+ - `config.ATTENDANCE_RESULT_CACHE_SIZE` is renamed `ACCOUNT_RESULT_CACHE_SIZE`:
10
+ it caps every cached read of an account.
11
+
12
+ ## 1.8.1 (2026-10-29) - Session recovery, lucky number, consent error and diagnostics
13
+
14
+ Signatures are unchanged; one error kind is added.
15
+
16
+ - Session recovery: for a session it no longer knows, Synergia answers every
17
+ page read with status 200 and a "Brak dostępu" page instead of a login
18
+ redirect (observed 2026-10-29). Up to 1.8.0 every read then failed as
19
+ `ParseError`, and kept failing until the client was recreated: the
20
+ profile's `parse_profile` failure a tester reported. Such a page now counts
21
+ as expiry: a read without an account effect signs in again and is repeated
22
+ once, view-selection POSTs included, since the page shows the request was
23
+ not applied. Opening a received message (mark-read) is not repeated; it
24
+ fails as `SessionExpiredError` and the next call signs in. A session that
25
+ has just signed in and gets the page, or a repeat that gets it again, fails
26
+ as `ParseError` and cools down for 60 seconds, so a page that stays does
27
+ not sign in on every call. Verified live by invalidating the session
28
+ cookies: student information, notification counts, grades, timetable,
29
+ attendance, homework and legacy messages each recovered with one login.
30
+ - Student information: the lucky number is read from the page header
31
+ (`span.luckyNumber`). Up to 1.8.0 the parser looked for an element ID the
32
+ page does not use, so `lucky_number` was always `UNAVAILABLE`. An empty
33
+ marker or "?" (reported where no number is drawn; neither observed) stays
34
+ `UNAVAILABLE` instead of failing the read.
35
+ - `describe_failure` summarizes a whole page held by the failing frame, after
36
+ the elements nearest the failure: body text length, Synergia menu and
37
+ header presence, title, headings, notices, forms and input types, and the
38
+ first tables row by row, instead of the start of the page head. Profile
39
+ labels, the login owner's row labels and known page notices are shown by
40
+ name, so a login or error page is told apart from a changed layout.
41
+ - `ErrorKind.CONSENT_REQUIRED` and `ConsentRequiredError`, a subclass of
42
+ `InvalidInputError`: opening a received message without
43
+ `allow_mark_read=True` raises it instead of a plain invalid input, so a
44
+ caller can ask the user rather than treat the message as unreadable.
45
+ `except InvalidInputError` still catches it; code comparing `kind` with
46
+ `INVALID_INPUT` sees the new kind.
47
+ - `DiagnosticEvent.http_status`: the HTTP status of the operation's last
48
+ checked read response, also logged by `logging_sink`. A read that fails as
49
+ `PARSE` at the response check now says whether it was a 2xx other than 200
50
+ or a 200 of another media type.
51
+ - `inspect_page profile FILE` checks a saved information page. A page source
52
+ saved as a macOS TextEdit document is named as such.
53
+
3
54
  ## 1.8.0 (2026-10-28) - Failure diagnostics and gateway descriptive grades
4
55
 
5
56
  Errors still carry only their kind; new helpers say where a failure happened
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: librus-python-api
3
- Version: 1.8.0
3
+ Version: 1.8.2
4
4
  Summary: Independent bounded async Librus Synergia client
5
5
  Project-URL: Repository, https://github.com/krzysztofbury/librus-python-api
6
6
  Project-URL: Issues, https://github.com/krzysztofbury/librus-python-api/issues
@@ -42,9 +42,13 @@ private ACLs. Windows installations include `pywin32` and `tzdata` automatically
42
42
  Network paths, reparse-point paths and unsafe storage permissions fail closed.
43
43
  See [platform requirements](contracts/persistence.md#windows-disk-boundary-25).
44
44
 
45
- **Status:** `1.8.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.2` keeps the modern mailbox session across a cancelled read
46
+ or a reached limit and sends its own version in the `User-Agent`. `1.8.1` recovers a session Synergia no longer knows (it answers
47
+ with a "Brak dostępu" page, not a login redirect), reads the lucky number
48
+ from the page header and summarizes a page that fails as a whole in
49
+ diagnostics. `1.8.0` reports where a failure
50
+ happened (code locations and a redacted page structure, see
51
+ [failure diagnostics](API.md#failure-diagnostics-180)), reads gateway descriptive grades for schools on the new descriptive schema
48
52
  (source-informed, first populated read live on one school) and keeps the 1.7.1 point
49
53
  grades and multi-line grade comments. It keeps the 1.6.4 linear-time message body
50
54
  rendering and the 1.6.2 completed-lesson and grade layouts, including behaviour
@@ -60,7 +64,7 @@ See [limitations](#supported-features-and-limitations) below.
60
64
  Install the exact release from PyPI after its gated publication completes:
61
65
 
62
66
  ```sh
63
- python -m pip install librus-python-api==1.8.0
67
+ python -m pip install librus-python-api==1.8.2
64
68
  ```
65
69
 
66
70
  Pin the exact version qualified by your application. Before publication, use a
@@ -76,7 +80,7 @@ python -m pip install .
76
80
  To install a locally built wheel instead:
77
81
 
78
82
  ```sh
79
- python -m pip install ./dist/librus_python_api-1.8.0-py3-none-any.whl
83
+ python -m pip install ./dist/librus_python_api-1.8.2-py3-none-any.whl
80
84
  ```
81
85
 
82
86
  No console script or background process is installed: import the library in your
@@ -280,7 +284,10 @@ except LibrusError as error:
280
284
 
281
285
  To check a saved page offline (a page source saved with Ctrl+U, or a copied
282
286
  table), run `python -m librus_python_api.inspect_page grades page.html` (or
283
- `final-grades`). It prints counts, or the failure's code locations and masked
287
+ `final-grades`, or `profile` for the information page `/informacja`). Save the
288
+ page source itself: in Safari use File > Save As with format Page Source; in
289
+ Chrome on macOS open it with Option+Cmd+U, then Cmd+S. A source view copied
290
+ into TextEdit is a text document, which `inspect_page` rejects. It prints counts, or the failure's code locations and masked
284
291
  structure, and makes no request. Pass a `diagnostic_sink` to `LibrusService`
285
292
  to receive one `DiagnosticEvent` per operation, with `frames` on failure.
286
293
  None of these print page text, values, logins or exception messages.
@@ -16,9 +16,13 @@ private ACLs. Windows installations include `pywin32` and `tzdata` automatically
16
16
  Network paths, reparse-point paths and unsafe storage permissions fail closed.
17
17
  See [platform requirements](contracts/persistence.md#windows-disk-boundary-25).
18
18
 
19
- **Status:** `1.8.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.2` keeps the modern mailbox session across a cancelled read
20
+ or a reached limit and sends its own version in the `User-Agent`. `1.8.1` recovers a session Synergia no longer knows (it answers
21
+ with a "Brak dostępu" page, not a login redirect), reads the lucky number
22
+ from the page header and summarizes a page that fails as a whole in
23
+ diagnostics. `1.8.0` reports where a failure
24
+ happened (code locations and a redacted page structure, see
25
+ [failure diagnostics](API.md#failure-diagnostics-180)), reads gateway descriptive grades for schools on the new descriptive schema
22
26
  (source-informed, first populated read live on one school) and keeps the 1.7.1 point
23
27
  grades and multi-line grade comments. It keeps the 1.6.4 linear-time message body
24
28
  rendering and the 1.6.2 completed-lesson and grade layouts, including behaviour
@@ -34,7 +38,7 @@ See [limitations](#supported-features-and-limitations) below.
34
38
  Install the exact release from PyPI after its gated publication completes:
35
39
 
36
40
  ```sh
37
- python -m pip install librus-python-api==1.8.0
41
+ python -m pip install librus-python-api==1.8.2
38
42
  ```
39
43
 
40
44
  Pin the exact version qualified by your application. Before publication, use a
@@ -50,7 +54,7 @@ python -m pip install .
50
54
  To install a locally built wheel instead:
51
55
 
52
56
  ```sh
53
- python -m pip install ./dist/librus_python_api-1.8.0-py3-none-any.whl
57
+ python -m pip install ./dist/librus_python_api-1.8.2-py3-none-any.whl
54
58
  ```
55
59
 
56
60
  No console script or background process is installed: import the library in your
@@ -254,7 +258,10 @@ except LibrusError as error:
254
258
 
255
259
  To check a saved page offline (a page source saved with Ctrl+U, or a copied
256
260
  table), run `python -m librus_python_api.inspect_page grades page.html` (or
257
- `final-grades`). It prints counts, or the failure's code locations and masked
261
+ `final-grades`, or `profile` for the information page `/informacja`). Save the
262
+ page source itself: in Safari use File > Save As with format Page Source; in
263
+ Chrome on macOS open it with Option+Cmd+U, then Cmd+S. A source view copied
264
+ into TextEdit is a text document, which `inspect_page` rejects. It prints counts, or the failure's code locations and masked
258
265
  structure, and makes no request. Pass a `diagnostic_sink` to `LibrusService`
259
266
  to receive one `DiagnosticEvent` per operation, with `frames` on failure.
260
267
  None of these print page text, values, logins or exception messages.
@@ -1,5 +1,8 @@
1
1
  """Independent bounded async client. General live compatibility is unqualified."""
2
2
 
3
+ # Before the imports: config reads it for the User-Agent.
4
+ __version__ = "1.8.2"
5
+
3
6
  from librus_python_api.attachments import AttachmentStream, ModernAttachmentStream
4
7
  from librus_python_api.budget import RequestBudget
5
8
  from librus_python_api.config import (
@@ -138,8 +141,6 @@ from librus_python_api.models import (
138
141
  from librus_python_api.sending import SendAttempt
139
142
  from librus_python_api.service import AccountClient, LibrusService
140
143
 
141
- __version__ = "1.8.0"
142
-
143
144
  __all__ = [
144
145
  "ArchiveAbsences",
145
146
  "ArchiveAchievement",
@@ -15,7 +15,10 @@ from librus_python_api.attachment_routes import (
15
15
  )
16
16
  from librus_python_api.budget import RequestBudget
17
17
  from librus_python_api.config import ATTACHMENT_MAX_BYTES
18
- from librus_python_api.exceptions import ErrorKind, LibrusError, SessionExpiredError
18
+ from librus_python_api.exceptions import (
19
+ ErrorKind,
20
+ LibrusError,
21
+ )
19
22
  from librus_python_api.lifecycle import join_owned
20
23
  from librus_python_api.models import (
21
24
  AttachmentHeaders,
@@ -283,9 +286,7 @@ class ModernAttachmentStream(AttachmentStream):
283
286
  budget,
284
287
  )
285
288
  except BaseException as error:
286
- client._invalidate_modern()
287
- if isinstance(error, SessionExpiredError):
288
- error._messages_origin = True
289
+ client._modern_read_failed(error)
289
290
  raise
290
291
  await client._transport.stream_download(
291
292
  key,
@@ -31,6 +31,7 @@ from pydantic import (
31
31
  model_validator,
32
32
  )
33
33
 
34
+ from librus_python_api import __version__
34
35
  from librus_python_api.exceptions import ErrorKind, LibrusError
35
36
  from librus_python_api.models import (
36
37
  AttendanceView,
@@ -113,7 +114,7 @@ UPSTREAM_ORIGINS = MappingProxyType(
113
114
  OAUTH_QUERY = (("client_id", "46"),)
114
115
  SESSION_COOKIE = "oauth_token"
115
116
  AUTH_COOKIES = frozenset({SESSION_COOKIE, "DZIENNIKSID", "SDZIENNIKSID"})
116
- USER_AGENT = "librus-python-api/0.4 (independent client)"
117
+ USER_AGENT = f"librus-python-api/{__version__} (independent client)"
117
118
  ENDPOINTS: Mapping[str, Endpoint] = MappingProxyType(
118
119
  {
119
120
  item.operation_id: item
@@ -435,7 +436,7 @@ ENDPOINTS: Mapping[str, Endpoint] = MappingProxyType(
435
436
  ),
436
437
  # Gateway reads behind the grades page's new-schema descriptive
437
438
  # table. Routes and field names come from public client code and the
438
- # official page script, not from an observed populated response.
439
+ # official page script; one authorized read parsed four records.
439
440
  Endpoint(
440
441
  "descriptive_token_info",
441
442
  "GET",
@@ -688,6 +689,21 @@ PROFILE_LABELS = MappingProxyType(
688
689
  "Szkoła": "school",
689
690
  }
690
691
  )
692
+ # Rows the information page lists for the login's own user after the
693
+ # student's rows (observed); never read, named by failure diagnostics.
694
+ PROFILE_OWNER_LABELS = ("Imię i nazwisko użytkownika", "Login", "Hasło")
695
+ # The page header shows the lucky number as `span.luckyNumber > b` (observed
696
+ # 2026-10-29). "?" there is reported where no number is drawn, for a school
697
+ # without lucky numbers and possibly on days without a draw; neither it nor
698
+ # an empty marker has been observed.
699
+ PROFILE_LUCKY_NUMBER_CLASS = "luckyNumber"
700
+ # For a session it no longer knows, Synergia answers every page read with 200
701
+ # and a page whose only heading is "Brak dostępu" (`h2.inside`), without the
702
+ # signed-in menu, not with a login redirect (observed 2026-10-29). A signed-in
703
+ # user's timetable page has no menu either, so the heading is required too.
704
+ LOGGED_IN_PAGE_MARKER = "graphic-menu"
705
+ LOGGED_OUT_HEADING = "Brak dostępu"
706
+ PROFILE_LUCKY_NUMBER_DISABLED = "?"
691
707
 
692
708
  # HTML summary headers omit the two leading body cells: expander and subject.
693
709
  # These labels/layout rules are source-informed requirements, not live evidence.
@@ -847,9 +863,11 @@ FORMATIVE_TEMPLATE_ID = "000000"
847
863
  FORMATIVE_MAX_TEXT_LENGTH = 65536
848
864
  ATTENDANCE_DETAIL_MAX_FIELDS = 32
849
865
  ATTENDANCE_METADATA_CACHE_SIZE = 256
850
- ATTENDANCE_RESULT_CACHE_SIZE = 64
851
866
  ATTENDANCE_METADATA_TTL_SECONDS = 3600
852
867
 
868
+ # Entries of every cached read of one account, oldest first out.
869
+ ACCOUNT_RESULT_CACHE_SIZE = 64
870
+
853
871
  ANNOUNCEMENT_MAX_ITEMS = 256
854
872
  ANNOUNCEMENT_MAX_FIELD_LENGTH = 1024
855
873
  ANNOUNCEMENT_MAX_CONTENT_LENGTH = 65536
@@ -30,6 +30,16 @@ _ELEMENT_LOCALS = 4
30
30
  _DEPTH = 6
31
31
  _NODES = 120
32
32
  _ELEMENT_BYTES = 8 * 1024
33
+ _PAGE_TABLES = 12
34
+ _PAGE_ROWS = 40
35
+ _PAGE_CELLS = 8
36
+ _PAGE_NOTICES = 4
37
+ # Input types a login, search or settings form uses; others are shown as "?".
38
+ _INPUT_TYPES = frozenset(
39
+ {"button", "checkbox", "hidden", "password", "radio", "submit", "text"}
40
+ )
41
+ # Element IDs whose presence tells a Synergia page from a login or error page.
42
+ _PAGE_MARKERS = ("graphic-menu", "user-section")
33
43
  _REPORT_BYTES = 32 * 1024
34
44
  # Markup observed on Synergia pages. Other class tokens, tags and attribute
35
45
  # names are shown only as lengths, since they could carry data.
@@ -45,6 +55,7 @@ _CLASSES = frozenset(
45
55
  "decorated",
46
56
  "detail-grades",
47
57
  "double",
58
+ "form",
48
59
  "gradesCell",
49
60
  "grade-box",
50
61
  "grade-box-mapping",
@@ -53,6 +64,7 @@ _CLASSES = frozenset(
53
64
  "legend",
54
65
  "line0",
55
66
  "line1",
67
+ "luckyNumber",
56
68
  "micro",
57
69
  "newSchemaGradesByStudentTable",
58
70
  "no-border-left",
@@ -65,6 +77,7 @@ _CLASSES = frozenset(
65
77
  "stretch",
66
78
  "studentRow",
67
79
  "tooltip",
80
+ "warning-content",
68
81
  }
69
82
  )
70
83
  _TAGS = frozenset(
@@ -164,6 +177,7 @@ def logging_sink(event: DiagnosticEvent) -> None:
164
177
  "budget_requests_dispatched": event.budget_requests_dispatched,
165
178
  "budget_response_bytes": event.budget_response_bytes,
166
179
  "frames": event.frames,
180
+ "http_status": event.http_status,
167
181
  },
168
182
  )
169
183
 
@@ -242,6 +256,9 @@ def _redaction_vocabulary() -> tuple[
242
256
  *config.GRADE_AVERAGE_HEADERS,
243
257
  }
244
258
  texts |= {label.casefold() for label in config.FORMATIVE_HEADERS}
259
+ profile = (*config.PROFILE_LABELS, *config.PROFILE_OWNER_LABELS)
260
+ texts |= set(profile) | {f"{label}:" for label in profile}
261
+ texts |= set(config.PAGE_NOTICES) | {config.LOGGED_OUT_HEADING}
245
262
  prefixes = tuple(
246
263
  sorted(
247
264
  (
@@ -398,13 +415,91 @@ def _path(element: html.HtmlElement, redactor: _Redactor) -> str:
398
415
  return " > ".join(reversed(steps))
399
416
 
400
417
 
418
+ def _cell(cell: html.HtmlElement, redactor: _Redactor) -> str:
419
+ spans = "".join(
420
+ f" {redactor.attribute(name, str(cell.get(name)))}"
421
+ for name in ("colspan", "rowspan")
422
+ if cell.get(name, "1") != "1"
423
+ )
424
+ text = redactor.text(cell.text_content()) or "EMPTY"
425
+ return f"{_tag(str(cell.tag))}{spans}[{text}]"
426
+
427
+
428
+ def _shown(label: str, texts: list[str]) -> str:
429
+ return " ".join([f" {label}: {len(texts)}", *texts[:_PAGE_NOTICES]])
430
+
431
+
432
+ def _page_summary(document: html.HtmlElement, redactor: _Redactor) -> list[str]:
433
+ """A whole page as its markers, forms, notices and tables, row by row.
434
+
435
+ A page that fails as a whole (no expected table, an unknown notice, a
436
+ login or error page) is told apart by these, not by its first elements.
437
+ Only the first tables, rows and cells are shown.
438
+ """
439
+ body = document.find("body")
440
+ text = " ".join((body if body is not None else document).text_content().split())
441
+ present = {
442
+ marker: document.get_element_by_id(marker, None) is not None
443
+ for marker in _PAGE_MARKERS
444
+ }
445
+ markers = " ".join(
446
+ f"{marker}={'yes' if found else 'no'}" for marker, found in present.items()
447
+ )
448
+ lines = [f" body text len={len(text)} {markers}"]
449
+ title = document.find(".//title")
450
+ titles = [] if title is None else [redactor.text(title.text_content()) or "EMPTY"]
451
+ lines.append(_shown("title", titles))
452
+ headings = [
453
+ redactor.text(heading.text_content()) or "EMPTY"
454
+ for heading in document.iter("h1", "h2", "h3")
455
+ ]
456
+ lines.append(_shown("headings", headings))
457
+ notices = document.xpath(
458
+ '//div[contains(concat(" ",normalize-space(@class)," ")," warning-content ")]'
459
+ )
460
+ shown = [redactor.text(notice.text_content()) or "EMPTY" for notice in notices]
461
+ lines.append(_shown("notices", shown))
462
+ forms = [redactor.href(form.get("action", "")) for form in document.iter("form")]
463
+ inputs: dict[str, int] = {}
464
+ for field in document.iter("input"):
465
+ kind = field.get("type", "text").casefold()
466
+ kind = kind if kind in _INPUT_TYPES else "?"
467
+ inputs[kind] = inputs.get(kind, 0) + 1
468
+ counted = " ".join(f"{kind}={count}" for kind, count in sorted(inputs.items()))
469
+ lines.append(_shown("forms", forms) + (f"; inputs {counted}" if counted else ""))
470
+ tables = list(document.iter("table"))
471
+ lines.append(f" tables: {len(tables)}")
472
+ for table in tables[:_PAGE_TABLES]:
473
+ rows = [
474
+ row
475
+ for row in table.iter("tr")
476
+ if next(row.iterancestors("table"), None) is table
477
+ ]
478
+ lines.append(f" {_path(table, redactor)}: {len(rows)} rows")
479
+ for row in rows[:_PAGE_ROWS]:
480
+ cells = [cell for cell in row if cell.tag in ("td", "th")]
481
+ more = " ..." if len(cells) > _PAGE_CELLS else ""
482
+ rendered = " ".join(_cell(cell, redactor) for cell in cells[:_PAGE_CELLS])
483
+ lines.append(f" {rendered}{more}")
484
+ if len(rows) > _PAGE_ROWS:
485
+ lines.append(" ...")
486
+ if len(tables) > _PAGE_TABLES:
487
+ lines.append(" ...")
488
+ return lines
489
+
490
+
491
+ def _is_page(element: html.HtmlElement) -> bool:
492
+ return element.getparent() is None and element.tag == "html"
493
+
494
+
401
495
  def describe_failure(error: BaseException) -> str | None:
402
496
  """The page structure around a failure, for a bug report.
403
497
 
404
498
  Shows the HTML elements held by the innermost library frame that holds
405
499
  any: tags, safe class tokens, known tooltip labels, known link families
406
- and lengths. 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):
@@ -124,7 +136,9 @@ class OperationTimeoutError(LibrusError):
124
136
 
125
137
 
126
138
  class LimitError(LibrusError):
127
- pass
139
+ # Library-private: the bound was the cookie count and the jar was emptied,
140
+ # so the session behind it is gone.
141
+ _cookies_cleared = False
128
142
 
129
143
 
130
144
  class ParseError(LibrusError):
@@ -154,6 +168,7 @@ class StorageError(LibrusError):
154
168
  _ERROR_TYPES: dict[ErrorKind, type[LibrusError]] = {
155
169
  ErrorKind.CHECKPOINT: CheckpointError,
156
170
  ErrorKind.INVALID_INPUT: InvalidInputError,
171
+ ErrorKind.CONSENT_REQUIRED: ConsentRequiredError,
157
172
  ErrorKind.CREDENTIALS_REJECTED: CredentialsRejectedError,
158
173
  ErrorKind.ACCOUNT_ACTION_REQUIRED: AccountActionRequiredError,
159
174
  ErrorKind.SESSION_EXPIRED: SessionExpiredError,
@@ -3,8 +3,9 @@
3
3
  The grades page of a school on the new descriptive grading schema renders a
4
4
  subject table whose semester cells are empty; the page script then loads the
5
5
  grades from gateway JSON routes. Routes and field names come from public client
6
- code and the official page script, not from an observed populated response,
7
- so every unexpected shape fails instead of producing a partial collection.
6
+ code and the official page script. One authorized read on 2026-10-28 parsed four
7
+ PartialGrades records on one school; pagination keys are still unknown, so every
8
+ unexpected shape fails instead of producing a partial collection.
8
9
  """
9
10
 
10
11
  import re