librus-python-api 1.7.1__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 (59) hide show
  1. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/API.md +93 -15
  2. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/CHANGELOG.md +95 -0
  3. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/PKG-INFO +43 -6
  4. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/README.md +42 -5
  5. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/__init__.py +1 -1
  6. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/config.py +104 -0
  7. librus_python_api-1.8.1/src/librus_python_api/diagnostics.py +546 -0
  8. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/exceptions.py +13 -0
  9. librus_python_api-1.8.1/src/librus_python_api/gateway_grades.py +458 -0
  10. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/grade_records.py +5 -1
  11. librus_python_api-1.8.1/src/librus_python_api/inspect_page.py +132 -0
  12. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/models.py +4 -0
  13. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/parsers.py +35 -5
  14. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/service.py +174 -20
  15. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/transport.py +20 -5
  16. librus_python_api-1.7.1/src/librus_python_api/diagnostics.py +0 -25
  17. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/.gitignore +0 -0
  18. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/LICENSE +0 -0
  19. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/SECURITY.md +0 -0
  20. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/pyproject.toml +0 -0
  21. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/_notification_bootstrap.py +0 -0
  22. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/_notification_codec.py +0 -0
  23. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/_storage.py +0 -0
  24. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/_windows_filesystem.py +0 -0
  25. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/announcements.py +0 -0
  26. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/attachment_routes.py +0 -0
  27. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/attachments.py +0 -0
  28. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/attendance.py +0 -0
  29. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/attendance_frequency.py +0 -0
  30. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/budget.py +0 -0
  31. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/checkpoint.py +0 -0
  32. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/class_free_days.py +0 -0
  33. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/completed_lessons.py +0 -0
  34. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/detail_fields.py +0 -0
  35. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/files.py +0 -0
  36. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/formative_grades.py +0 -0
  37. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/grade_parsers.py +0 -0
  38. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/homework_range.py +0 -0
  39. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/lifecycle.py +0 -0
  40. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/markup.py +0 -0
  41. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/message_content.py +0 -0
  42. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/messages.py +0 -0
  43. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/modern_body.py +0 -0
  44. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/modern_mailbox.py +0 -0
  45. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/modern_messages.py +0 -0
  46. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/notification_models.py +0 -0
  47. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/notification_persistence.py +0 -0
  48. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/notification_workflow.py +0 -0
  49. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/notifications.py +0 -0
  50. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/parsing.py +0 -0
  51. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/persistence.py +0 -0
  52. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/py.typed +0 -0
  53. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/recipients.py +0 -0
  54. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/scheduler.py +0 -0
  55. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/school_reads.py +0 -0
  56. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/school_year_archive.py +0 -0
  57. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/sending.py +0 -0
  58. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/src/librus_python_api/timetable.py +0 -0
  59. {librus_python_api-1.7.1 → librus_python_api-1.8.1}/uv.lock +0 -0
@@ -148,11 +148,47 @@ 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)`.
152
- `librus_python_api.diagnostics.logging_sink` emits allowlisted fields through
153
- the standard `logging` logger named `librus_python_api`. The application owns
154
- handlers and formatting; no logging dependency or global configuration is added. Sink errors
155
- never affect results.
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.
155
+ `frames` (1.8.0) holds the code locations of a `LibrusError` outcome, as below;
156
+ it is empty on success and for timeouts, cancellation and replaced
157
+ connection errors. `librus_python_api.diagnostics.logging_sink` emits allowlisted fields
158
+ through the standard `logging` logger named `librus_python_api`. The
159
+ application owns handlers and formatting; no logging dependency or global
160
+ configuration is added. Sink errors never affect results.
161
+
162
+ ### Failure diagnostics (1.8.0)
163
+
164
+ Errors carry only their kind, so these helpers read where a failure happened
165
+ from the exception's own traceback. Neither raises.
166
+
167
+ - `diagnostics.failure_frames(error, limit=6, packages=("librus_python_api",))`
168
+ returns code locations such as `("grade_records:_numeric:100",
169
+ "markup:tooltip_fields:113")`, the original failure site last. It follows an
170
+ explicit cause or a context suppressed with `from None`, and skips helpers
171
+ that only raise. The strings are opaque and change between releases; report
172
+ them with the library version.
173
+ - `diagnostics.describe_failure(error)` returns the page structure around the
174
+ failure, or None: the HTML elements held by the innermost library frame that
175
+ holds any, with their path from the grade table, tags, safe class tokens,
176
+ known tooltip labels (`config.TOOLTIP_LABELS`), known detail-link families and
177
+ lengths. Text and attribute values are masked unless they equal a parser
178
+ constant, so a failing tooltip reads like `[[Data], [Waga], NO-COLON(len=17)]`.
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
184
+ automatic upload.
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
190
+ library's parse size limit. Exit status: 0 parsed, 1 failed, 2 unreadable
191
+ file.
156
192
 
157
193
  All upstream routes and forms are fixed in `config.py` and documented in
158
194
  [contracts/upstream.openapi.yaml](contracts/upstream.openapi.yaml). There is no
@@ -165,7 +201,8 @@ arbitrary-URL method.
165
201
  reads it, so the first call costs no extra request.
166
202
  - `student_information()` returns `StudentInformation` with name, class, register
167
203
  number, tutor, school and `lucky_number`. `LuckyNumber.availability` is
168
- `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`.
169
206
 
170
207
  Each result carries an `Observation(account, observed_at, session_generation,
171
208
  source)` with an aware UTC timestamp.
@@ -178,7 +215,8 @@ source)` with an aware UTC timestamp.
178
215
  `UNAVAILABLE` means the column is absent.
179
216
  - `grades(view=GradeView.ALL)` returns `Grades(identity, records, observation,
180
217
  view)`. Selecting a view (`ALL`, `WEEK`, `LAST_LOGIN`) is one POST that changes
181
- 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:
182
220
  - `numeric`: `NumericGrade` with subject, raw symbol, civil `day`, semester,
183
221
  `kind` (`GradeKind`), and the optional count flag, weight, category, teacher,
184
222
  comment and tooltip `metadata`. Unknown weight or count is `None`, not zero.
@@ -249,12 +287,32 @@ such as `(0-100)`, not a grade symbol. The table's "Wyn." period results are
249
287
  into `averages`), and a point table with titled summary columns adds its
250
288
  subjects to `final_grades()`. See [point grades](contracts/grades.md#point-grades-170).
251
289
 
290
+ Gateway descriptive grades (1.8.0) belong to schools on the new
291
+ descriptive grading schema, whose grades page shows a subject table with empty
292
+ semester cells that the page script fills from the gateway. In the `ALL` view
293
+ only, `grades()` then reads those grades from the gateway with the login's
294
+ session and the call's budget: three more requests, five when there are
295
+ grades. They are `DescriptiveGrade`s in `descriptive` (kind `CURRENT`, no
296
+ `href`), with the page's subject name, the scale value or else the content as
297
+ `raw`, the civil date, semester 1 or 2, the teacher's name when listed, the
298
+ comment and metadata pairs for the present fields only (`Obszar`, `Treść`,
299
+ `Dodał`, `Komentarz`, `Skala`). `grades_window()` filters them like other
300
+ dated records. `WEEK` and `LAST_LOGIN` do not include them, because the page
301
+ narrows those views by a field whose meaning is not established; notifications
302
+ therefore do not report them. A gateway error, an unknown subject, a grade with
303
+ no value, an unexpected shape or a pagination member that shows more items
304
+ fails the whole read instead of returning it without these grades; a refused
305
+ gateway route (403) is `UNSUPPORTED_CAPABILITY`, so it does not put the other
306
+ grade views into the access-denied cooldown. The routes and field names are
307
+ source-informed; a first authorized live read parsed populated records on one
308
+ school's login. See [gateway descriptive grades](contracts/grades.md#gateway-descriptive-grades-180-issue-73).
309
+
252
310
  ## Attendance
253
311
 
254
312
  - `attendance(view=AttendanceView.ALL)` returns `Attendance` with
255
313
  `AttendanceRecord`s (symbol, day, semester, type, teacher, period, excursion
256
314
  flag, topic, subject, numeric `detail_id`, tooltip metadata) and the semesters
257
- 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)).
258
316
  - `attendance_window(start=None, end=None, view=AttendanceView.ALL)` filters the
259
317
  selected collection, with the same optional-date/370-day-difference rules.
260
318
  `AttendanceWindow.view` records the selection.
@@ -287,7 +345,8 @@ seven `TimetableDay`s. Each `TimetablePeriod` has a number, a local-time
287
345
  `changes` and the reported `next_recess`. A `TimetableChange` keeps the notice
288
346
  label (for example "zastępstwo") and its tooltip fields in order. The tooltip
289
347
  anchor may wrap the notice or sit inside it. No status is inferred from the
290
- 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)).
291
350
 
292
351
  ## Announcements
293
352
 
@@ -395,7 +454,8 @@ Each `MessageSummary` carries:
395
454
  Each text field is bounded to 4,096 characters, total page text to 256 KiB,
396
455
  in addition to shared body/parser/request limits. No body or attachment fetch,
397
456
  mark-read, delete, send or read-once operation occurs. Pagination changes the
398
- 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)).
399
459
  Page and batch caches are separate and fresh by default.
400
460
 
401
461
  In 0.4.11 a full 50-row page without pagination metadata is unsupported, not
@@ -892,7 +952,8 @@ be a `MessageReference` for the same login with a numeric ID and typed folder.
892
952
  Foreign accounts, arbitrary URLs and injected IDs fail before authentication.
893
953
 
894
954
  - Received opens require `allow_mark_read=True`, even for an already-read
895
- 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
896
957
  failure. Consent does not assert that upstream actually changed a read flag.
897
958
  - `may_mark_read` describes the route, not delivery or mutation confirmation.
898
959
  Sent opens do not require the opt-in. Neither folder is automatically replayed,
@@ -1025,7 +1086,7 @@ Limits: 32 group selectors, 2,048 recipients, 1,024 characters per label and
1025
1086
  128 KiB total recipient text, plus shared transport/parser/body limits. Each
1026
1087
  lookup has its own account/type/selection cache key and accepts the common budget and
1027
1088
  freshness parameters. Fresh reads are default. Both selection operations are
1028
- 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
1029
1090
  content, mark-read, attachment or read-once access occurs in recipient discovery.
1030
1091
 
1031
1092
  The empty group-option selector is observed. Populated choices, nonzero dispatch,
@@ -1041,7 +1102,8 @@ grades, attendance, messages, announcements, agenda and homework. A shown catego
1041
1102
  without a counter has zero; missing categories are not fabricated. Invalid or
1042
1103
  duplicate counters fail. The menu may be a token-scoped snapshot, not a fresh poll
1043
1104
  or the input for seen-state updates. This ordinary read accepts the common
1044
- 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)).
1045
1107
 
1046
1108
  ```python
1047
1109
  batch = await client.consume_schedule_events(
@@ -1098,6 +1160,7 @@ text. `error_for(kind)` builds one.
1098
1160
  | Error | Meaning |
1099
1161
  | --- | --- |
1100
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 |
1101
1164
  | `CredentialsRejectedError` | Login and password refused |
1102
1165
  | `AccountActionRequiredError` | CAPTCHA, 2FA or another interactive step is required |
1103
1166
  | `SessionExpiredError` | Proven expiry that could not be recovered |
@@ -1115,9 +1178,24 @@ text. `error_for(kind)` builds one.
1115
1178
 
1116
1179
  Recovery policy:
1117
1180
 
1118
- - 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.
1119
1188
  - Safe GET reads then log in once more and retry once, within the same budget.
1120
- - 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.
1121
1199
  - A denial or unrecovered expiry starts a 60-second cooldown for that operation
1122
1200
  on that login; a failed login starts one for the whole login.
1123
1201
  - 429 and 503 pause the whole scheduler, honouring `Retry-After` up to 24 hours.
@@ -1,5 +1,100 @@
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
+
45
+ ## 1.8.0 (2026-10-28) - Failure diagnostics and gateway descriptive grades
46
+
47
+ Errors still carry only their kind; new helpers say where a failure happened
48
+ without page data. Schools on the new descriptive grading schema get their
49
+ gateway grades.
50
+
51
+ ### Failure diagnostics
52
+
53
+ - `diagnostics.failure_frames(error)` returns the failure's code locations
54
+ (`module:function:line`, the original site last) from its traceback, through
55
+ the parser thread, re-raises and a context suppressed with `from None`.
56
+ - `diagnostics.describe_failure(error)` returns the page structure around the
57
+ failure: element path, tags, safe class tokens, known tooltip labels and
58
+ lengths, with every text and attribute value masked unless it equals a parser
59
+ constant. It names, for example, the unlabelled tooltip line that broke a
60
+ read.
61
+ - `python -m librus_python_api.inspect_page {grades,final-grades} FILE` parses a
62
+ saved page or copied table offline and prints counts or the failure report.
63
+ - `DiagnosticEvent.frames` carries the code locations to diagnostic sinks, and
64
+ `logging_sink` logs them.
65
+ - `config.TOOLTIP_LABELS` lists the tooltip labels diagnostics may show by name.
66
+
67
+ ### Gateway descriptive grades
68
+
69
+ Reads the grades of schools on the new descriptive grading schema, whose
70
+ grades page renders a subject table with empty semester cells and loads the
71
+ grades from gateway JSON routes. Routes and field names come from public client
72
+ code and the official page script. A first authorized live read on 2026-10-28
73
+ parsed four populated records on one school's login and showed the page
74
+ layout below (issue #73).
75
+
76
+ - Grades: in the `ALL` view, a page with the new-schema table adds its gateway
77
+ grades to `GradeRecords.descriptive` as `DescriptiveGrade` records, which
78
+ grade windows filter by date. The read makes three more requests, or five
79
+ when there are grades, under the same session and budget. Public types and
80
+ signatures are unchanged and `DescriptiveGrade` has no new field.
81
+ - Grades: `WEEK` and `LAST_LOGIN` make no gateway request and do not include
82
+ these grades, so notifications do not report them yet.
83
+ - Grades: a gateway error, unknown subject, grade without a value, malformed
84
+ or oversized response, unknown table layout or a pagination member showing
85
+ more items fails the whole all-view read on such a page, instead of
86
+ returning it without these grades. Pages without the table are unchanged.
87
+ A refused gateway route (403) is `UnsupportedCapabilityError`, so it does
88
+ not put the week and last-login views into the access-denied cooldown.
89
+ - Upstream contract: five source-informed gateway operations; the two that
90
+ take an account identifier accept `[A-Za-z0-9_-]{1,128}`, every other
91
+ `{id}` route stays numeric.
92
+ - Live check: the weekly grade checks and `scripts/live_capture.py` declare
93
+ the five gateway routes.
94
+ - The page may hold an unclosed, row-less table of the same class before the
95
+ real one, and the real table has a period header row, hidden details rows
96
+ and a footer; only a table with subject rows of its own is read.
97
+
3
98
  ## 1.7.1 (2026-10-27) - Observed point-grade page and multi-line comments
4
99
 
5
100
  Fixes for layouts 1.7.0 rejected with `ParseError`, established from an
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: librus-python-api
3
- Version: 1.7.1
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,8 +42,14 @@ 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.7.1` reads point grades ("Oceny punktowe") as an affected
46
- school's page shows them, and multi-line grade comments. It keeps the 1.6.4 linear-time message body
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
51
+ (source-informed, first populated read live on one school) and keeps the 1.7.1 point
52
+ grades and multi-line grade comments. It keeps the 1.6.4 linear-time message body
47
53
  rendering and the 1.6.2 completed-lesson and grade layouts, including behaviour
48
54
  points, which are validated and not reported as grades.
49
55
  Class free days, typed capability outcomes and the live-check freshness watchdog
@@ -57,7 +63,7 @@ See [limitations](#supported-features-and-limitations) below.
57
63
  Install the exact release from PyPI after its gated publication completes:
58
64
 
59
65
  ```sh
60
- python -m pip install librus-python-api==1.7.1
66
+ python -m pip install librus-python-api==1.8.1
61
67
  ```
62
68
 
63
69
  Pin the exact version qualified by your application. Before publication, use a
@@ -73,10 +79,12 @@ python -m pip install .
73
79
  To install a locally built wheel instead:
74
80
 
75
81
  ```sh
76
- python -m pip install ./dist/librus_python_api-1.7.1-py3-none-any.whl
82
+ python -m pip install ./dist/librus_python_api-1.8.1-py3-none-any.whl
77
83
  ```
78
84
 
79
- No CLI or background process is installed: import the library in your own program.
85
+ No console script or background process is installed: import the library in your
86
+ own program. `python -m librus_python_api.inspect_page` only parses a saved page
87
+ offline for a bug report.
80
88
 
81
89
  ## First request
82
90
 
@@ -254,6 +262,35 @@ unqualified; backend acceptance is not proof of delivery. Tests cover supported
254
262
  contracts, not every school or role. Detailed coverage is in the
255
263
  [verification log][verification]; open work is tracked in [issues][issues].
256
264
 
265
+ ## Diagnosing failures
266
+
267
+ Errors carry only their kind. To see where a failure happened without any page
268
+ data, use the helpers in `librus_python_api.diagnostics` (see
269
+ [API.md](API.md#failure-diagnostics-180)):
270
+
271
+ ```python
272
+ from librus_python_api.diagnostics import describe_failure, failure_frames
273
+ from librus_python_api.exceptions import LibrusError
274
+
275
+ try:
276
+ await client.grades()
277
+ except LibrusError as error:
278
+ print(
279
+ error.kind, failure_frames(error)
280
+ ) # ('grade_records:_numeric:100', 'markup:tooltip_fields:113')
281
+ print(describe_failure(error)) # masked HTML structure around the failure
282
+ ```
283
+
284
+ To check a saved page offline (a page source saved with Ctrl+U, or a copied
285
+ table), run `python -m librus_python_api.inspect_page grades page.html` (or
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
290
+ structure, and makes no request. Pass a `diagnostic_sink` to `LibrusService`
291
+ to receive one `DiagnosticEvent` per operation, with `frames` on failure.
292
+ None of these print page text, values, logins or exception messages.
293
+
257
294
  ## Development and support
258
295
 
259
296
  Use a repository checkout for tests and development tools; they are deliberately
@@ -16,8 +16,14 @@ 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.7.1` reads point grades ("Oceny punktowe") as an affected
20
- school's page shows them, and multi-line grade comments. It keeps the 1.6.4 linear-time message body
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
25
+ (source-informed, first populated read live on one school) and keeps the 1.7.1 point
26
+ grades and multi-line grade comments. It keeps the 1.6.4 linear-time message body
21
27
  rendering and the 1.6.2 completed-lesson and grade layouts, including behaviour
22
28
  points, which are validated and not reported as grades.
23
29
  Class free days, typed capability outcomes and the live-check freshness watchdog
@@ -31,7 +37,7 @@ See [limitations](#supported-features-and-limitations) below.
31
37
  Install the exact release from PyPI after its gated publication completes:
32
38
 
33
39
  ```sh
34
- python -m pip install librus-python-api==1.7.1
40
+ python -m pip install librus-python-api==1.8.1
35
41
  ```
36
42
 
37
43
  Pin the exact version qualified by your application. Before publication, use a
@@ -47,10 +53,12 @@ python -m pip install .
47
53
  To install a locally built wheel instead:
48
54
 
49
55
  ```sh
50
- python -m pip install ./dist/librus_python_api-1.7.1-py3-none-any.whl
56
+ python -m pip install ./dist/librus_python_api-1.8.1-py3-none-any.whl
51
57
  ```
52
58
 
53
- No CLI or background process is installed: import the library in your own program.
59
+ No console script or background process is installed: import the library in your
60
+ own program. `python -m librus_python_api.inspect_page` only parses a saved page
61
+ offline for a bug report.
54
62
 
55
63
  ## First request
56
64
 
@@ -228,6 +236,35 @@ unqualified; backend acceptance is not proof of delivery. Tests cover supported
228
236
  contracts, not every school or role. Detailed coverage is in the
229
237
  [verification log][verification]; open work is tracked in [issues][issues].
230
238
 
239
+ ## Diagnosing failures
240
+
241
+ Errors carry only their kind. To see where a failure happened without any page
242
+ data, use the helpers in `librus_python_api.diagnostics` (see
243
+ [API.md](API.md#failure-diagnostics-180)):
244
+
245
+ ```python
246
+ from librus_python_api.diagnostics import describe_failure, failure_frames
247
+ from librus_python_api.exceptions import LibrusError
248
+
249
+ try:
250
+ await client.grades()
251
+ except LibrusError as error:
252
+ print(
253
+ error.kind, failure_frames(error)
254
+ ) # ('grade_records:_numeric:100', 'markup:tooltip_fields:113')
255
+ print(describe_failure(error)) # masked HTML structure around the failure
256
+ ```
257
+
258
+ To check a saved page offline (a page source saved with Ctrl+U, or a copied
259
+ table), run `python -m librus_python_api.inspect_page grades page.html` (or
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
264
+ structure, and makes no request. Pass a `diagnostic_sink` to `LibrusService`
265
+ to receive one `DiagnosticEvent` per operation, with `frames` on failure.
266
+ None of these print page text, values, logins or exception messages.
267
+
231
268
  ## Development and support
232
269
 
233
270
  Use a repository checkout for tests and development tools; they are deliberately
@@ -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.7.1"
141
+ __version__ = "1.8.1"
142
142
 
143
143
  __all__ = [
144
144
  "ArchiveAbsences",
@@ -433,6 +433,51 @@ ENDPOINTS: Mapping[str, Endpoint] = MappingProxyType(
433
433
  False,
434
434
  Evidence.INDEPENDENTLY_OBSERVED,
435
435
  ),
436
+ # Gateway reads behind the grades page's new-schema descriptive
437
+ # table. Routes and field names come from public client code and the
438
+ # official page script, not from an observed populated response.
439
+ Endpoint(
440
+ "descriptive_token_info",
441
+ "GET",
442
+ "/gateway/api/2.0/Auth/TokenInfo",
443
+ SideEffect.NONE,
444
+ True,
445
+ Evidence.SOURCE_INFORMED,
446
+ ),
447
+ Endpoint(
448
+ "descriptive_user_info",
449
+ "GET",
450
+ "/gateway/api/2.0/Auth/UserInfo/{id}",
451
+ SideEffect.NONE,
452
+ True,
453
+ Evidence.SOURCE_INFORMED,
454
+ ),
455
+ # A read sent as POST with the fixed JSON body `{}`; it is never
456
+ # replayed, although it changes nothing that is known.
457
+ Endpoint(
458
+ "descriptive_partial_grades",
459
+ "POST",
460
+ "/gateway/api/2.0/Auth/DescriptiveGradingSystem/PartialGrades/Student/{id}",
461
+ SideEffect.NONE,
462
+ False,
463
+ Evidence.SOURCE_INFORMED,
464
+ ),
465
+ Endpoint(
466
+ "descriptive_subjects",
467
+ "GET",
468
+ "/gateway/api/3.0/Auth/Subjects",
469
+ SideEffect.NONE,
470
+ True,
471
+ Evidence.SOURCE_INFORMED,
472
+ ),
473
+ Endpoint(
474
+ "descriptive_users",
475
+ "GET",
476
+ "/gateway/api/2.0/Users",
477
+ SideEffect.NONE,
478
+ True,
479
+ Evidence.SOURCE_INFORMED,
480
+ ),
436
481
  Endpoint(
437
482
  "attendance",
438
483
  "POST",
@@ -643,6 +688,21 @@ PROFILE_LABELS = MappingProxyType(
643
688
  "Szkoła": "school",
644
689
  }
645
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 = "?"
646
706
 
647
707
  # HTML summary headers omit the two leading body cells: expander and subject.
648
708
  # These labels/layout rules are source-informed requirements, not live evidence.
@@ -662,6 +722,35 @@ GRADE_INLINE_DETAIL_LABEL = "Ocena"
662
722
  # Tooltip fields whose free text may continue on unlabelled lines. A teacher's
663
723
  # multi-line grade comment was observed on 2026-10-27.
664
724
  TOOLTIP_MULTILINE_LABELS = frozenset({"Komentarz"})
725
+ # Tooltip labels observed on grade, attendance and point pages. Diagnostics
726
+ # show only these by name; any other label is masked as teacher-entered text.
727
+ TOOLTIP_LABELS = frozenset(
728
+ {
729
+ "Czy wycieczka",
730
+ "Data",
731
+ "Data realizacji",
732
+ "Data wystawienia",
733
+ "Data zapowiedzi",
734
+ "Dodał",
735
+ "Godzina lekcyjna",
736
+ "Kategoria",
737
+ "Komentarz",
738
+ "Lekcja",
739
+ "Licz do puli",
740
+ "Licz do wyniku",
741
+ "Licz do średniej",
742
+ "Maksymalna ilość punktów",
743
+ "Nauczyciel",
744
+ "Obowiązek wyk. zadania",
745
+ "Ocena",
746
+ "Rodzaj",
747
+ "Suma punktów",
748
+ "Temat zajęć",
749
+ "Waga",
750
+ "Zakres do",
751
+ "Zakres od",
752
+ }
753
+ )
665
754
  # A point-grade ("Oceny punktowe") table marks each subject row's expander image
666
755
  # with this ID; ordinary grade rows use `przedmioty_<id>_node`.
667
756
  GRADE_POINT_ROW_NODE = re.compile(r"przedmioty_OP_[0-9A-Za-z_]{1,64}_node")
@@ -693,6 +782,21 @@ GRADE_PERIOD_HEADERS = MappingProxyType(
693
782
  "Ocena roczna": 0,
694
783
  }
695
784
  )
785
+ # New-schema descriptive grading: the page renders this table with empty
786
+ # semester cells and its script loads the grades from the gateway routes above.
787
+ DESCRIPTIVE_TABLE_CLASS = "newSchemaGradesByStudentTable"
788
+ DESCRIPTIVE_ROW_CLASS = "studentRow"
789
+ # Gateway account and student identifiers are opaque strings, unlike every
790
+ # other `{id}` route, which takes a numeric ID.
791
+ DESCRIPTIVE_IDENTIFIER = re.compile(r"[A-Za-z0-9_-]{1,128}")
792
+ DESCRIPTIVE_IDENTIFIER_OPERATIONS = frozenset(
793
+ {"descriptive_user_info", "descriptive_partial_grades"}
794
+ )
795
+ # The PartialGrades read is a POST whose only body is this fixed JSON object.
796
+ DESCRIPTIVE_PARTIAL_GRADES_BODY = b"{}"
797
+ DESCRIPTIVE_MAX_SUBJECTS = 1024
798
+ DESCRIPTIVE_MAX_USERS = 8192
799
+ DESCRIPTIVE_MAX_NAME_LENGTH = 1024
696
800
  GRADE_PREDICTED_ANNUAL_HEADER = "Przewidywana ocena roczna"
697
801
  GRADE_PREDICTED_PERIOD_HEADERS = MappingProxyType(
698
802
  {