librus-python-api 1.7.0__tar.gz → 1.8.0__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.0 → librus_python_api-1.8.0}/API.md +52 -5
  2. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/CHANGELOG.md +71 -0
  3. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/PKG-INFO +37 -6
  4. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/README.md +36 -5
  5. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/__init__.py +1 -1
  6. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/config.py +92 -0
  7. librus_python_api-1.8.0/src/librus_python_api/diagnostics.py +444 -0
  8. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/formative_grades.py +6 -1
  9. librus_python_api-1.8.0/src/librus_python_api/gateway_grades.py +458 -0
  10. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/grade_parsers.py +28 -8
  11. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/grade_records.py +22 -12
  12. librus_python_api-1.8.0/src/librus_python_api/inspect_page.py +114 -0
  13. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/markup.py +11 -1
  14. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/models.py +2 -0
  15. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/service.py +94 -4
  16. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/transport.py +20 -5
  17. librus_python_api-1.7.0/src/librus_python_api/diagnostics.py +0 -25
  18. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/.gitignore +0 -0
  19. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/LICENSE +0 -0
  20. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/SECURITY.md +0 -0
  21. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/pyproject.toml +0 -0
  22. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/_notification_bootstrap.py +0 -0
  23. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/_notification_codec.py +0 -0
  24. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/_storage.py +0 -0
  25. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/_windows_filesystem.py +0 -0
  26. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/announcements.py +0 -0
  27. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/attachment_routes.py +0 -0
  28. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/attachments.py +0 -0
  29. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/attendance.py +0 -0
  30. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/attendance_frequency.py +0 -0
  31. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/budget.py +0 -0
  32. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/checkpoint.py +0 -0
  33. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/class_free_days.py +0 -0
  34. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/completed_lessons.py +0 -0
  35. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/detail_fields.py +0 -0
  36. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/exceptions.py +0 -0
  37. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/files.py +0 -0
  38. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/homework_range.py +0 -0
  39. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/lifecycle.py +0 -0
  40. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/message_content.py +0 -0
  41. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/messages.py +0 -0
  42. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/modern_body.py +0 -0
  43. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/modern_mailbox.py +0 -0
  44. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/modern_messages.py +0 -0
  45. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/notification_models.py +0 -0
  46. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/notification_persistence.py +0 -0
  47. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/notification_workflow.py +0 -0
  48. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/notifications.py +0 -0
  49. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/parsers.py +0 -0
  50. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/parsing.py +0 -0
  51. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/persistence.py +0 -0
  52. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/py.typed +0 -0
  53. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/recipients.py +0 -0
  54. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/scheduler.py +0 -0
  55. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/school_reads.py +0 -0
  56. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/school_year_archive.py +0 -0
  57. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/sending.py +0 -0
  58. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/src/librus_python_api/timetable.py +0 -0
  59. {librus_python_api-1.7.0 → librus_python_api-1.8.0}/uv.lock +0 -0
@@ -148,11 +148,38 @@ 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
+ `frames` (1.8.0) holds the code locations of a `LibrusError` outcome, as below;
153
+ it is empty on success and for timeouts, cancellation and replaced
154
+ connection errors. `librus_python_api.diagnostics.logging_sink` emits allowlisted fields
155
+ through the standard `logging` logger named `librus_python_api`. The
156
+ application owns handlers and formatting; no logging dependency or global
157
+ configuration is added. Sink errors never affect results.
158
+
159
+ ### Failure diagnostics (1.8.0)
160
+
161
+ Errors carry only their kind, so these helpers read where a failure happened
162
+ from the exception's own traceback. Neither raises.
163
+
164
+ - `diagnostics.failure_frames(error, limit=6, packages=("librus_python_api",))`
165
+ returns code locations such as `("grade_records:_numeric:100",
166
+ "markup:tooltip_fields:113")`, the original failure site last. It follows an
167
+ explicit cause or a context suppressed with `from None`, and skips helpers
168
+ that only raise. The strings are opaque and change between releases; report
169
+ them with the library version.
170
+ - `diagnostics.describe_failure(error)` returns the page structure around the
171
+ failure, or None: the HTML elements held by the innermost library frame that
172
+ holds any, with their path from the grade table, tags, safe class tokens,
173
+ known tooltip labels (`config.TOOLTIP_LABELS`), known detail-link families and
174
+ lengths. Text and attribute values are masked unless they equal a parser
175
+ 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
177
+ 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
181
+ library's parse size limit. Exit status: 0 parsed, 1 failed, 2 unreadable
182
+ file.
156
183
 
157
184
  All upstream routes and forms are fixed in `config.py` and documented in
158
185
  [contracts/upstream.openapi.yaml](contracts/upstream.openapi.yaml). There is no
@@ -249,6 +276,26 @@ such as `(0-100)`, not a grade symbol. The table's "Wyn." period results are
249
276
  into `averages`), and a point table with titled summary columns adds its
250
277
  subjects to `final_grades()`. See [point grades](contracts/grades.md#point-grades-170).
251
278
 
279
+ Gateway descriptive grades (1.8.0) belong to schools on the new
280
+ descriptive grading schema, whose grades page shows a subject table with empty
281
+ semester cells that the page script fills from the gateway. In the `ALL` view
282
+ only, `grades()` then reads those grades from the gateway with the login's
283
+ session and the call's budget: three more requests, five when there are
284
+ grades. They are `DescriptiveGrade`s in `descriptive` (kind `CURRENT`, no
285
+ `href`), with the page's subject name, the scale value or else the content as
286
+ `raw`, the civil date, semester 1 or 2, the teacher's name when listed, the
287
+ comment and metadata pairs for the present fields only (`Obszar`, `Treść`,
288
+ `Dodał`, `Komentarz`, `Skala`). `grades_window()` filters them like other
289
+ dated records. `WEEK` and `LAST_LOGIN` do not include them, because the page
290
+ narrows those views by a field whose meaning is not established; notifications
291
+ therefore do not report them. A gateway error, an unknown subject, a grade with
292
+ no value, an unexpected shape or a pagination member that shows more items
293
+ fails the whole read instead of returning it without these grades; a refused
294
+ gateway route (403) is `UNSUPPORTED_CAPABILITY`, so it does not put the other
295
+ grade views into the access-denied cooldown. The routes and field names are
296
+ source-informed; a first authorized live read parsed populated records on one
297
+ school's login. See [gateway descriptive grades](contracts/grades.md#gateway-descriptive-grades-180-issue-73).
298
+
252
299
  ## Attendance
253
300
 
254
301
  - `attendance(view=AttendanceView.ALL)` returns `Attendance` with
@@ -1,5 +1,76 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.8.0 (2026-10-28) - Failure diagnostics and gateway descriptive grades
4
+
5
+ Errors still carry only their kind; new helpers say where a failure happened
6
+ without page data. Schools on the new descriptive grading schema get their
7
+ gateway grades.
8
+
9
+ ### Failure diagnostics
10
+
11
+ - `diagnostics.failure_frames(error)` returns the failure's code locations
12
+ (`module:function:line`, the original site last) from its traceback, through
13
+ the parser thread, re-raises and a context suppressed with `from None`.
14
+ - `diagnostics.describe_failure(error)` returns the page structure around the
15
+ failure: element path, tags, safe class tokens, known tooltip labels and
16
+ lengths, with every text and attribute value masked unless it equals a parser
17
+ constant. It names, for example, the unlabelled tooltip line that broke a
18
+ read.
19
+ - `python -m librus_python_api.inspect_page {grades,final-grades} FILE` parses a
20
+ saved page or copied table offline and prints counts or the failure report.
21
+ - `DiagnosticEvent.frames` carries the code locations to diagnostic sinks, and
22
+ `logging_sink` logs them.
23
+ - `config.TOOLTIP_LABELS` lists the tooltip labels diagnostics may show by name.
24
+
25
+ ### Gateway descriptive grades
26
+
27
+ Reads the grades of schools on the new descriptive grading schema, whose
28
+ grades page renders a subject table with empty semester cells and loads the
29
+ grades from gateway JSON routes. Routes and field names come from public client
30
+ code and the official page script. A first authorized live read on 2026-10-28
31
+ parsed four populated records on one school's login and showed the page
32
+ layout below (issue #73).
33
+
34
+ - Grades: in the `ALL` view, a page with the new-schema table adds its gateway
35
+ grades to `GradeRecords.descriptive` as `DescriptiveGrade` records, which
36
+ grade windows filter by date. The read makes three more requests, or five
37
+ when there are grades, under the same session and budget. Public types and
38
+ signatures are unchanged and `DescriptiveGrade` has no new field.
39
+ - Grades: `WEEK` and `LAST_LOGIN` make no gateway request and do not include
40
+ these grades, so notifications do not report them yet.
41
+ - Grades: a gateway error, unknown subject, grade without a value, malformed
42
+ or oversized response, unknown table layout or a pagination member showing
43
+ more items fails the whole all-view read on such a page, instead of
44
+ returning it without these grades. Pages without the table are unchanged.
45
+ A refused gateway route (403) is `UnsupportedCapabilityError`, so it does
46
+ not put the week and last-login views into the access-denied cooldown.
47
+ - Upstream contract: five source-informed gateway operations; the two that
48
+ take an account identifier accept `[A-Za-z0-9_-]{1,128}`, every other
49
+ `{id}` route stays numeric.
50
+ - Live check: the weekly grade checks and `scripts/live_capture.py` declare
51
+ the five gateway routes.
52
+ - The page may hold an unclosed, row-less table of the same class before the
53
+ real one, and the real table has a period header row, hidden details rows
54
+ and a footer; only a table with subject rows of its own is read.
55
+
56
+ ## 1.7.1 (2026-10-27) - Observed point-grade page and multi-line comments
57
+
58
+ Fixes for layouts 1.7.0 rejected with `ParseError`, established from an
59
+ affected school's real point-grade page and a live grade read. Public types
60
+ and signatures are unchanged.
61
+
62
+ - Grades: a teacher's multi-line `Komentarz` continues on unlabelled tooltip
63
+ lines and is kept with its line breaks. Every grade read of an account with
64
+ such a comment failed in all releases up to 1.7.0; any other unlabelled
65
+ tooltip line still fails.
66
+ - Point grades: a formative entry among the point entries is accepted as the
67
+ formative mirror it is; the empty footer row wider than the table is
68
+ skipped; titles that break their own label (`Ocena śródroczna <br> z
69
+ pierwszego okresu`) name the period columns, so point midterm grades are
70
+ read. These rules apply to point tables only.
71
+ - Formative assessments: assessment text may contain line breaks. 1.7.0 and
72
+ earlier failed such a table with `UnsupportedCapabilityError`.
73
+
3
74
  ## 1.7.0 (2026-10-27) - Point grades
4
75
 
5
76
  Reads the "Oceny punktowe" table that point-grade schools show on the grades
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: librus-python-api
3
- Version: 1.7.0
3
+ Version: 1.8.0
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,11 @@ 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.0` reads point grades ("Oceny punktowe"), which made both grade
46
- reads fail at point-grade schools. It keeps the 1.6.4 linear-time message body
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
48
+ (source-informed, first populated read live on one school) and keeps the 1.7.1 point
49
+ grades and multi-line grade comments. It keeps the 1.6.4 linear-time message body
47
50
  rendering and the 1.6.2 completed-lesson and grade layouts, including behaviour
48
51
  points, which are validated and not reported as grades.
49
52
  Class free days, typed capability outcomes and the live-check freshness watchdog
@@ -57,7 +60,7 @@ See [limitations](#supported-features-and-limitations) below.
57
60
  Install the exact release from PyPI after its gated publication completes:
58
61
 
59
62
  ```sh
60
- python -m pip install librus-python-api==1.7.0
63
+ python -m pip install librus-python-api==1.8.0
61
64
  ```
62
65
 
63
66
  Pin the exact version qualified by your application. Before publication, use a
@@ -73,10 +76,12 @@ python -m pip install .
73
76
  To install a locally built wheel instead:
74
77
 
75
78
  ```sh
76
- python -m pip install ./dist/librus_python_api-1.7.0-py3-none-any.whl
79
+ python -m pip install ./dist/librus_python_api-1.8.0-py3-none-any.whl
77
80
  ```
78
81
 
79
- No CLI or background process is installed: import the library in your own program.
82
+ No console script or background process is installed: import the library in your
83
+ own program. `python -m librus_python_api.inspect_page` only parses a saved page
84
+ offline for a bug report.
80
85
 
81
86
  ## First request
82
87
 
@@ -254,6 +259,32 @@ unqualified; backend acceptance is not proof of delivery. Tests cover supported
254
259
  contracts, not every school or role. Detailed coverage is in the
255
260
  [verification log][verification]; open work is tracked in [issues][issues].
256
261
 
262
+ ## Diagnosing failures
263
+
264
+ Errors carry only their kind. To see where a failure happened without any page
265
+ data, use the helpers in `librus_python_api.diagnostics` (see
266
+ [API.md](API.md#failure-diagnostics-180)):
267
+
268
+ ```python
269
+ from librus_python_api.diagnostics import describe_failure, failure_frames
270
+ from librus_python_api.exceptions import LibrusError
271
+
272
+ try:
273
+ await client.grades()
274
+ except LibrusError as error:
275
+ print(
276
+ error.kind, failure_frames(error)
277
+ ) # ('grade_records:_numeric:100', 'markup:tooltip_fields:113')
278
+ print(describe_failure(error)) # masked HTML structure around the failure
279
+ ```
280
+
281
+ To check a saved page offline (a page source saved with Ctrl+U, or a copied
282
+ 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
284
+ structure, and makes no request. Pass a `diagnostic_sink` to `LibrusService`
285
+ to receive one `DiagnosticEvent` per operation, with `frames` on failure.
286
+ None of these print page text, values, logins or exception messages.
287
+
257
288
  ## Development and support
258
289
 
259
290
  Use a repository checkout for tests and development tools; they are deliberately
@@ -16,8 +16,11 @@ 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.0` reads point grades ("Oceny punktowe"), which made both grade
20
- reads fail at point-grade schools. It keeps the 1.6.4 linear-time message body
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
22
+ (source-informed, first populated read live on one school) and keeps the 1.7.1 point
23
+ grades and multi-line grade comments. It keeps the 1.6.4 linear-time message body
21
24
  rendering and the 1.6.2 completed-lesson and grade layouts, including behaviour
22
25
  points, which are validated and not reported as grades.
23
26
  Class free days, typed capability outcomes and the live-check freshness watchdog
@@ -31,7 +34,7 @@ See [limitations](#supported-features-and-limitations) below.
31
34
  Install the exact release from PyPI after its gated publication completes:
32
35
 
33
36
  ```sh
34
- python -m pip install librus-python-api==1.7.0
37
+ python -m pip install librus-python-api==1.8.0
35
38
  ```
36
39
 
37
40
  Pin the exact version qualified by your application. Before publication, use a
@@ -47,10 +50,12 @@ python -m pip install .
47
50
  To install a locally built wheel instead:
48
51
 
49
52
  ```sh
50
- python -m pip install ./dist/librus_python_api-1.7.0-py3-none-any.whl
53
+ python -m pip install ./dist/librus_python_api-1.8.0-py3-none-any.whl
51
54
  ```
52
55
 
53
- No CLI or background process is installed: import the library in your own program.
56
+ No console script or background process is installed: import the library in your
57
+ own program. `python -m librus_python_api.inspect_page` only parses a saved page
58
+ offline for a bug report.
54
59
 
55
60
  ## First request
56
61
 
@@ -228,6 +233,32 @@ unqualified; backend acceptance is not proof of delivery. Tests cover supported
228
233
  contracts, not every school or role. Detailed coverage is in the
229
234
  [verification log][verification]; open work is tracked in [issues][issues].
230
235
 
236
+ ## Diagnosing failures
237
+
238
+ Errors carry only their kind. To see where a failure happened without any page
239
+ data, use the helpers in `librus_python_api.diagnostics` (see
240
+ [API.md](API.md#failure-diagnostics-180)):
241
+
242
+ ```python
243
+ from librus_python_api.diagnostics import describe_failure, failure_frames
244
+ from librus_python_api.exceptions import LibrusError
245
+
246
+ try:
247
+ await client.grades()
248
+ except LibrusError as error:
249
+ print(
250
+ error.kind, failure_frames(error)
251
+ ) # ('grade_records:_numeric:100', 'markup:tooltip_fields:113')
252
+ print(describe_failure(error)) # masked HTML structure around the failure
253
+ ```
254
+
255
+ To check a saved page offline (a page source saved with Ctrl+U, or a copied
256
+ 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
258
+ structure, and makes no request. Pass a `diagnostic_sink` to `LibrusService`
259
+ to receive one `DiagnosticEvent` per operation, with `frames` on failure.
260
+ None of these print page text, values, logins or exception messages.
261
+
231
262
  ## Development and support
232
263
 
233
264
  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.0"
141
+ __version__ = "1.8.0"
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",
@@ -659,6 +704,38 @@ GRADE_MAX_SUBJECTS = 128
659
704
  GRADE_MAX_VALUE_LENGTH = 1024
660
705
  GRADE_MERGED_SUBJECTS = frozenset({"Zachowanie"})
661
706
  GRADE_INLINE_DETAIL_LABEL = "Ocena"
707
+ # Tooltip fields whose free text may continue on unlabelled lines. A teacher's
708
+ # multi-line grade comment was observed on 2026-10-27.
709
+ TOOLTIP_MULTILINE_LABELS = frozenset({"Komentarz"})
710
+ # Tooltip labels observed on grade, attendance and point pages. Diagnostics
711
+ # show only these by name; any other label is masked as teacher-entered text.
712
+ TOOLTIP_LABELS = frozenset(
713
+ {
714
+ "Czy wycieczka",
715
+ "Data",
716
+ "Data realizacji",
717
+ "Data wystawienia",
718
+ "Data zapowiedzi",
719
+ "Dodał",
720
+ "Godzina lekcyjna",
721
+ "Kategoria",
722
+ "Komentarz",
723
+ "Lekcja",
724
+ "Licz do puli",
725
+ "Licz do wyniku",
726
+ "Licz do średniej",
727
+ "Maksymalna ilość punktów",
728
+ "Nauczyciel",
729
+ "Obowiązek wyk. zadania",
730
+ "Ocena",
731
+ "Rodzaj",
732
+ "Suma punktów",
733
+ "Temat zajęć",
734
+ "Waga",
735
+ "Zakres do",
736
+ "Zakres od",
737
+ }
738
+ )
662
739
  # A point-grade ("Oceny punktowe") table marks each subject row's expander image
663
740
  # with this ID; ordinary grade rows use `przedmioty_<id>_node`.
664
741
  GRADE_POINT_ROW_NODE = re.compile(r"przedmioty_OP_[0-9A-Za-z_]{1,64}_node")
@@ -690,6 +767,21 @@ GRADE_PERIOD_HEADERS = MappingProxyType(
690
767
  "Ocena roczna": 0,
691
768
  }
692
769
  )
770
+ # New-schema descriptive grading: the page renders this table with empty
771
+ # semester cells and its script loads the grades from the gateway routes above.
772
+ DESCRIPTIVE_TABLE_CLASS = "newSchemaGradesByStudentTable"
773
+ DESCRIPTIVE_ROW_CLASS = "studentRow"
774
+ # Gateway account and student identifiers are opaque strings, unlike every
775
+ # other `{id}` route, which takes a numeric ID.
776
+ DESCRIPTIVE_IDENTIFIER = re.compile(r"[A-Za-z0-9_-]{1,128}")
777
+ DESCRIPTIVE_IDENTIFIER_OPERATIONS = frozenset(
778
+ {"descriptive_user_info", "descriptive_partial_grades"}
779
+ )
780
+ # The PartialGrades read is a POST whose only body is this fixed JSON object.
781
+ DESCRIPTIVE_PARTIAL_GRADES_BODY = b"{}"
782
+ DESCRIPTIVE_MAX_SUBJECTS = 1024
783
+ DESCRIPTIVE_MAX_USERS = 8192
784
+ DESCRIPTIVE_MAX_NAME_LENGTH = 1024
693
785
  GRADE_PREDICTED_ANNUAL_HEADER = "Przewidywana ocena roczna"
694
786
  GRADE_PREDICTED_PERIOD_HEADERS = MappingProxyType(
695
787
  {