librus-python-api 1.8.2__tar.gz → 1.8.4__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.2 → librus_python_api-1.8.4}/API.md +31 -8
  2. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/CHANGELOG.md +68 -0
  3. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/PKG-INFO +9 -4
  4. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/README.md +8 -3
  5. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/pyproject.toml +2 -2
  6. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/__init__.py +1 -1
  7. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/attendance.py +13 -1
  8. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/config.py +27 -4
  9. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/gateway_grades.py +45 -12
  10. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/grade_records.py +77 -2
  11. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/inspect_page.py +5 -2
  12. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/markup.py +56 -11
  13. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/notification_workflow.py +2 -1
  14. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/service.py +29 -28
  15. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/uv.lock +212 -212
  16. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/.gitignore +0 -0
  17. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/LICENSE +0 -0
  18. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/SECURITY.md +0 -0
  19. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/_notification_bootstrap.py +0 -0
  20. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/_notification_codec.py +0 -0
  21. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/_storage.py +0 -0
  22. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/_windows_filesystem.py +0 -0
  23. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/announcements.py +0 -0
  24. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/attachment_routes.py +0 -0
  25. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/attachments.py +0 -0
  26. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/attendance_frequency.py +0 -0
  27. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/budget.py +0 -0
  28. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/checkpoint.py +0 -0
  29. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/class_free_days.py +0 -0
  30. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/completed_lessons.py +0 -0
  31. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/detail_fields.py +0 -0
  32. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/diagnostics.py +0 -0
  33. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/exceptions.py +0 -0
  34. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/files.py +0 -0
  35. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/formative_grades.py +0 -0
  36. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/grade_parsers.py +0 -0
  37. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/homework_range.py +0 -0
  38. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/lifecycle.py +0 -0
  39. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/message_content.py +0 -0
  40. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/messages.py +0 -0
  41. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/models.py +0 -0
  42. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/modern_body.py +0 -0
  43. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/modern_mailbox.py +0 -0
  44. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/modern_messages.py +0 -0
  45. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/notification_models.py +0 -0
  46. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/notification_persistence.py +0 -0
  47. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/notifications.py +0 -0
  48. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/parsers.py +0 -0
  49. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/parsing.py +0 -0
  50. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/persistence.py +0 -0
  51. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/py.typed +0 -0
  52. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/recipients.py +0 -0
  53. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/scheduler.py +0 -0
  54. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/school_reads.py +0 -0
  55. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/school_year_archive.py +0 -0
  56. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/sending.py +0 -0
  57. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/timetable.py +0 -0
  58. {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/transport.py +0 -0
@@ -287,12 +287,31 @@ such as `(0-100)`, not a grade symbol. The table's "Wyn." period results are
287
287
  into `averages`), and a point table with titled summary columns adds its
288
288
  subjects to `final_grades()`. See [point grades](contracts/grades.md#point-grades-170).
289
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
290
+ Behaviour grade symbols (1.8.3): an entry in the behaviour row with a grade
291
+ symbol such as `wz`, linking to a behaviour detail, is a `NumericGrade` in
292
+ `numeric`. As for point grades, `category` is the tooltip's unlabelled first
293
+ line (such as `wzorowe`), `day` is `Data wystawienia`, `teacher` stays `None`
294
+ and `Dodał` is kept only in `metadata`. See [grades](contracts/grades.md).
295
+
296
+ Schools on the new descriptive grading schema show a subject table
297
+ (`newSchemaGradesByStudentTable`) in one of two layouts. Every view checks it
298
+ the same way (from 1.8.4; before, only the `ALL` view did):
299
+
300
+ - Empty semester cells (`gradesCell` hooks) that the page script fills from
301
+ the gateway. In the `ALL` view `grades()` reads those grades from the
302
+ gateway, as described below. `WEEK` and `LAST_LOGIN` contain none of them.
303
+ - Semester cells holding bare text, such as `5` or `Brak ocen` (1.8.3). The
304
+ text becomes undated `descriptive_summaries` (`Brak ocen` is empty) and no
305
+ view makes a gateway request. Undated summaries are not notified.
306
+ - Anything else fails the read: blank cells without hooks, a cell with an
307
+ attribute or markup, both layouts in one table, a bare-text table that still
308
+ has `studentGradesDetailsRow` rows (`UnsupportedCapabilityError`), or rows
309
+ of more than one student (`ParseError`).
310
+
311
+ Gateway descriptive grades (1.8.0): with the empty layout, the `ALL` view
312
+ reads the grades from the gateway with the login's session and the call's
313
+ budget: three more requests, five when there are grades. They are
314
+ `DescriptiveGrade`s in `descriptive` (kind `CURRENT`, no
296
315
  `href`), with the page's subject name, the scale value or else the content as
297
316
  `raw`, the civil date, semester 1 or 2, the teacher's name when listed, the
298
317
  comment and metadata pairs for the present fields only (`Obszar`, `Treść`,
@@ -804,7 +823,11 @@ async with NotificationStore(Path("/absolute/private/notification-state")) as st
804
823
  announcements use the ordinary collection. No menu counts, message content,
805
824
  detail calls, modern fallback or arbitrary historical catch-up is performed.
806
825
  - New `AGENDA` consumption requires explicit `allow_consume_events=True` each
807
- time. Complete encoded raw bytes and original metadata commit before parsing.
826
+ time. Without it, and with no stored raw batch to replay, `poll()` and
827
+ `consume_schedule_events()` raise `ConsentRequiredError` before any request
828
+ (from 1.8.4; before, a plain `InvalidInputError`). Other invalid inputs,
829
+ such as repeated categories, stay `InvalidInputError`.
830
+ Complete encoded raw bytes and original metadata commit before parsing.
808
831
  Existing raw checkpoints replay locally before another consume. Malformed raw
809
832
  data remains stored and blocks another consume, even with fresh consent.
810
833
  - `NotificationBatch(receipt, context, first_run, categories, items,
@@ -1162,7 +1185,7 @@ text. `error_for(kind)` builds one.
1162
1185
  | Error | Meaning |
1163
1186
  | --- | --- |
1164
1187
  | `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 |
1188
+ | `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. Since 1.8.4 also consuming new calendar events without `allow_consume_events=True` |
1166
1189
  | `CredentialsRejectedError` | Login and password refused |
1167
1190
  | `AccountActionRequiredError` | CAPTCHA, 2FA or another interactive step is required |
1168
1191
  | `SessionExpiredError` | Proven expiry that could not be recovered |
@@ -1,5 +1,73 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.8.4 (2026-11-08) - Multi-line formative tooltips, grade checks in every view, calendar consent
4
+
5
+ Signatures are unchanged.
6
+
7
+ - Grades: the week and last-login views run the same new-schema descriptive
8
+ table checks as the all view (#123). They used to return summaries or
9
+ nothing for a table the all view rejects: blank or classed semester cells,
10
+ a filled table with gateway detail rows, mixed layouts or rows of more than
11
+ one student. Notification polls read the last-login view, so such a page
12
+ now fails the poll instead of passing silently. Only the all view with
13
+ empty `gradesCell` hooks reads the gateway, as before.
14
+ - Grades: a formative entry in the grade grid whose tooltip carries a
15
+ multi-line assessment text after `Ocena` no longer fails every grade read of
16
+ the login with `ParseError`. Found on two of the owner's logins by the live
17
+ check for this release (2026-11-08); 1.8.3 fails the same way. Unlabelled
18
+ lines continue `Ocena`, or a teacher-written field, with newlines. Tooltips
19
+ that already parsed keep the same metadata, so notification identities do
20
+ not change; the same lines in an ordinary grade's tooltip still fail.
21
+ - Grades: a grade that corrects two earlier grades no longer fails the week
22
+ view with `ParseError`. Its tooltip lists one `Poprawa oceny` line per
23
+ corrected grade (seen live on 2026-11-08 in the week view of two owner
24
+ logins); the values are kept in order in one metadata pair, joined by a
25
+ newline. Other repeated labels still fail.
26
+ - Notifications (krzysztofbury/librus-mcp#91): polling `AGENDA` without
27
+ `allow_consume_events=True` when no stored raw batch can be replayed, and
28
+ `consume_schedule_events()` with
29
+ `allow_consume_events=False`, raise `ConsentRequiredError` instead of a
30
+ plain invalid input, so a caller can ask the user. Nothing is requested
31
+ either way. `except InvalidInputError` still catches it; code comparing
32
+ `kind` with `INVALID_INPUT` sees the new kind. Repeated categories and a
33
+ non-boolean flag stay `InvalidInputError`.
34
+ - Docs: `API.md`, the `grades()` docstring and `contracts/grades.md` describe
35
+ both new-schema table layouts, which views read the gateway, and behaviour
36
+ grade symbols (#124).
37
+ - The weekly live check also reads the week and last-login grade views.
38
+
39
+ ## 1.8.3 (2026-11-06) - Reported grade, attendance and announcement layouts
40
+
41
+ Thank you, @wiktorw and @Witoso, for the reports and the careful synthetic
42
+ reproductions. Signatures are unchanged.
43
+
44
+ - Announcements: "Aktualnie brak ogłoszeń" is an empty page; it used to fail
45
+ as `ParseError` and stop notification polls (#120, reported by @Witoso).
46
+ - Grades: a behaviour-row entry with a grade symbol such as `wz`, linking to
47
+ a behaviour detail, is a `NumericGrade`. Its tooltip's unlabelled first
48
+ line, such as `wzorowe`, is the `category`, as a point entry's first line
49
+ names its category; the day is `Data wystawienia` and `Dodał` stays in
50
+ `metadata`. The whole grades read used to fail (#114, reported by @wiktorw).
51
+ - Grades: a new-schema descriptive table whose semester cells already hold
52
+ plain text, such as `5` or `Brak ocen`, is read as undated semester
53
+ summaries and makes no gateway request. The all view used to fail with
54
+ `UnsupportedCapabilityError` (#115, reported by @wiktorw). Blank cells,
55
+ cells with attributes or markup, mixed layouts and filled tables with
56
+ gateway detail rows still fail.
57
+ - Grades: the new-schema table's header row is no longer read as a subject
58
+ named "Przedmiot" with "Okres 1" and "Okres 2" summaries.
59
+ - Attendance: an absence excused through the e-Usprawiedliwienia module
60
+ carries a fixed notice line in its tooltip, which failed every attendance
61
+ read with `ParseError` (observed live on 2026-11-06 on two logins). The
62
+ notice is now the metadata pair `("Usprawiedliwienie", "dodane za pomocą
63
+ modułu e-Usprawiedliwienia")`; other unlabelled lines still fail.
64
+ - `inspect_page grades` also runs the new-schema table check that `grades()`
65
+ runs in the all view, so a saved page reproduces such a failure offline.
66
+
67
+ The #114 and #115 layouts come from the reporters' synthetic examples, not an
68
+ observed page: the owner's logins have neither. Live checks show they cause no
69
+ regression, not that the fix matches every school's page.
70
+
3
71
  ## 1.8.2 (2026-11-01) - Modern session kept across cancels, honest User-Agent
4
72
 
5
73
  - A cancelled modern mailbox read or a reached limit no longer drops the modern
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: librus-python-api
3
- Version: 1.8.2
3
+ Version: 1.8.4
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,7 +42,12 @@ private ACLs. Windows installations include `pywin32` and `tzdata` automatically
42
42
  Network paths, reparse-point paths and unsafe storage permissions fail closed.
43
43
  See [platform requirements](contracts/persistence.md#windows-disk-boundary-25).
44
44
 
45
- **Status:** `1.8.2` keeps the modern mailbox session across a cancelled read
45
+ **Status:** `1.8.4` reads multi-line formative assessment text and repeated
46
+ corrections in grade tooltips, checks the new-schema grade table in every grade view and reports
47
+ missing calendar-consumption consent as `ConsentRequiredError`.
48
+ `1.8.3` reads behaviour grade symbols, filled descriptive
49
+ grade tables, the "Aktualnie brak ogłoszeń" page and absences excused
50
+ through e-Usprawiedliwienia. `1.8.2` keeps the modern mailbox session across a cancelled read
46
51
  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
52
  with a "Brak dostępu" page, not a login redirect), reads the lucky number
48
53
  from the page header and summarizes a page that fails as a whole in
@@ -64,7 +69,7 @@ See [limitations](#supported-features-and-limitations) below.
64
69
  Install the exact release from PyPI after its gated publication completes:
65
70
 
66
71
  ```sh
67
- python -m pip install librus-python-api==1.8.2
72
+ python -m pip install librus-python-api==1.8.4
68
73
  ```
69
74
 
70
75
  Pin the exact version qualified by your application. Before publication, use a
@@ -80,7 +85,7 @@ python -m pip install .
80
85
  To install a locally built wheel instead:
81
86
 
82
87
  ```sh
83
- python -m pip install ./dist/librus_python_api-1.8.2-py3-none-any.whl
88
+ python -m pip install ./dist/librus_python_api-1.8.4-py3-none-any.whl
84
89
  ```
85
90
 
86
91
  No console script or background process is installed: import the library in your
@@ -16,7 +16,12 @@ private ACLs. Windows installations include `pywin32` and `tzdata` automatically
16
16
  Network paths, reparse-point paths and unsafe storage permissions fail closed.
17
17
  See [platform requirements](contracts/persistence.md#windows-disk-boundary-25).
18
18
 
19
- **Status:** `1.8.2` keeps the modern mailbox session across a cancelled read
19
+ **Status:** `1.8.4` reads multi-line formative assessment text and repeated
20
+ corrections in grade tooltips, checks the new-schema grade table in every grade view and reports
21
+ missing calendar-consumption consent as `ConsentRequiredError`.
22
+ `1.8.3` reads behaviour grade symbols, filled descriptive
23
+ grade tables, the "Aktualnie brak ogłoszeń" page and absences excused
24
+ through e-Usprawiedliwienia. `1.8.2` keeps the modern mailbox session across a cancelled read
20
25
  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
26
  with a "Brak dostępu" page, not a login redirect), reads the lucky number
22
27
  from the page header and summarizes a page that fails as a whole in
@@ -38,7 +43,7 @@ See [limitations](#supported-features-and-limitations) below.
38
43
  Install the exact release from PyPI after its gated publication completes:
39
44
 
40
45
  ```sh
41
- python -m pip install librus-python-api==1.8.2
46
+ python -m pip install librus-python-api==1.8.4
42
47
  ```
43
48
 
44
49
  Pin the exact version qualified by your application. Before publication, use a
@@ -54,7 +59,7 @@ python -m pip install .
54
59
  To install a locally built wheel instead:
55
60
 
56
61
  ```sh
57
- python -m pip install ./dist/librus_python_api-1.8.2-py3-none-any.whl
62
+ python -m pip install ./dist/librus_python_api-1.8.4-py3-none-any.whl
58
63
  ```
59
64
 
60
65
  No console script or background process is installed: import the library in your
@@ -1,5 +1,5 @@
1
1
  [build-system]
2
- requires = ["hatchling==1.32.4"]
2
+ requires = ["hatchling==1.33.0"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [project]
@@ -29,7 +29,7 @@ Issues = "https://github.com/krzysztofbury/librus-python-api/issues"
29
29
 
30
30
  [dependency-groups]
31
31
  dev = [
32
- "hatchling==1.32.4",
32
+ "hatchling==1.33.0",
33
33
  "twine>=7,<8",
34
34
  "mypy>=1.19,<3",
35
35
  "openapi-spec-validator>=0.7,<0.10",
@@ -1,7 +1,7 @@
1
1
  """Independent bounded async client. General live compatibility is unqualified."""
2
2
 
3
3
  # Before the imports: config reads it for the User-Agent.
4
- __version__ = "1.8.2"
4
+ __version__ = "1.8.4"
5
5
 
6
6
  from librus_python_api.attachments import AttachmentStream, ModernAttachmentStream
7
7
  from librus_python_api.budget import RequestBudget
@@ -11,6 +11,7 @@ from librus_python_api.config import (
11
11
  ATTENDANCE_EMPTY_MARKERS,
12
12
  ATTENDANCE_MAX_RECORDS,
13
13
  ATTENDANCE_SEMESTER_LABELS,
14
+ ATTENDANCE_TOOLTIP_NOTICES,
14
15
  GRADE_MAX_METADATA_LENGTH,
15
16
  GRADE_MAX_VALUE_LENGTH,
16
17
  )
@@ -29,6 +30,17 @@ def _entry(element: html.HtmlElement, semester: Literal[1, 2]) -> AttendanceReco
29
30
  title = element.get("title", "")
30
31
  if len(title) > GRADE_MAX_METADATA_LENGTH:
31
32
  raise LibrusError(ErrorKind.LIMIT)
33
+ # A known Librus notice is a whole line of its own; other unlabelled lines
34
+ # still fail in tooltip_fields.
35
+ notices: list[tuple[str, str]] = []
36
+ lines = []
37
+ for line in re.split(r"<br\s*/?>|\n", title, flags=re.I):
38
+ notice = ATTENDANCE_TOOLTIP_NOTICES.get(markup.tooltip_line(line))
39
+ if notice is None or notice in notices:
40
+ lines.append(line)
41
+ else:
42
+ notices.append(notice)
43
+ title = "<br>".join(lines)
32
44
  holder = html.Element("span")
33
45
  holder.set("title", re.sub(r"</b>\s*(?=<b[ >])", "</b>\n", title, flags=re.I))
34
46
  fields = markup.tooltip_fields(holder)
@@ -57,7 +69,7 @@ def _entry(element: html.HtmlElement, semester: Literal[1, 2]) -> AttendanceReco
57
69
  fields.get("Temat zajęć"),
58
70
  fields.get("Lekcja"),
59
71
  markup.attendance_detail_id(element),
60
- tuple(fields.items()),
72
+ (*fields.items(), *notices),
61
73
  )
62
74
 
63
75
 
@@ -721,8 +721,12 @@ GRADE_MAX_VALUE_LENGTH = 1024
721
721
  GRADE_MERGED_SUBJECTS = frozenset({"Zachowanie"})
722
722
  GRADE_INLINE_DETAIL_LABEL = "Ocena"
723
723
  # Tooltip fields whose free text may continue on unlabelled lines. A teacher's
724
- # multi-line grade comment was observed on 2026-10-27.
724
+ # multi-line grade comment was observed on 2026-10-27. A formative mirror's
725
+ # assessment text after `Ocena` also spans lines (2026-11-08, markup.py).
725
726
  TOOLTIP_MULTILINE_LABELS = frozenset({"Komentarz"})
727
+ # Labels a tooltip may repeat. A grade correcting two earlier grades lists one
728
+ # `Poprawa oceny` line per corrected grade in the week view (2026-11-08).
729
+ TOOLTIP_REPEATABLE_LABELS = frozenset({"Poprawa oceny"})
726
730
  # Tooltip labels observed on grade, attendance and point pages. Diagnostics
727
731
  # show only these by name; any other label is masked as teacher-entered text.
728
732
  TOOLTIP_LABELS = frozenset(
@@ -744,6 +748,7 @@ TOOLTIP_LABELS = frozenset(
744
748
  "Nauczyciel",
745
749
  "Obowiązek wyk. zadania",
746
750
  "Ocena",
751
+ "Poprawa oceny",
747
752
  "Rodzaj",
748
753
  "Suma punktów",
749
754
  "Temat zajęć",
@@ -783,10 +788,12 @@ GRADE_PERIOD_HEADERS = MappingProxyType(
783
788
  "Ocena roczna": 0,
784
789
  }
785
790
  )
786
- # New-schema descriptive grading: the page renders this table with empty
787
- # semester cells and its script loads the grades from the gateway routes above.
791
+ # New-schema descriptive grading: the page renders this table either with empty
792
+ # `gradesCell` semester cells, which its script fills from the gateway routes
793
+ # above, or with bare semester text, which needs no gateway read.
788
794
  DESCRIPTIVE_TABLE_CLASS = "newSchemaGradesByStudentTable"
789
795
  DESCRIPTIVE_ROW_CLASS = "studentRow"
796
+ DESCRIPTIVE_DETAILS_ROW_CLASS = "studentGradesDetailsRow"
790
797
  # Gateway account and student identifiers are opaque strings, unlike every
791
798
  # other `{id}` route, which takes a numeric ID.
792
799
  DESCRIPTIVE_IDENTIFIER = re.compile(r"[A-Za-z0-9_-]{1,128}")
@@ -838,6 +845,17 @@ ATTENDANCE_SEMESTER_LABELS = MappingProxyType(
838
845
  }
839
846
  )
840
847
  ATTENDANCE_EMPTY_MARKERS = frozenset({"", "-", "Brak nieobecności"})
848
+ # Fixed unlabelled tooltip lines Librus appends to an attendance entry, and the
849
+ # metadata pair each becomes. Observed live on 2026-11-06 on two logins: an
850
+ # absence excused through the e-Usprawiedliwienia module.
851
+ ATTENDANCE_TOOLTIP_NOTICES = MappingProxyType(
852
+ {
853
+ "Usprawiedliwienie dodane za pomocą modułu e-Usprawiedliwienia": (
854
+ "Usprawiedliwienie",
855
+ "dodane za pomocą modułu e-Usprawiedliwienia",
856
+ )
857
+ }
858
+ )
841
859
  ATTENDANCE_MAX_RECORDS = 2048
842
860
  ATTENDANCE_MAX_WINDOW_DAYS = 371
843
861
  # Whole-school gateway collections resolve attendance metadata in two requests.
@@ -849,6 +867,8 @@ ATTENDANCE_DETAIL_PATH_PREFIX = "/przegladaj_nb/szczegoly/"
849
867
  FORMATIVE_DETAIL_PATH_PREFIX = "/przegladaj_oceny/szczegoly/ksztaltujace/"
850
868
  # Recognition only: behaviour-point detail links are classified, never requested.
851
869
  BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX = "/przegladaj_oceny/zachowanie_szczegoly/"
870
+ # Tooltip labels of a behaviour grade symbol entry (#114, synthetic example).
871
+ BEHAVIOUR_GRADE_LABELS = frozenset({"Data wystawienia", "Dodał", "Komentarz"})
852
872
  # Recognition only: point-grade detail links are classified, never requested.
853
873
  POINT_GRADE_DETAIL_PATH_PREFIX = "/przegladaj_oceny_punktowe/szczegoly/"
854
874
  FORMATIVE_HEADERS = (
@@ -916,7 +936,10 @@ ANNOUNCEMENT_LABELS = MappingProxyType(
916
936
  ANNOUNCEMENT_EMPTY_CLASSES = frozenset(
917
937
  {"container", "border-red", "resizeable", "center"}
918
938
  )
919
- ANNOUNCEMENT_EMPTY_MARKERS = frozenset({"brak ogłoszeń", "nie ma ogłoszeń"})
939
+ # "Aktualnie brak ogłoszeń" reported in #120 on two accounts.
940
+ ANNOUNCEMENT_EMPTY_MARKERS = frozenset(
941
+ {"brak ogłoszeń", "nie ma ogłoszeń", "aktualnie brak ogłoszeń"}
942
+ )
920
943
  TIMETABLE_MAX_PERIODS = 32
921
944
  TIMETABLE_MAX_LESSONS_PER_SLOT = 16
922
945
  TIMETABLE_MAX_CHANGES_PER_SLOT = 16
@@ -6,6 +6,11 @@ grades from gateway JSON routes. Routes and field names come from public client
6
6
  code and the official page script. One authorized read on 2026-10-28 parsed four
7
7
  PartialGrades records on one school; pagination keys are still unknown, so every
8
8
  unexpected shape fails instead of producing a partial collection.
9
+
10
+ Another school renders the same table with plain text in its semester cells and
11
+ no script hooks (#115, from a reporter's synthetic example). The grade record
12
+ parser reads those cells like any descriptive row, so that table needs no
13
+ gateway read.
9
14
  """
10
15
 
11
16
  import re
@@ -20,6 +25,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_valida
20
25
 
21
26
  from librus_python_api import markup
22
27
  from librus_python_api.config import (
28
+ DESCRIPTIVE_DETAILS_ROW_CLASS,
23
29
  DESCRIPTIVE_IDENTIFIER,
24
30
  DESCRIPTIVE_MAX_NAME_LENGTH,
25
31
  DESCRIPTIVE_MAX_SUBJECTS,
@@ -331,9 +337,13 @@ def parse_gateway_users(body: bytes) -> Mapping[str, str]:
331
337
 
332
338
  @dataclass(frozen=True, slots=True)
333
339
  class DescriptiveTable:
334
- """Numeric subject ID to the subject name the grades page shows."""
340
+ """Numeric subject ID to the subject name the grades page shows.
341
+
342
+ A filled table already holds its grades and has no gateway grades.
343
+ """
335
344
 
336
345
  subjects: Mapping[int, str] = field(repr=False)
346
+ filled: bool = False
337
347
 
338
348
 
339
349
  def _classes(element: html.HtmlElement) -> set[str]:
@@ -343,9 +353,11 @@ def _classes(element: html.HtmlElement) -> set[str]:
343
353
  def read_descriptive_table(document: html.HtmlElement) -> DescriptiveTable | None:
344
354
  """The new-schema subject table, or None when the page has none.
345
355
 
346
- Its rows hold an expander, the subject and two empty semester cells that
347
- the page script fills from the gateway. A cell the page already filled
348
- would duplicate the gateway records, so it is an unsupported layout.
356
+ Its rows hold an expander, the subject and two semester cells. Either
357
+ every row has empty `gradesCell` cells that the page script fills from
358
+ the gateway, or every row has plain cells the page filled itself, which
359
+ `read_grade_records` reads and validates. A script cell the page already
360
+ filled would duplicate the gateway records, so it is unsupported.
349
361
  """
350
362
  tables = [
351
363
  table
@@ -369,20 +381,33 @@ def read_descriptive_table(document: html.HtmlElement) -> DescriptiveTable | Non
369
381
  tables = populated
370
382
  subjects: dict[int, str] = {}
371
383
  students: set[str | None] = set()
384
+ layouts: set[bool] = set()
372
385
  for row in markup.rows(tables[0]):
373
386
  if DESCRIPTIVE_ROW_CLASS not in _classes(row):
374
387
  continue
375
388
  cells = markup.cells(row)
376
- if (
377
- len(cells) != 4
378
- or not {"micro", "center", "screen-only"} <= _classes(cells[0])
379
- or [cell.get("data-semester") for cell in cells[2:]] != ["1", "2"]
380
- or not all("gradesCell" in _classes(cell) for cell in cells[2:])
389
+ if len(cells) != 4 or not {"micro", "center", "screen-only"} <= _classes(
390
+ cells[0]
391
+ ):
392
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
393
+ semesters = cells[2:]
394
+ # A filled cell is bare text: no attributes and no markup, so a renamed
395
+ # or moved script hook never passes as a filled table.
396
+ filled = all(
397
+ not cell.attrib
398
+ and markup.text(cell)
399
+ and not any(isinstance(node.tag, str) for node in cell)
400
+ for cell in semesters
401
+ )
402
+ if not filled and (
403
+ [cell.get("data-semester") for cell in semesters] != ["1", "2"]
404
+ or not all("gradesCell" in _classes(cell) for cell in semesters)
381
405
  ):
382
406
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
383
- if any(
407
+ layouts.add(filled)
408
+ if not filled and any(
384
409
  markup.text(cell) or any(isinstance(node.tag, str) for node in cell)
385
- for cell in cells[2:]
410
+ for cell in semesters
386
411
  ):
387
412
  raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
388
413
  identifier = row.get("data-subject_id", "")
@@ -398,7 +423,15 @@ def read_descriptive_table(document: html.HtmlElement) -> DescriptiveTable | Non
398
423
  if len(students) > 1:
399
424
  # One page shows one student; rows of several students are unknown.
400
425
  raise LibrusError(ErrorKind.PARSE)
401
- return DescriptiveTable(MappingProxyType(subjects))
426
+ if len(layouts) > 1:
427
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
428
+ if layouts == {True} and any(
429
+ DESCRIPTIVE_DETAILS_ROW_CLASS in _classes(row) for row in markup.rows(tables[0])
430
+ ):
431
+ # Detail rows are what the page script fills from the gateway; a filled
432
+ # table that still has them may also have gateway grades.
433
+ raise LibrusError(ErrorKind.UNSUPPORTED_CAPABILITY)
434
+ return DescriptiveTable(MappingProxyType(subjects), layouts == {True})
402
435
 
403
436
 
404
437
  def parse_grade_page(body: bytes) -> tuple[GradeRecords, DescriptiveTable | None]:
@@ -8,12 +8,14 @@ from lxml import html
8
8
 
9
9
  from librus_python_api import markup
10
10
  from librus_python_api.config import (
11
+ BEHAVIOUR_GRADE_LABELS,
11
12
  BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX,
12
13
  FORMATIVE_DETAIL_PATH_PREFIX,
13
14
  GRADE_AVERAGE_HEADERS,
14
15
  GRADE_BODY_PREFIX_COLUMNS,
15
16
  GRADE_CURRENT_HEADER,
16
17
  GRADE_EMPTY_MARKERS,
18
+ GRADE_INLINE_DETAIL_LABEL,
17
19
  GRADE_MAX_METADATA_LENGTH,
18
20
  GRADE_MAX_RECORDS,
19
21
  GRADE_MAX_SUBJECTS,
@@ -26,6 +28,8 @@ from librus_python_api.config import (
26
28
  GRADE_PUBLICATION_DATE_LABEL,
27
29
  GRADE_PUBLICATION_PERIOD_LABELS,
28
30
  GRADE_PUBLICATION_TEACHER_LABEL,
31
+ TOOLTIP_LABELS,
32
+ TOOLTIP_MULTILINE_LABELS,
29
33
  )
30
34
  from librus_python_api.exceptions import ErrorKind, LibrusError
31
35
  from librus_python_api.formative_grades import is_formative_table, read_formative_table
@@ -97,7 +101,16 @@ def _numeric(
97
101
  semester: Literal[0, 1, 2],
98
102
  kind: GradeKind = GradeKind.CURRENT,
99
103
  ) -> NumericGrade:
100
- metadata = markup.tooltip_fields(element)
104
+ if markup.detail_id(element.get("href"), FORMATIVE_DETAIL_PATH_PREFIX) is None:
105
+ metadata = markup.tooltip_fields(element)
106
+ else:
107
+ # A formative mirror's assessment text after `Ocena` is free text that
108
+ # may span lines and hold colons (observed live 2026-11-08).
109
+ metadata = markup.tooltip_fields(
110
+ element,
111
+ multiline=TOOLTIP_MULTILINE_LABELS | {GRADE_INLINE_DETAIL_LABEL},
112
+ unknown_multiline=True,
113
+ )
101
114
  counts = metadata.get("Licz do średniej")
102
115
  if counts is not None and counts.casefold() not in ("tak", "nie"):
103
116
  raise LibrusError(ErrorKind.PARSE)
@@ -126,6 +139,51 @@ def _numeric(
126
139
  )
127
140
 
128
141
 
142
+ def _behaviour_grade(
143
+ element: html.HtmlElement,
144
+ subject: str,
145
+ semester: Literal[0, 1, 2],
146
+ kind: GradeKind = GradeKind.CURRENT,
147
+ ) -> NumericGrade:
148
+ """A behaviour entry with a grade symbol, such as `wz`, instead of points.
149
+
150
+ Its tooltip has the point entries' layout: an unlabelled first line, then
151
+ `Data wystawienia` and `Dodał`. The first line names the entry, as the
152
+ point category does, so it is the category. `Dodał` stays in metadata. A
153
+ label with digits, a point entry's range labels or any other label fails.
154
+ """
155
+ heading: list[str] = []
156
+ metadata = markup.tooltip_fields(element, heading)
157
+ raw = markup.text(element)
158
+ if (
159
+ not heading
160
+ # A labelled first line means the heading is missing.
161
+ or heading[0].partition(":")[0].strip() in TOOLTIP_LABELS
162
+ # Symbols carry no digits; points or a range are a point entry.
163
+ or not raw
164
+ or re.search(r"[0-9]", raw)
165
+ or not BEHAVIOUR_GRADE_LABELS >= metadata.keys()
166
+ ):
167
+ raise LibrusError(ErrorKind.PARSE)
168
+ href = element.get("href")
169
+ if href is not None and len(href) > GRADE_MAX_METADATA_LENGTH:
170
+ raise LibrusError(ErrorKind.LIMIT)
171
+ return NumericGrade(
172
+ subject,
173
+ raw,
174
+ markup.civil_date(metadata.get("Data wystawienia")),
175
+ semester,
176
+ None,
177
+ None,
178
+ heading[0],
179
+ None,
180
+ metadata.get("Komentarz"),
181
+ href,
182
+ tuple(metadata.items()),
183
+ kind,
184
+ )
185
+
186
+
129
187
  def _descriptive(
130
188
  box: html.HtmlElement,
131
189
  element: html.HtmlElement,
@@ -284,7 +342,21 @@ def _entries(
284
342
  if len(anchors) + len(descriptions) != 1:
285
343
  raise LibrusError(ErrorKind.PARSE)
286
344
  if anchors:
287
- record = _numeric(anchors[0], subject, period, kind)
345
+ behaviour = (
346
+ subject in GRADE_MERGED_SUBJECTS
347
+ and markup.detail_id(
348
+ anchors[0].get("href"), BEHAVIOUR_POINTS_DETAIL_PATH_PREFIX
349
+ )
350
+ is not None
351
+ )
352
+ if behaviour and descriptive:
353
+ # A descriptive record has no category for the first line.
354
+ raise LibrusError(ErrorKind.PARSE)
355
+ record = (
356
+ _behaviour_grade(anchors[0], subject, period, kind)
357
+ if behaviour
358
+ else _numeric(anchors[0], subject, period, kind)
359
+ )
288
360
  if descriptive:
289
361
  href = record.href
290
362
  if href is not None and re.sub(
@@ -540,6 +612,9 @@ def read_grade_records(document: html.HtmlElement) -> GradeRecords:
540
612
  _read_numeric_table(table, collection, points=True)
541
613
  else:
542
614
  for row in markup.rows(table):
615
+ if markup.in_header(row):
616
+ # The new-schema table's column labels, not a subject.
617
+ continue
543
618
  cells = markup.cells(row)
544
619
  if _descriptive_row(cells):
545
620
  _read_descriptive_row(cells, collection)
@@ -19,8 +19,8 @@ from librus_python_api import __version__
19
19
  from librus_python_api.config import TransportLimits
20
20
  from librus_python_api.diagnostics import describe_failure, failure_frames
21
21
  from librus_python_api.exceptions import LibrusError
22
+ from librus_python_api.gateway_grades import parse_grade_page
22
23
  from librus_python_api.grade_parsers import parse_final_grades
23
- from librus_python_api.grade_records import parse_grade_records
24
24
  from librus_python_api.models import Availability
25
25
  from librus_python_api.parsers import parse_profile
26
26
 
@@ -31,7 +31,8 @@ NOTICE = (
31
31
 
32
32
 
33
33
  def _grades(body: bytes) -> dict[str, int]:
34
- records = parse_grade_records(body)
34
+ # Every grade view also checks the new-schema table, as `grades()` does.
35
+ records, table = parse_grade_page(body)
35
36
  return {
36
37
  "numeric": sum(1 for g in records.numeric if g.point_grade_id is None),
37
38
  "point": sum(1 for g in records.numeric if g.point_grade_id is not None),
@@ -41,6 +42,8 @@ def _grades(body: bytes) -> dict[str, int]:
41
42
  "averages": len(records.averages),
42
43
  "point_results": len(records.point_results),
43
44
  "formative": len(records.formative),
45
+ "descriptive_table": int(table is not None),
46
+ "descriptive_table_filled": int(table is not None and table.filled),
44
47
  }
45
48
 
46
49