librus-python-api 1.8.3__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.3 → librus_python_api-1.8.4}/API.md +31 -8
  2. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/CHANGELOG.md +36 -0
  3. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/PKG-INFO +7 -4
  4. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/README.md +6 -3
  5. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/__init__.py +1 -1
  6. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/config.py +9 -3
  7. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/grade_records.py +12 -1
  8. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/inspect_page.py +1 -1
  9. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/markup.py +42 -6
  10. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/notification_workflow.py +2 -1
  11. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/service.py +16 -19
  12. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/.gitignore +0 -0
  13. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/LICENSE +0 -0
  14. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/SECURITY.md +0 -0
  15. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/pyproject.toml +0 -0
  16. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/_notification_bootstrap.py +0 -0
  17. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/_notification_codec.py +0 -0
  18. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/_storage.py +0 -0
  19. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/_windows_filesystem.py +0 -0
  20. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/announcements.py +0 -0
  21. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/attachment_routes.py +0 -0
  22. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/attachments.py +0 -0
  23. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/attendance.py +0 -0
  24. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/attendance_frequency.py +0 -0
  25. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/budget.py +0 -0
  26. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/checkpoint.py +0 -0
  27. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/class_free_days.py +0 -0
  28. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/completed_lessons.py +0 -0
  29. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/detail_fields.py +0 -0
  30. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/diagnostics.py +0 -0
  31. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/exceptions.py +0 -0
  32. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/files.py +0 -0
  33. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/formative_grades.py +0 -0
  34. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/gateway_grades.py +0 -0
  35. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/grade_parsers.py +0 -0
  36. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/homework_range.py +0 -0
  37. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/lifecycle.py +0 -0
  38. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/message_content.py +0 -0
  39. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/messages.py +0 -0
  40. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/models.py +0 -0
  41. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/modern_body.py +0 -0
  42. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/modern_mailbox.py +0 -0
  43. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/modern_messages.py +0 -0
  44. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/notification_models.py +0 -0
  45. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/notification_persistence.py +0 -0
  46. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/notifications.py +0 -0
  47. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/parsers.py +0 -0
  48. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/parsing.py +0 -0
  49. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/persistence.py +0 -0
  50. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/py.typed +0 -0
  51. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/recipients.py +0 -0
  52. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/scheduler.py +0 -0
  53. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/school_reads.py +0 -0
  54. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/school_year_archive.py +0 -0
  55. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/sending.py +0 -0
  56. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/timetable.py +0 -0
  57. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/src/librus_python_api/transport.py +0 -0
  58. {librus_python_api-1.8.3 → librus_python_api-1.8.4}/uv.lock +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,41 @@
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
+
3
39
  ## 1.8.3 (2026-11-06) - Reported grade, attendance and announcement layouts
4
40
 
5
41
  Thank you, @wiktorw and @Witoso, for the reports and the careful synthetic
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: librus-python-api
3
- Version: 1.8.3
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,10 @@ 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.3` reads behaviour grade symbols, filled descriptive
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
46
49
  grade tables, the "Aktualnie brak ogłoszeń" page and absences excused
47
50
  through e-Usprawiedliwienia. `1.8.2` keeps the modern mailbox session across a cancelled read
48
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
@@ -66,7 +69,7 @@ See [limitations](#supported-features-and-limitations) below.
66
69
  Install the exact release from PyPI after its gated publication completes:
67
70
 
68
71
  ```sh
69
- python -m pip install librus-python-api==1.8.3
72
+ python -m pip install librus-python-api==1.8.4
70
73
  ```
71
74
 
72
75
  Pin the exact version qualified by your application. Before publication, use a
@@ -82,7 +85,7 @@ python -m pip install .
82
85
  To install a locally built wheel instead:
83
86
 
84
87
  ```sh
85
- python -m pip install ./dist/librus_python_api-1.8.3-py3-none-any.whl
88
+ python -m pip install ./dist/librus_python_api-1.8.4-py3-none-any.whl
86
89
  ```
87
90
 
88
91
  No console script or background process is installed: import the library in your
@@ -16,7 +16,10 @@ 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.3` reads behaviour grade symbols, filled descriptive
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
20
23
  grade tables, the "Aktualnie brak ogłoszeń" page and absences excused
21
24
  through e-Usprawiedliwienia. `1.8.2` keeps the modern mailbox session across a cancelled read
22
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
@@ -40,7 +43,7 @@ See [limitations](#supported-features-and-limitations) below.
40
43
  Install the exact release from PyPI after its gated publication completes:
41
44
 
42
45
  ```sh
43
- python -m pip install librus-python-api==1.8.3
46
+ python -m pip install librus-python-api==1.8.4
44
47
  ```
45
48
 
46
49
  Pin the exact version qualified by your application. Before publication, use a
@@ -56,7 +59,7 @@ python -m pip install .
56
59
  To install a locally built wheel instead:
57
60
 
58
61
  ```sh
59
- python -m pip install ./dist/librus_python_api-1.8.3-py3-none-any.whl
62
+ python -m pip install ./dist/librus_python_api-1.8.4-py3-none-any.whl
60
63
  ```
61
64
 
62
65
  No console script or background process is installed: import the library in your
@@ -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.3"
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
@@ -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,8 +788,9 @@ 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"
790
796
  DESCRIPTIVE_DETAILS_ROW_CLASS = "studentGradesDetailsRow"
@@ -15,6 +15,7 @@ from librus_python_api.config import (
15
15
  GRADE_BODY_PREFIX_COLUMNS,
16
16
  GRADE_CURRENT_HEADER,
17
17
  GRADE_EMPTY_MARKERS,
18
+ GRADE_INLINE_DETAIL_LABEL,
18
19
  GRADE_MAX_METADATA_LENGTH,
19
20
  GRADE_MAX_RECORDS,
20
21
  GRADE_MAX_SUBJECTS,
@@ -28,6 +29,7 @@ from librus_python_api.config import (
28
29
  GRADE_PUBLICATION_PERIOD_LABELS,
29
30
  GRADE_PUBLICATION_TEACHER_LABEL,
30
31
  TOOLTIP_LABELS,
32
+ TOOLTIP_MULTILINE_LABELS,
31
33
  )
32
34
  from librus_python_api.exceptions import ErrorKind, LibrusError
33
35
  from librus_python_api.formative_grades import is_formative_table, read_formative_table
@@ -99,7 +101,16 @@ def _numeric(
99
101
  semester: Literal[0, 1, 2],
100
102
  kind: GradeKind = GradeKind.CURRENT,
101
103
  ) -> NumericGrade:
102
- 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
+ )
103
114
  counts = metadata.get("Licz do średniej")
104
115
  if counts is not None and counts.casefold() not in ("tak", "nie"):
105
116
  raise LibrusError(ErrorKind.PARSE)
@@ -31,7 +31,7 @@ NOTICE = (
31
31
 
32
32
 
33
33
  def _grades(body: bytes) -> dict[str, int]:
34
- # The all view also checks the new-schema table, as `grades()` does.
34
+ # Every grade view also checks the new-schema table, as `grades()` does.
35
35
  records, table = parse_grade_page(body)
36
36
  return {
37
37
  "numeric": sum(1 for g in records.numeric if g.point_grade_id is None),
@@ -13,7 +13,9 @@ from librus_python_api.config import (
13
13
  GRADE_MAX_COLUMNS,
14
14
  GRADE_MAX_METADATA_FIELDS,
15
15
  GRADE_MAX_METADATA_LENGTH,
16
+ TOOLTIP_LABELS,
16
17
  TOOLTIP_MULTILINE_LABELS,
18
+ TOOLTIP_REPEATABLE_LABELS,
17
19
  UPSTREAM_ORIGINS,
18
20
  )
19
21
  from librus_python_api.exceptions import ErrorKind, LibrusError
@@ -82,14 +84,27 @@ def colspan(cell: html.HtmlElement) -> int:
82
84
 
83
85
 
84
86
  def tooltip_fields(
85
- element: html.HtmlElement, heading: list[str] | None = None
87
+ element: html.HtmlElement,
88
+ heading: list[str] | None = None,
89
+ *,
90
+ multiline: frozenset[str] | None = None,
91
+ unknown_multiline: bool = False,
86
92
  ) -> dict[str, str]:
87
93
  """Parse a 'Label: value<br>' title attribute into unique labelled fields.
88
94
 
89
95
  A free-text field may span lines; an unlabelled line after it continues
90
96
  its value. Any other unlabelled line is an unknown layout. With `heading`,
91
97
  the first line is taken whole, colon included, and appended to it.
98
+ `multiline` names the free-text labels (default `Komentarz`); with
99
+ `unknown_multiline`, a field whose label is not a known one (teacher text
100
+ holding a colon) is free text too, and inside such free text a line
101
+ starting with a known label is text unless the tooltip's closing run of
102
+ known-label lines begins there. Otherwise a known label such as `Data`
103
+ never continues. A repeatable label (`Poprawa oceny`) adds its value as
104
+ one more line of the same field.
92
105
  """
106
+ if multiline is None:
107
+ multiline = TOOLTIP_MULTILINE_LABELS
93
108
  title = element.get("title", "")
94
109
  if len(title) > GRADE_MAX_METADATA_LENGTH:
95
110
  raise LibrusError(ErrorKind.LIMIT)
@@ -98,18 +113,34 @@ def tooltip_fields(
98
113
  chunks = re.split(r"<br\s*/?>|\n", title, flags=re.I)
99
114
  if len(chunks) > GRADE_MAX_METADATA_FIELDS:
100
115
  raise LibrusError(ErrorKind.LIMIT)
101
- for chunk in chunks:
102
- if not chunk.strip():
103
- continue
104
- rendered = tooltip_line(chunk)
116
+ lines = [tooltip_line(chunk) for chunk in chunks if chunk.strip()]
117
+ # Start of the closing run of known-label lines, such as Kategoria, Data.
118
+ closing = len(lines)
119
+ while closing and _known_label(lines[closing - 1]):
120
+ closing -= 1
121
+ for index, rendered in enumerate(lines):
105
122
  if heading is not None and not heading:
106
123
  heading.append(rendered)
107
124
  continue
108
125
  key, separator, value = rendered.partition(":")
109
126
  key, value = key.strip(), value.strip()
110
- if not separator and last in TOOLTIP_MULTILINE_LABELS:
127
+ if (
128
+ last is not None
129
+ and (
130
+ last in multiline or (unknown_multiline and last not in TOOLTIP_LABELS)
131
+ )
132
+ and (
133
+ not separator
134
+ or (unknown_multiline and index < closing and key in TOOLTIP_LABELS)
135
+ )
136
+ ):
111
137
  result[last] = f"{result[last]}\n{rendered}".strip()
112
138
  continue
139
+ if separator and key in result and key in TOOLTIP_REPEATABLE_LABELS:
140
+ # Each repeat keeps its value, in order, as one more line.
141
+ result[key] = f"{result[key]}\n{value}"
142
+ last = key
143
+ continue
113
144
  if not separator or not key or key in result:
114
145
  raise LibrusError(ErrorKind.PARSE)
115
146
  result[key] = value
@@ -117,6 +148,11 @@ def tooltip_fields(
117
148
  return result
118
149
 
119
150
 
151
+ def _known_label(line: str) -> bool:
152
+ key, separator, _ = line.partition(":")
153
+ return bool(separator) and key.strip() in TOOLTIP_LABELS
154
+
155
+
120
156
  def tooltip_line(chunk: str) -> str:
121
157
  """One tooltip line's text, whitespace normalized."""
122
158
  # Tooltips are HTML inside an attribute; parse markup only where present.
@@ -130,7 +130,8 @@ class NotificationWorkflow:
130
130
  if uncertain:
131
131
  raise LibrusError(ErrorKind.CHECKPOINT)
132
132
  if not consume:
133
- raise LibrusError(ErrorKind.INVALID_INPUT)
133
+ # Nothing is read before the caller agrees to consume new events.
134
+ raise LibrusError(ErrorKind.CONSENT_REQUIRED)
134
135
  state = await store._io(lambda: store._read_state(context.identifier))
135
136
  ordinary = await self._ordinary(categories, window, budget)
136
137
  # Validate whole ordinary results and state capacity before consuming.
@@ -89,7 +89,6 @@ from librus_python_api.gateway_grades import (
89
89
  parse_user_info,
90
90
  )
91
91
  from librus_python_api.grade_parsers import parse_final_grades
92
- from librus_python_api.grade_records import parse_grade_records
93
92
  from librus_python_api.homework_range import HomeworkAccumulator
94
93
  from librus_python_api.lifecycle import join_owned
95
94
  from librus_python_api.message_content import parse_message_content, validate_reference
@@ -1084,11 +1083,11 @@ class AccountClient:
1084
1083
  Callback success acknowledges consumer-owned durable storage. Callback
1085
1084
  failure/timeout means acknowledgement unknown, never automatic replay.
1086
1085
  """
1087
- if (
1088
- type(allow_consume_events) is not bool
1089
- or not allow_consume_events
1090
- or self._consuming_schedule
1091
- ):
1086
+ if type(allow_consume_events) is not bool:
1087
+ raise LibrusError(ErrorKind.INVALID_INPUT)
1088
+ if not allow_consume_events:
1089
+ raise LibrusError(ErrorKind.CONSENT_REQUIRED)
1090
+ if self._consuming_schedule:
1092
1091
  raise LibrusError(ErrorKind.INVALID_INPUT)
1093
1092
  validate_checkpoint(checkpoint, checkpoint_timeout_seconds)
1094
1093
  self._consuming_schedule = True
@@ -1324,24 +1323,22 @@ class AccountClient:
1324
1323
  """Select a grade view with one view-changing POST.
1325
1324
 
1326
1325
  Changes the selected grade filter, not school records. A cache hit makes
1327
- no POST. Missing inline weight/count data remains unknown. In the all
1328
- view, a page with the new-schema descriptive table adds that table's
1326
+ no POST. Missing inline weight/count data remains unknown.
1327
+
1328
+ Every view checks a new-schema descriptive table the same way. Its
1329
+ semester cells either are empty script hooks or hold bare text, which
1330
+ becomes undated summaries with no extra request; any other layout
1331
+ fails the read. Only the all view with empty hooks adds that table's
1329
1332
  grades from the gateway: three more reads, five when there are grades.
1330
- Other views do not request them.
1331
1333
  """
1332
1334
  form = grade_view_form(view)
1333
1335
 
1334
1336
  async def fetch(budget: RequestBudget, _: bool) -> Grades:
1335
- if view is not GradeView.ALL:
1336
- records = await self._page(
1337
- "grades", budget, parse_grade_records, form=form
1338
- )
1339
- else:
1340
- records, table = await self._page(
1341
- "grades", budget, parse_grade_page, form=form
1342
- )
1343
- if table is not None and not table.filled:
1344
- records = await self._gateway_descriptive(records, table, budget)
1337
+ records, table = await self._page(
1338
+ "grades", budget, parse_grade_page, form=form
1339
+ )
1340
+ if view is GradeView.ALL and table is not None and not table.filled:
1341
+ records = await self._gateway_descriptive(records, table, budget)
1345
1342
  return Grades(
1346
1343
  self._session_identity(), records, self._observation("grades"), view
1347
1344
  )