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.
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/API.md +31 -8
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/CHANGELOG.md +68 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/PKG-INFO +9 -4
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/README.md +8 -3
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/pyproject.toml +2 -2
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/__init__.py +1 -1
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/attendance.py +13 -1
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/config.py +27 -4
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/gateway_grades.py +45 -12
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/grade_records.py +77 -2
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/inspect_page.py +5 -2
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/markup.py +56 -11
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/notification_workflow.py +2 -1
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/service.py +29 -28
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/uv.lock +212 -212
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/.gitignore +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/LICENSE +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/SECURITY.md +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/_notification_bootstrap.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/_notification_codec.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/_storage.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/_windows_filesystem.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/announcements.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/attachment_routes.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/attachments.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/attendance_frequency.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/budget.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/checkpoint.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/class_free_days.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/completed_lessons.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/detail_fields.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/diagnostics.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/exceptions.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/files.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/formative_grades.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/grade_parsers.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/homework_range.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/lifecycle.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/message_content.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/messages.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/models.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/modern_body.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/modern_mailbox.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/modern_messages.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/notification_models.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/notification_persistence.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/notifications.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/parsers.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/parsing.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/persistence.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/py.typed +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/recipients.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/scheduler.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/school_reads.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/school_year_archive.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/sending.py +0 -0
- {librus_python_api-1.8.2 → librus_python_api-1.8.4}/src/librus_python_api/timetable.py +0 -0
- {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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
"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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|